diff --git a/app/services/live_trading_account.py b/app/services/live_trading_account.py index 62eab37..05a7381 100644 --- a/app/services/live_trading_account.py +++ b/app/services/live_trading_account.py @@ -62,6 +62,16 @@ def _position_pnl_pct(unrealized_pnl: float, margin: float, position_value: floa return 0.0 +def _display_symbol(symbol: str) -> str: + """Normalize ccxt futures symbols like BANK/USDT:USDT for UI display.""" + value = str(symbol or "").strip().upper() + if ":" in value: + value = value.split(":", 1)[0] + if value and "/" not in value and value.endswith("USDT"): + value = value[:-4] + "/USDT" + return value + + 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")) @@ -89,7 +99,7 @@ def _compact_position(item: dict, account: dict | None = None) -> dict: 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"), + "symbol": _display_symbol(item.get("symbol") or info.get("symbol")), "side": side, "side_label": _position_side_label(side), "contracts": abs(contracts), @@ -109,7 +119,7 @@ def _compact_order(item: dict) -> dict: 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"), + "symbol": _display_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"), @@ -143,10 +153,7 @@ def _enrich_positions(positions: list[dict]) -> list[dict]: def _normalize_symbol(symbol: str) -> str: - value = str(symbol or "").strip().upper() - if value and "/" not in value and value.endswith("USDT"): - value = value[:-4] + "/USDT" - return value + return _display_symbol(symbol) def _order_history_symbols(account: dict, overview: dict) -> list[str]: @@ -273,22 +280,121 @@ def _max_drawdown(history: list[dict]) -> tuple[float, float]: return round(max_dd_pct, 6), round(max_dd_usdt, 8) +def _order_ts(order: dict) -> str: + return str(order.get("timestamp") or "") + + +def _order_price(order: dict) -> float: + return _safe_float(order.get("average") or order.get("price")) + + +def _order_amount(order: dict) -> float: + return _safe_float(order.get("filled") or order.get("amount")) + + +def _order_direction(order: dict, *, closing: bool = False) -> str: + side = str(order.get("side") or "").strip().lower() + position_side = str(order.get("position_side") or "").strip().lower() + if position_side in {"long", "short"}: + return position_side + if closing: + if side == "sell": + return "long" + if side == "buy": + return "short" + if side == "buy": + return "long" + if side == "sell": + return "short" + return "" + + +def _realized_pnl_pct(side: str, entry_price: float, exit_price: float) -> float: + if entry_price <= 0 or exit_price <= 0: + return 0.0 + if side == "short": + return round((entry_price / exit_price - 1.0) * 100.0, 6) + return round((exit_price / entry_price - 1.0) * 100.0, 6) + + def _historical_positions_from_orders(orders: list[dict]) -> list[dict]: + """Build a position-level history from exchange order history. + + Binance futures order history is order-centric. For the console we pair + open orders with later reduce-only/realized-PnL orders so users can see + entry price, exit price and whether the completed position made money. + """ + active_lots: dict[tuple[str, str], list[dict]] = {} rows = [] + sorted_orders = sorted(orders or [], key=_order_ts) + for order in sorted_orders: + status = str(order.get("status") or "").lower() + if status not in {"closed", "filled"}: + continue + symbol = _display_symbol(order.get("symbol") or "") + if not symbol: + continue + amount = _order_amount(order) + price = _order_price(order) + pnl = _safe_float(order.get("realized_pnl")) + reduce_only = bool(order.get("reduce_only")) + is_close = reduce_only or abs(pnl) > 0 + direction = _order_direction(order, closing=is_close) + if not direction: + continue + key = (symbol, direction) + if not is_close: + active_lots.setdefault(key, []).append({ + "time": order.get("timestamp") or "", + "entry_price": price, + "amount": amount, + }) + continue + + lot = active_lots.get(key, []).pop(0) if active_lots.get(key) else {} + entry_price = _safe_float(lot.get("entry_price")) + exit_price = price + if pnl == 0 and entry_price > 0 and exit_price > 0 and amount > 0: + raw_pnl = (exit_price - entry_price) * amount if direction == "long" else (entry_price - exit_price) * amount + pnl = round(raw_pnl, 8) + pnl_pct = _realized_pnl_pct(direction, entry_price, exit_price) + result = "盈利" if pnl > 0 else "亏损" if pnl < 0 else "未知" + rows.append({ + "time": order.get("timestamp") or "", + "symbol": symbol, + "side": direction, + "side_label": _position_side_label(direction), + "entry_price": entry_price, + "exit_price": exit_price, + "price": exit_price, + "amount": amount, + "realized_pnl": pnl, + "realized_pnl_pct": pnl_pct, + "result": result, + "status": order.get("status") or "", + }) + + if rows: + return sorted(rows, key=lambda x: str(x.get("time") or ""), reverse=True)[:30] + for order in orders or []: status = str(order.get("status") or "").lower() if status not in {"closed", "filled"}: continue + symbol = _display_symbol(order.get("symbol") or "") 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 "", + "symbol": symbol, "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")), + "entry_price": 0, + "exit_price": _order_price(order), + "price": _order_price(order), + "amount": _order_amount(order), "realized_pnl": pnl, + "realized_pnl_pct": 0, "result": result, "status": order.get("status") or "", }) diff --git a/static/live_trading.html b/static/live_trading.html index d7950dd..6400a99 100644 --- a/static/live_trading.html +++ b/static/live_trading.html @@ -141,7 +141,7 @@ if($('exchangeCacheNote'))$('exchangeCacheNote').textContent=cache.loaded?(cache $('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)+''+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 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.entry_price,8)+''+fmt(x.exit_price||x.price,8)+''+fmt(x.amount,6)+''+signed(x.realized_pnl,4,' U')+''+signed(x.realized_pnl_pct,2,'%')+''+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,'暂无维护日志')} diff --git a/tests/test_live_trading.py b/tests/test_live_trading.py index 0e54637..a213384 100644 --- a/tests/test_live_trading.py +++ b/tests/test_live_trading.py @@ -270,6 +270,86 @@ def test_live_account_overview_fetches_order_history_per_symbol(): assert overview["errors"] == [] +def test_live_account_overview_normalizes_futures_symbols_and_pairs_closed_positions(): + account = upsert_live_account( + account_code="binance_overview_futures_symbol_history", + status="enabled", + risk_config={"max_order_margin_usdt": 10, "max_symbol_leverage": 2, "allowed_symbols": ["BANK/USDT"]}, + ) + + class Client: + def load_markets(self): + return {} + + def fetch_balance(self): + return {"total": {"USDT": 1000}, "free": {"USDT": 980}, "used": {"USDT": 20}} + + def fetch_positions(self, symbols=None): + return [{ + "symbol": "BANK/USDT:USDT", + "contracts": 100, + "entryPrice": 0.1, + "markPrice": 0.11, + "unrealizedPnl": 1, + "info": {"positionAmt": "100", "leverage": "2"}, + }] + + def fetch_open_orders(self, symbol=None): + return [{ + "id": "open-1", + "symbol": "BANK/USDT:USDT", + "type": "limit", + "side": "sell", + "status": "open", + "price": 0.12, + "amount": 100, + "datetime": "2026-06-07T08:00:00", + }] + + def fetch_orders(self, symbol=None, limit=30): + assert symbol == "BANK/USDT" + return [ + { + "id": "entry-1", + "symbol": "BANK/USDT:USDT", + "type": "market", + "side": "buy", + "status": "closed", + "average": 0.1, + "filled": 100, + "datetime": "2026-06-07T08:00:00", + "info": {"reduceOnly": False}, + }, + { + "id": "exit-1", + "symbol": "BANK/USDT:USDT", + "type": "market", + "side": "sell", + "status": "closed", + "average": 0.12, + "filled": 100, + "datetime": "2026-06-07T09:00:00", + "info": {"reduceOnly": True, "realizedPnl": "2"}, + }, + ] + + overview = get_live_account_overview(account["id"], refresh=True, client_factory=lambda account: Client()) + position = overview["positions"][0] + open_order = overview["open_orders"][0] + history = overview["historical_positions"][0] + + assert position["symbol"] == "BANK/USDT" + assert open_order["symbol"] == "BANK/USDT" + assert overview["order_history"][0]["symbol"] == "BANK/USDT" + assert history["symbol"] == "BANK/USDT" + assert history["side"] == "long" + assert history["entry_price"] == 0.1 + assert history["exit_price"] == 0.12 + assert history["realized_pnl"] == 2 + assert history["realized_pnl_pct"] == 20 + assert history["result"] == "盈利" + + def test_live_account_can_be_deleted_without_deleting_history_contract(): account = upsert_live_account(account_code="binance_delete_me", status="disabled")