185 lines
8.1 KiB
Python
185 lines
8.1 KiB
Python
"""Account-centric read model for live trading console."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from app.db.live_trading import _safe_float, get_live_account, list_live_order_events, list_live_order_intents
|
|
from app.integrations.binance_live import LiveTradingConfigError, build_binance_client
|
|
|
|
_ACCOUNT_OVERVIEW_CACHE: dict[int, dict] = {}
|
|
|
|
|
|
def _compact_balance(balance: dict) -> dict:
|
|
total = balance.get("total") if isinstance(balance.get("total"), dict) else {}
|
|
free = balance.get("free") if isinstance(balance.get("free"), dict) else {}
|
|
used = balance.get("used") if isinstance(balance.get("used"), dict) else {}
|
|
assets = []
|
|
for asset in sorted(set(total) | set(free) | set(used)):
|
|
total_value = _safe_float(total.get(asset))
|
|
free_value = _safe_float(free.get(asset))
|
|
used_value = _safe_float(used.get(asset))
|
|
if abs(total_value) > 0 or abs(free_value) > 0 or abs(used_value) > 0:
|
|
assets.append({"asset": asset, "free": free_value, "used": used_value, "total": total_value})
|
|
return {
|
|
"assets": assets,
|
|
"usdt": {
|
|
"free": _safe_float(free.get("USDT")),
|
|
"used": _safe_float(used.get("USDT")),
|
|
"total": _safe_float(total.get("USDT")),
|
|
},
|
|
}
|
|
|
|
|
|
def _position_side_label(side: str) -> str:
|
|
side = str(side or "").strip().lower()
|
|
if side in {"long", "buy"}:
|
|
return "多"
|
|
if side in {"short", "sell"}:
|
|
return "空"
|
|
return "--"
|
|
|
|
|
|
def _compact_position(item: dict, account: dict | None = None) -> dict:
|
|
info = item.get("info") if isinstance(item.get("info"), dict) else {}
|
|
contracts = _safe_float(item.get("contracts") or info.get("positionAmt"))
|
|
notional = _safe_float(item.get("notional") or info.get("notional"))
|
|
entry_price = _safe_float(item.get("entryPrice") or info.get("entryPrice"))
|
|
mark_price = _safe_float(item.get("markPrice") or info.get("markPrice"))
|
|
position_value = abs(notional)
|
|
if position_value <= 0 and abs(contracts) > 0 and mark_price > 0:
|
|
position_value = abs(contracts) * mark_price
|
|
margin = _safe_float(
|
|
item.get("initialMargin")
|
|
or item.get("collateral")
|
|
or info.get("initialMargin")
|
|
or info.get("positionInitialMargin")
|
|
or info.get("isolatedWallet")
|
|
)
|
|
leverage = _safe_float(item.get("leverage") or info.get("leverage"))
|
|
leverage_source = "exchange"
|
|
if leverage <= 0 and position_value > 0 and margin > 0:
|
|
leverage = position_value / margin
|
|
leverage_source = "computed"
|
|
if leverage <= 0 and account:
|
|
risk = account.get("risk_config") if isinstance(account.get("risk_config"), dict) else {}
|
|
leverage = _safe_float(risk.get("max_symbol_leverage"), 0)
|
|
leverage_source = "account_config" if leverage > 0 else "missing"
|
|
side = item.get("side") or ("long" if contracts > 0 else ("short" if contracts < 0 else ""))
|
|
return {
|
|
"symbol": item.get("symbol") or info.get("symbol"),
|
|
"side": side,
|
|
"side_label": _position_side_label(side),
|
|
"contracts": abs(contracts),
|
|
"entry_price": entry_price,
|
|
"mark_price": mark_price,
|
|
"notional": notional,
|
|
"position_value_usdt": position_value,
|
|
"margin_usdt": margin,
|
|
"unrealized_pnl": _safe_float(item.get("unrealizedPnl") or info.get("unrealizedProfit")),
|
|
"leverage": leverage,
|
|
"leverage_source": leverage_source,
|
|
}
|
|
|
|
|
|
def _compact_order(item: dict) -> dict:
|
|
info = item.get("info") if isinstance(item.get("info"), dict) else {}
|
|
return {
|
|
"id": str(item.get("id") or info.get("orderId") or ""),
|
|
"client_order_id": item.get("clientOrderId") or info.get("clientOrderId") or "",
|
|
"symbol": item.get("symbol") or info.get("symbol"),
|
|
"type": item.get("type") or info.get("type"),
|
|
"side": item.get("side") or info.get("side"),
|
|
"status": item.get("status") or info.get("status"),
|
|
"price": _safe_float(item.get("price") or info.get("price")),
|
|
"amount": _safe_float(item.get("amount") or info.get("origQty")),
|
|
"filled": _safe_float(item.get("filled") or info.get("executedQty")),
|
|
"average": _safe_float(item.get("average") or info.get("avgPrice")),
|
|
"timestamp": item.get("datetime") or item.get("timestamp") or info.get("updateTime") or info.get("time"),
|
|
}
|
|
|
|
|
|
def _account_risk_view(account: dict) -> dict:
|
|
risk = account.get("risk_config") if isinstance(account.get("risk_config"), dict) else {}
|
|
allowed = [str(x).strip().upper() for x in risk.get("allowed_symbols", []) if str(x).strip()]
|
|
max_leverage = _safe_float(risk.get("max_symbol_leverage"), 1)
|
|
margin = _safe_float(risk.get("max_order_margin_usdt"), 0)
|
|
return {
|
|
"max_order_margin_usdt": margin,
|
|
"max_symbol_leverage": max_leverage,
|
|
"max_order_notional_usdt": _safe_float(risk.get("max_order_notional_usdt"), margin * max(1.0, max_leverage)),
|
|
"max_cumulative_leverage": _safe_float(risk.get("max_cumulative_leverage"), 1),
|
|
"max_daily_order_count": int(risk.get("max_daily_order_count") or 0),
|
|
"allowed_symbols": allowed,
|
|
"symbol_policy": "all" if not allowed else "allowlist",
|
|
}
|
|
|
|
|
|
def _cache_overview(account_id: int, overview: dict) -> dict:
|
|
_ACCOUNT_OVERVIEW_CACHE[int(account_id)] = overview
|
|
return overview
|
|
|
|
|
|
def _cached_overview(account_id: int) -> dict | None:
|
|
item = _ACCOUNT_OVERVIEW_CACHE.get(int(account_id))
|
|
if not item:
|
|
return None
|
|
cached = dict(item)
|
|
cached["exchange_cache"] = {**(cached.get("exchange_cache") or {}), "cached": True}
|
|
return cached
|
|
|
|
|
|
def get_live_account_overview(account_id: int, *, history_limit: int = 30, refresh: bool = False, client_factory=None) -> dict:
|
|
account = get_live_account(account_id)
|
|
if not account:
|
|
raise LiveTradingConfigError("live account not found")
|
|
overview = {
|
|
"account": account,
|
|
"risk": _account_risk_view(account),
|
|
"balance": {"assets": [], "usdt": {"free": 0, "used": 0, "total": 0}},
|
|
"positions": [],
|
|
"open_orders": [],
|
|
"order_history": [],
|
|
"intent_history": list_live_order_intents(limit=history_limit, account_id=account_id).get("items", []),
|
|
"events": list_live_order_events(limit=history_limit).get("items", []),
|
|
"exchange_cache": {"cached": False, "loaded": False, "requires_refresh": True},
|
|
"errors": [],
|
|
}
|
|
if account.get("status") != "enabled":
|
|
return overview
|
|
if not refresh:
|
|
cached = _cached_overview(account_id)
|
|
if cached:
|
|
cached["account"] = account
|
|
cached["risk"] = overview["risk"]
|
|
cached["intent_history"] = overview["intent_history"]
|
|
cached["events"] = overview["events"]
|
|
return cached
|
|
overview["exchange_cache"]["reason"] = "点击刷新交易所数据后读取余额、持仓和订单"
|
|
return overview
|
|
try:
|
|
client = client_factory(account) if client_factory else build_binance_client(account, require_testnet=True)
|
|
client.load_markets()
|
|
except Exception as exc:
|
|
overview["errors"].append(f"账户连接失败:{exc}")
|
|
return overview
|
|
try:
|
|
overview["balance"] = _compact_balance(client.fetch_balance())
|
|
except Exception as exc:
|
|
overview["errors"].append(f"余额读取失败:{exc}")
|
|
try:
|
|
overview["positions"] = [
|
|
item for item in (_compact_position(p, account) for p in client.fetch_positions(None))
|
|
if abs(_safe_float(item.get("contracts"))) > 0
|
|
]
|
|
except Exception as exc:
|
|
overview["errors"].append(f"持仓读取失败:{exc}")
|
|
try:
|
|
overview["open_orders"] = [_compact_order(o) for o in client.fetch_open_orders(None)]
|
|
except Exception as exc:
|
|
overview["errors"].append(f"挂单读取失败:{exc}")
|
|
try:
|
|
overview["order_history"] = [_compact_order(o) for o in client.fetch_orders(None, limit=history_limit)]
|
|
except Exception as exc:
|
|
overview["errors"].append(f"订单历史读取失败:{exc}")
|
|
overview["exchange_cache"] = {"cached": False, "loaded": True, "requires_refresh": False}
|
|
return _cache_overview(account_id, overview)
|