This commit is contained in:
aaron 2026-06-08 00:41:46 +08:00
parent d646007bc5
commit 9f799b04f6
3 changed files with 196 additions and 10 deletions

View File

@ -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 "",
})

View File

@ -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?'<div class="note" style="grid-column:1/-1">账户数据读取异常:'+esc(errors[0])+'</div>':'')+(!cache.loaded&&a.status==='enabled'?'<div class="note" style="grid-column:1/-1">后台实盘同步还没有生成该账号的账户快照。可以等待调度器下一轮同步,或点击“立即同步”。</div>':'');renderPositions();renderHistoricalPositions();renderOpenOrders();renderOrderHistory();renderEvents()}
function table(headers,rows,empty){if(!rows.length)return '<div class="empty">'+esc(empty||'暂无数据')+'</div>';return '<table><thead><tr>'+headers.map(function(h){return '<th>'+esc(h)+'</th>'}).join('')+'</tr></thead><tbody>'+rows.join('')+'</tbody></table>'}
function renderPositions(){var rows=(state.overview?.positions||[]).map(function(x){var lev=Number(x.leverage||0);return '<tr><td>'+esc(x.symbol)+'</td><td>'+sidePill(x.side)+'</td><td>'+fmt(x.position_value_usdt,2)+' U</td><td>'+fmt(x.margin_usdt,2)+' U</td><td>'+fmt(x.entry_price,6)+'</td><td>'+fmt(x.mark_price,6)+'</td><td>'+signed(x.unrealized_pnl,4,' U')+'</td><td>'+signed(x.pnl_pct,2,'%')+'</td><td>'+fmt(lev,2)+'x</td></tr>'});$('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 '<tr><td>'+time(x.time)+'</td><td>'+esc(x.symbol)+'</td><td>'+sidePill(x.side)+'</td><td>'+fmt(x.price,8)+'</td><td>'+fmt(x.amount,6)+'</td><td>'+signed(x.realized_pnl,4,' U')+'</td><td><span class="result-pill '+cls+'">'+esc(x.result||'未知')+'</span></td></tr>'});$('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 '<tr><td>'+time(x.time)+'</td><td>'+esc(x.symbol)+'</td><td>'+sidePill(x.side)+'</td><td>'+fmt(x.entry_price,8)+'</td><td>'+fmt(x.exit_price||x.price,8)+'</td><td>'+fmt(x.amount,6)+'</td><td>'+signed(x.realized_pnl,4,' U')+'</td><td>'+signed(x.realized_pnl_pct,2,'%')+'</td><td><span class="result-pill '+cls+'">'+esc(x.result||'未知')+'</span></td></tr>'});$('historicalPositions').innerHTML=table(['时间','币种','方向','开仓价','平仓价','数量','已实现盈亏','收益率','结果'],rows,'暂无可识别的历史仓位盈亏。若交易所订单未返回 realizedPnl只能在订单历史查看成交记录。')}
function renderOpenOrders(){var rows=(state.overview?.open_orders||[]).map(function(x){return '<tr><td>'+time(x.timestamp)+'</td><td>'+esc(x.symbol)+'</td><td>'+esc(x.type)+'</td><td>'+sidePill(x.side)+'</td><td>'+fmt(x.price,8)+'</td><td>'+fmt(x.amount,6)+'</td><td>'+badge(x.status)+'</td></tr>'});$('openOrders').innerHTML=table(['时间','币种','类型','方向','价格','数量','状态'],rows,'当前账号暂无挂单')}
function renderOrderHistory(){var orders=(state.overview?.order_history||[]).map(function(x){return '<tr><td>'+time(x.timestamp)+'</td><td>'+esc(x.symbol)+'</td><td>'+esc(x.type)+'</td><td>'+sidePill(x.side)+'</td><td>'+fmt(x.average||x.price,8)+'</td><td>'+fmt(x.filled||x.amount,6)+'</td><td>'+signed(x.realized_pnl,4,' U')+'</td><td>'+badge(x.status)+'</td></tr>'});$('orderHistory').innerHTML=table(['时间','币种','类型','方向','均价/价格','成交/数量','已实现盈亏','状态'],orders,'当前账号暂无订单历史')}
function renderEvents(){var rows=(state.events||[]).slice(0,20).map(function(e){return '<tr><td>'+time(e.event_time)+'</td><td>'+esc(e.event_type)+'</td><td>'+badge(e.status)+'</td><td>'+esc(e.message||'--')+'</td></tr>'});$('events').innerHTML=table(['时间','事件','状态','说明'],rows,'暂无维护日志')}

View File

@ -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")