diff --git a/app/db/live_trading.py b/app/db/live_trading.py index 86dfd65..1afa200 100644 --- a/app/db/live_trading.py +++ b/app/db/live_trading.py @@ -332,6 +332,76 @@ def get_live_account_snapshot(account_id: int) -> dict: return _row(row) +def record_live_account_equity_snapshot( + account_id: int, + *, + equity_usdt: float = 0, + wallet_balance_usdt: float = 0, + available_usdt: float = 0, + used_margin_usdt: float = 0, + unrealized_pnl_usdt: float = 0, + open_position_value_usdt: float = 0, + position_count: int = 0, + snapshot_at: str = "", +) -> dict: + account_id = _safe_int(account_id) + if account_id <= 0: + return {"ok": False, "reason": "invalid_account_id"} + snapshot_at = snapshot_at or _now() + conn = get_conn() + try: + row = conn.execute( + """ + INSERT INTO live_account_equity_history ( + account_id, equity_usdt, wallet_balance_usdt, available_usdt, + used_margin_usdt, unrealized_pnl_usdt, open_position_value_usdt, + position_count, snapshot_at + ) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) + RETURNING * + """, + ( + account_id, + _safe_float(equity_usdt), + _safe_float(wallet_balance_usdt), + _safe_float(available_usdt), + _safe_float(used_margin_usdt), + _safe_float(unrealized_pnl_usdt), + _safe_float(open_position_value_usdt), + _safe_int(position_count), + snapshot_at, + ), + ).fetchone() + conn.commit() + finally: + conn.close() + item = _row(row) + item["ok"] = True + return item + + +def list_live_account_equity_history(account_id: int, limit: int = 500) -> list[dict]: + account_id = _safe_int(account_id) + if account_id <= 0: + return [] + limit = max(1, min(_safe_int(limit, 500), 2000)) + conn = get_conn() + try: + rows = conn.execute( + """ + SELECT * + FROM live_account_equity_history + WHERE account_id=%s + ORDER BY snapshot_at ASC, id ASC + LIMIT %s + """, + (account_id, limit), + ).fetchall() + finally: + conn.close() + return [_row(r) for r in rows] + + def _config_for_account(account: dict | None = None) -> dict: cfg = get_effective_live_trading_config() if account: diff --git a/app/db/migrations/0022_live_account_equity_history.sql b/app/db/migrations/0022_live_account_equity_history.sql new file mode 100644 index 0000000..b8753d5 --- /dev/null +++ b/app/db/migrations/0022_live_account_equity_history.sql @@ -0,0 +1,17 @@ +-- Equity history for live account performance dashboards. + +CREATE TABLE IF NOT EXISTS live_account_equity_history ( + id BIGSERIAL PRIMARY KEY, + account_id BIGINT NOT NULL REFERENCES live_trade_accounts(id) ON DELETE CASCADE, + equity_usdt DOUBLE PRECISION NOT NULL DEFAULT 0, + wallet_balance_usdt DOUBLE PRECISION NOT NULL DEFAULT 0, + available_usdt DOUBLE PRECISION NOT NULL DEFAULT 0, + used_margin_usdt DOUBLE PRECISION NOT NULL DEFAULT 0, + unrealized_pnl_usdt DOUBLE PRECISION NOT NULL DEFAULT 0, + open_position_value_usdt DOUBLE PRECISION NOT NULL DEFAULT 0, + position_count INTEGER NOT NULL DEFAULT 0, + snapshot_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_live_account_equity_history_account_time + ON live_account_equity_history(account_id, snapshot_at DESC); diff --git a/app/services/live_trading_account.py b/app/services/live_trading_account.py index 64f3682..62eab37 100644 --- a/app/services/live_trading_account.py +++ b/app/services/live_trading_account.py @@ -8,9 +8,11 @@ from app.db.live_trading import ( _safe_float, get_live_account, get_live_account_snapshot, + list_live_account_equity_history, list_enabled_live_accounts, list_live_order_events, list_live_order_intents, + record_live_account_equity_snapshot, upsert_live_account_snapshot, ) from app.integrations.binance_live import LiveTradingConfigError, build_binance_client @@ -52,6 +54,14 @@ def _position_side_label(side: str) -> str: return "--" +def _position_pnl_pct(unrealized_pnl: float, margin: float, position_value: float) -> float: + if margin > 0: + return round(unrealized_pnl / margin * 100.0, 6) + if position_value > 0: + return round(unrealized_pnl / position_value * 100.0, 6) + return 0.0 + + 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")) @@ -107,10 +117,31 @@ def _compact_order(item: dict) -> dict: "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")), + "realized_pnl": _safe_float(item.get("realizedPnl") or info.get("realizedPnl") or info.get("realizedProfit")), + "reduce_only": bool(item.get("reduceOnly") or info.get("reduceOnly")), + "position_side": item.get("positionSide") or info.get("positionSide") or "", "timestamp": item.get("datetime") or item.get("timestamp") or info.get("updateTime") or info.get("time"), } +def _enrich_positions(positions: list[dict]) -> list[dict]: + enriched = [] + for item in positions or []: + row = dict(item) + unrealized = _safe_float(row.get("unrealized_pnl")) + margin = _safe_float(row.get("margin_usdt")) + value = _safe_float(row.get("position_value_usdt")) + row["pnl_pct"] = _position_pnl_pct(unrealized, margin, value) + if unrealized > 0: + row["pnl_status"] = "profit" + elif unrealized < 0: + row["pnl_status"] = "loss" + else: + row["pnl_status"] = "flat" + enriched.append(row) + return enriched + + def _normalize_symbol(symbol: str) -> str: value = str(symbol or "").strip().upper() if value and "/" not in value and value.endswith("USDT"): @@ -198,14 +229,109 @@ def _exchange_snapshot_payload(overview: dict) -> dict: "order_history": overview.get("order_history") or [], "exchange_cache": overview.get("exchange_cache") or {}, "errors": overview.get("errors") or [], + "performance": overview.get("performance") or {}, + "historical_positions": overview.get("historical_positions") or [], } +def _current_equity_metrics(overview: dict) -> dict: + balance = overview.get("balance") if isinstance(overview.get("balance"), dict) else {} + usdt = balance.get("usdt") if isinstance(balance.get("usdt"), dict) else {} + positions = overview.get("positions") or [] + equity = _safe_float(usdt.get("total")) + available = _safe_float(usdt.get("free")) + used = _safe_float(usdt.get("used")) + unrealized = sum(_safe_float(x.get("unrealized_pnl")) for x in positions) + position_value = sum(abs(_safe_float(x.get("position_value_usdt"))) for x in positions) + return { + "equity_usdt": round(equity, 8), + "wallet_balance_usdt": round(equity - unrealized, 8), + "available_usdt": round(available, 8), + "used_margin_usdt": round(used, 8), + "unrealized_pnl_usdt": round(unrealized, 8), + "open_position_value_usdt": round(position_value, 8), + "position_count": len(positions), + } + + +def _max_drawdown(history: list[dict]) -> tuple[float, float]: + peak = 0.0 + max_dd_pct = 0.0 + max_dd_usdt = 0.0 + for row in history: + equity = _safe_float(row.get("equity_usdt")) + if equity <= 0: + continue + peak = max(peak, equity) + if peak <= 0: + continue + dd_usdt = max(0.0, peak - equity) + dd_pct = dd_usdt / peak * 100.0 + if dd_pct > max_dd_pct: + max_dd_pct = dd_pct + max_dd_usdt = dd_usdt + return round(max_dd_pct, 6), round(max_dd_usdt, 8) + + +def _historical_positions_from_orders(orders: list[dict]) -> list[dict]: + rows = [] + for order in orders or []: + status = str(order.get("status") or "").lower() + if status not in {"closed", "filled"}: + continue + pnl = _safe_float(order.get("realized_pnl")) + result = "盈利" if pnl > 0 else "亏损" if pnl < 0 else "未知" + rows.append({ + "time": order.get("timestamp") or "", + "symbol": order.get("symbol") or "", + "side": order.get("side") or "", + "side_label": _position_side_label(order.get("side")), + "price": _safe_float(order.get("average") or order.get("price")), + "amount": _safe_float(order.get("filled") or order.get("amount")), + "realized_pnl": pnl, + "result": result, + "status": order.get("status") or "", + }) + return rows[:30] + + +def _attach_performance(overview: dict, account_id: int, *, record_history: bool = False, snapshot_at: str = "") -> dict: + overview["positions"] = _enrich_positions(overview.get("positions") or []) + metrics = _current_equity_metrics(overview) + if record_history and metrics["equity_usdt"] > 0: + record_live_account_equity_snapshot(account_id, **metrics, snapshot_at=snapshot_at or _now()) + history = list_live_account_equity_history(account_id) + if not history and metrics["equity_usdt"] > 0: + history = [{**metrics, "snapshot_at": snapshot_at or _now()}] + baseline = _safe_float(history[0].get("equity_usdt")) if history else metrics["equity_usdt"] + peak = max([_safe_float(x.get("equity_usdt")) for x in history] + [metrics["equity_usdt"], 0.0]) + total_pnl = metrics["equity_usdt"] - baseline if baseline > 0 else 0.0 + return_pct = total_pnl / baseline * 100.0 if baseline > 0 else 0.0 + current_dd_usdt = max(0.0, peak - metrics["equity_usdt"]) + current_dd_pct = current_dd_usdt / peak * 100.0 if peak > 0 else 0.0 + max_dd_pct, max_dd_usdt = _max_drawdown(history + [{**metrics, "snapshot_at": snapshot_at or _now()}]) + overview["performance"] = { + **metrics, + "baseline_equity_usdt": round(baseline, 8), + "total_pnl_usdt": round(total_pnl, 8), + "return_pct": round(return_pct, 6), + "peak_equity_usdt": round(peak, 8), + "current_drawdown_usdt": round(current_dd_usdt, 8), + "current_drawdown_pct": round(current_dd_pct, 6), + "max_drawdown_usdt": max_dd_usdt, + "max_drawdown_pct": max_dd_pct, + "history_points": len(history), + "basis": "按首次同步净值计算,未单独扣除充值/提现影响", + } + overview["historical_positions"] = _historical_positions_from_orders(overview.get("order_history") or []) + return overview + + def _merge_snapshot(overview: dict, snapshot_row: dict) -> dict: payload = snapshot_row.get("snapshot") if isinstance(snapshot_row.get("snapshot"), dict) else {} if not payload: return overview - for key in ("balance", "positions", "open_orders", "order_history", "errors"): + for key in ("balance", "positions", "open_orders", "order_history", "errors", "performance", "historical_positions"): if key in payload: overview[key] = payload.get(key) synced_at = snapshot_row.get("synced_at") or "" @@ -222,7 +348,7 @@ def _merge_snapshot(overview: dict, snapshot_row: dict) -> dict: } if status == "error" and error_message and error_message not in (overview.get("errors") or []): overview.setdefault("errors", []).append(error_message) - return overview + return _attach_performance(overview, int(overview.get("account", {}).get("id") or 0)) def get_live_account_overview(account_id: int, *, history_limit: int = 30, refresh: bool = False, client_factory=None) -> dict: @@ -236,8 +362,10 @@ def get_live_account_overview(account_id: int, *, history_limit: int = 30, refre "positions": [], "open_orders": [], "order_history": [], + "historical_positions": [], "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", []), + "performance": {}, "exchange_cache": {"cached": False, "loaded": False, "requires_refresh": True}, "errors": [], } @@ -270,6 +398,7 @@ def get_live_account_overview(account_id: int, *, history_limit: int = 30, refre "status": "error", "synced_at": synced_at, } + overview = _attach_performance(overview, account_id, record_history=False, snapshot_at=synced_at) upsert_live_account_snapshot( account_id, _exchange_snapshot_payload(overview), @@ -305,6 +434,7 @@ def get_live_account_overview(account_id: int, *, history_limit: int = 30, refre "status": "error" if overview["errors"] else "ok", "synced_at": synced_at, } + overview = _attach_performance(overview, account_id, record_history=True, snapshot_at=synced_at) upsert_live_account_snapshot( account_id, _exchange_snapshot_payload(overview), diff --git a/static/live_trading.html b/static/live_trading.html index 9427702..d7950dd 100644 --- a/static/live_trading.html +++ b/static/live_trading.html @@ -2,7 +2,7 @@ {% block title %}AlphaX Agent — 实盘控制台{% endblock %} {% block extra_head_css %} {% endblock %} {% block content %} @@ -29,23 +29,37 @@
账号加载中
- + + + - +
-
资金与持仓
默认读取数据库快照,后台定时同步交易所数据
+
账号权益表现
默认读取数据库快照,后台定时同步交易所数据
-
-
+
+
+
当前持仓
每个仓位的方向、保证金、浮盈亏和收益率
+
+
+
+ +
+
+
历史仓位
基于交易所订单历史中可识别的已完成记录
+
+
+
+
当前挂单
交易所未完成订单
@@ -111,21 +125,25 @@ function fmt(n,d){n=Number(n||0);return n.toLocaleString(undefined,{maximumFract function time(s){return esc(String(s||'').replace('T',' ').slice(0,19))} function badge(v){var cls=v==='enabled'||v==='ok'||v==='closed'?'green':(v==='disabled'||v==='error'?'red':(v==='open'||v==='exchange_api'?'blue':'warn'));return ''+esc(v||'--')+''} function sideText(v){v=String(v||'').toLowerCase();if(v==='long'||v==='buy')return '多';if(v==='short'||v==='sell')return '空';return v||'--'} +function sidePill(v){var s=String(v||'').toLowerCase(),isShort=s==='short'||s==='sell'||s==='空';return ''+(isShort?'空':'多')+''} +function pnlClass(n){n=Number(n||0);return n>0?'green':n<0?'red':''} +function signed(n,d,suffix){n=Number(n||0);var txt=(n>0?'+':'')+fmt(n,d==null?2:d)+(suffix||'');return ''+txt+''} function selectedAccountObj(){return (state.accounts||[]).find(function(x){return Number(x.id)===Number(state.selectedId)})||{}} function showTab(id,btn){document.querySelectorAll('.tab').forEach(function(x){x.classList.remove('active')});document.querySelectorAll('.tab-panel').forEach(function(x){x.classList.remove('active')});btn.classList.add('active');$(id+'Pane').classList.add('active')} function card(k,v,cls,s){return '
'+esc(k)+''+esc(v)+''+esc(s||'')+'
'} function renderAccounts(){var rows=state.accounts||[];if(!rows.length){$('accounts').innerHTML='
暂无账号配置
';return}$('accounts').innerHTML=rows.map(function(x){var r=x.risk_config||{},allowed=r.allowed_symbols||[];return ''}).join('')} -function renderKpis(){var a=selectedAccountObj(),o=state.overview||{},risk=o.risk||{},b=(o.balance||{}).usdt||{},pos=o.positions||[];$('kpis').innerHTML=[card('当前账号',a.account_code||'未选择','blue',a.exchange?esc(a.exchange)+' · '+esc(a.market_type):'--'),card('USDT 总额',fmt(b.total,2),'','可用 '+fmt(b.free,2)+' / 占用 '+fmt(b.used,2)),card('持仓数量',pos.length,'','当前未平仓合约'),card('币种权限',risk.symbol_policy==='all'?'全部币种':'白名单 '+(risk.allowed_symbols||[]).length+' 个',risk.symbol_policy==='all'?'green':'blue','留空即不限制')].join('')} +function renderKpis(){var a=selectedAccountObj(),o=state.overview||{},p=o.performance||{},pos=o.positions||[];$('kpis').innerHTML=[card('当前净值',fmt(p.equity_usdt,2)+'U','blue',a.account_code||'未选择账号'),card('总盈亏',(Number(p.total_pnl_usdt||0)>0?'+':'')+fmt(p.total_pnl_usdt,2)+'U',pnlClass(p.total_pnl_usdt),'按首次同步净值计算'),card('收益率',(Number(p.return_pct||0)>0?'+':'')+fmt(p.return_pct,2)+'%',pnlClass(p.return_pct),'基准 '+fmt(p.baseline_equity_usdt,2)+'U'),card('当前/最大回撤',fmt(p.current_drawdown_pct,2)+'% / '+fmt(p.max_drawdown_pct,2)+'%','red','持仓 '+pos.length+' 个 · 浮盈 '+(Number(p.unrealized_pnl_usdt||0)>0?'+':'')+fmt(p.unrealized_pnl_usdt,2)+'U')].join('')} function fillAccountForm(id){var x=(state.accounts||[]).find(function(a){return Number(a.id)===Number(id)})||{},r=x.risk_config||{};$('accountCode').value=x.account_code||'';$('accountStatus').value=x.status||'disabled';$('accountExchange').value=x.exchange||'binance';$('accountMarket').value=x.market_type||'um_futures';$('apiKeyEnv').value=x.api_key_env||'ALPHAX_BINANCE_API_KEY';$('apiSecretEnv').value=x.api_secret_env||'ALPHAX_BINANCE_API_SECRET';$('maxOrderMargin').value=r.max_order_margin_usdt||10;$('maxSymbolLeverage').value=r.max_symbol_leverage||1;$('maxCumulativeLeverage').value=r.max_cumulative_leverage||1;$('allowedSymbols').value=(r.allowed_symbols||[]).join(',');if($('saveAccountBtn'))$('saveAccountBtn').textContent=Number(id)>0?'保存修改':'新增账号'} function resetForm(){state.selectedId=0;state.overview=null;renderAccounts();fillAccountForm(0);renderKpis();renderOverview();document.querySelectorAll('.tab').forEach(function(x){x.classList.remove('active')});document.querySelectorAll('.tab-panel').forEach(function(x){x.classList.remove('active')});document.querySelector('.tab[onclick*="config"]').classList.add('active');$('configPane').classList.add('active')} function info(k,v){return '
'+esc(k)+''+esc(v)+'
'} -function renderOverview(){var a=selectedAccountObj(),o=state.overview||{},risk=o.risk||{},errors=o.errors||[],cache=o.exchange_cache||{},syncText=cache.synced_at?(' · 同步 '+time(cache.synced_at)):''; +function renderOverview(){var a=selectedAccountObj(),o=state.overview||{},risk=o.risk||{},p=o.performance||{},b=(o.balance||{}).usdt||{},errors=o.errors||[],cache=o.exchange_cache||{},syncText=cache.synced_at?(' · 同步 '+time(cache.synced_at)):''; if($('exchangeCacheNote'))$('exchangeCacheNote').textContent=cache.loaded?(cache.source==='database'?'读取数据库快照'+syncText:'交易所数据已同步'+syncText):(cache.reason||'等待后台同步生成账户快照'); -$('accountInfo').innerHTML=[info('账号状态',a.status||'--'),info('API Key 变量',a.api_key_env||'--'),info('每单保证金上限',fmt(risk.max_order_margin_usdt,2)+' USDT'),info('单币杠杆上限',fmt(risk.max_symbol_leverage,2)+'x'),info('累计杠杆上限',fmt(risk.max_cumulative_leverage,2)+'x'),info('允许交易币种',risk.symbol_policy==='all'?'全部币种':(risk.allowed_symbols||[]).join(', '))].join('')+(errors.length?'
账户数据读取异常:'+esc(errors[0])+'
':'')+(!cache.loaded&&a.status==='enabled'?'
后台实盘同步还没有生成该账号的账户快照。可以等待调度器下一轮同步,或点击“立即同步”。
':'');renderPositions();renderOpenOrders();renderOrderHistory();renderEvents()} +$('accountInfo').innerHTML=[info('可用资金',fmt(b.free,2)+' USDT'),info('保证金占用',fmt(b.used||p.used_margin_usdt,2)+' USDT'),info('持仓名义价值',fmt(p.open_position_value_usdt,2)+' USDT'),info('浮动盈亏',(Number(p.unrealized_pnl_usdt||0)>0?'+':'')+fmt(p.unrealized_pnl_usdt,2)+' USDT'),info('权益峰值',fmt(p.peak_equity_usdt,2)+' USDT'),info('统计口径',p.basis||'等待首次同步')].join('')+(errors.length?'
账户数据读取异常:'+esc(errors[0])+'
':'')+(!cache.loaded&&a.status==='enabled'?'
后台实盘同步还没有生成该账号的账户快照。可以等待调度器下一轮同步,或点击“立即同步”。
':'');renderPositions();renderHistoricalPositions();renderOpenOrders();renderOrderHistory();renderEvents()} function table(headers,rows,empty){if(!rows.length)return '
'+esc(empty||'暂无数据')+'
';return ''+headers.map(function(h){return ''}).join('')+''+rows.join('')+'
'+esc(h)+'
'} -function renderPositions(){var rows=(state.overview?.positions||[]).map(function(x){var lev=Number(x.leverage||0);return ''+esc(x.symbol)+''+badge(x.side_label||sideText(x.side))+''+fmt(x.contracts,6)+''+fmt(x.position_value_usdt,2)+' U'+fmt(x.entry_price,6)+''+fmt(x.mark_price,6)+''+fmt(x.unrealized_pnl,4)+''+fmt(lev,2)+'x'});$('positions').innerHTML=table(['币种','方向','数量','仓位价值','开仓价','标记价','未实现盈亏','杠杆'],rows,'当前账号暂无持仓')} -function renderOpenOrders(){var rows=(state.overview?.open_orders||[]).map(function(x){return ''+time(x.timestamp)+''+esc(x.symbol)+''+esc(x.type)+''+badge(sideText(x.side))+''+fmt(x.price,8)+''+fmt(x.amount,6)+''+badge(x.status)+''});$('openOrders').innerHTML=table(['时间','币种','类型','方向','价格','数量','状态'],rows,'当前账号暂无挂单')} -function renderOrderHistory(){var orders=(state.overview?.order_history||[]).map(function(x){return ''+time(x.timestamp)+''+esc(x.symbol)+''+esc(x.type)+''+badge(sideText(x.side))+''+fmt(x.average||x.price,8)+''+fmt(x.filled||x.amount,6)+''+badge(x.status)+''});$('orderHistory').innerHTML=table(['时间','币种','类型','方向','均价/价格','成交/数量','状态'],orders,'当前账号暂无订单历史')} +function renderPositions(){var rows=(state.overview?.positions||[]).map(function(x){var lev=Number(x.leverage||0);return ''+esc(x.symbol)+''+sidePill(x.side)+''+fmt(x.position_value_usdt,2)+' U'+fmt(x.margin_usdt,2)+' U'+fmt(x.entry_price,6)+''+fmt(x.mark_price,6)+''+signed(x.unrealized_pnl,4,' U')+''+signed(x.pnl_pct,2,'%')+''+fmt(lev,2)+'x'});$('positions').innerHTML=table(['币种','方向','仓位价值','保证金','开仓价','标记价','浮盈亏','收益率','杠杆'],rows,'当前账号暂无持仓')} +function renderHistoricalPositions(){var rows=(state.overview?.historical_positions||[]).map(function(x){var cls=Number(x.realized_pnl||0)>0?'profit':Number(x.realized_pnl||0)<0?'loss':'flat';return ''+time(x.time)+''+esc(x.symbol)+''+sidePill(x.side)+''+fmt(x.price,8)+''+fmt(x.amount,6)+''+signed(x.realized_pnl,4,' U')+''+esc(x.result||'未知')+''});$('historicalPositions').innerHTML=table(['时间','币种','方向','成交价','数量','已实现盈亏','结果'],rows,'暂无可识别的历史仓位盈亏。若交易所订单未返回 realizedPnl,只能在订单历史查看成交记录。')} +function renderOpenOrders(){var rows=(state.overview?.open_orders||[]).map(function(x){return ''+time(x.timestamp)+''+esc(x.symbol)+''+esc(x.type)+''+sidePill(x.side)+''+fmt(x.price,8)+''+fmt(x.amount,6)+''+badge(x.status)+''});$('openOrders').innerHTML=table(['时间','币种','类型','方向','价格','数量','状态'],rows,'当前账号暂无挂单')} +function renderOrderHistory(){var orders=(state.overview?.order_history||[]).map(function(x){return ''+time(x.timestamp)+''+esc(x.symbol)+''+esc(x.type)+''+sidePill(x.side)+''+fmt(x.average||x.price,8)+''+fmt(x.filled||x.amount,6)+''+signed(x.realized_pnl,4,' U')+''+badge(x.status)+''});$('orderHistory').innerHTML=table(['时间','币种','类型','方向','均价/价格','成交/数量','已实现盈亏','状态'],orders,'当前账号暂无订单历史')} function renderEvents(){var rows=(state.events||[]).slice(0,20).map(function(e){return ''+time(e.event_time)+''+esc(e.event_type)+''+badge(e.status)+''+esc(e.message||'--')+''});$('events').innerHTML=table(['时间','事件','状态','说明'],rows,'暂无维护日志')} function renderAll(){if(!state.selectedId && state.accounts[0])state.selectedId=state.accounts[0].id;renderAccounts();fillAccountForm(state.selectedId);renderKpis();renderOverview()} async function selectAccount(id){state.selectedId=Number(id);renderAccounts();fillAccountForm(id);await loadOverview()} diff --git a/tests/conftest.py b/tests/conftest.py index f73044a..0d0a2cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -78,6 +78,7 @@ _ID_TABLES = { "paper_trades", "paper_trade_events", "live_account_snapshots", + "live_account_equity_history", "live_trade_accounts", "live_order_intents", "live_order_events", diff --git a/tests/test_live_trading.py b/tests/test_live_trading.py index 9f4a535..95d54c1 100644 --- a/tests/test_live_trading.py +++ b/tests/test_live_trading.py @@ -60,7 +60,9 @@ def test_live_trading_admin_can_access_page_and_seed_account(): assert page.status_code == 200 assert "实盘控制台" in page.text - assert "账户总览" in page.text + assert "账户表现" in page.text + assert "当前净值" in page.text + assert "历史仓位" in page.text assert "留空=全部" in page.text assert created.status_code == 200 assert created.json()["account_code"] == "binance_sub_1" @@ -173,6 +175,9 @@ def test_live_account_overview_refresh_compacts_position_value_side_and_leverage assert pos["position_value_usdt"] == 1520 assert pos["leverage"] == 3 assert pos["leverage_source"] == "account_config" + assert round(pos["pnl_pct"], 2) == 1.32 + assert overview["performance"]["equity_usdt"] == 1000 + assert overview["performance"]["unrealized_pnl_usdt"] == 20 def test_live_account_overview_refresh_persists_database_snapshot_and_reads_without_exchange(monkeypatch): @@ -252,6 +257,7 @@ def test_live_account_overview_fetches_order_history_per_symbol(): "amount": 2, "filled": 2, "datetime": "2026-05-30T08:00:00", + "info": {"realizedPnl": "3.5"}, }] client = Client() @@ -259,6 +265,7 @@ def test_live_account_overview_fetches_order_history_per_symbol(): assert client.order_symbols == ["BTC/USDT", "ETH/USDT"] assert len(overview["order_history"]) == 2 + assert overview["historical_positions"][0]["result"] == "盈利" assert overview["errors"] == []