1
This commit is contained in:
parent
d646007bc5
commit
9f799b04f6
@ -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 "",
|
||||
})
|
||||
|
||||
@ -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,'暂无维护日志')}
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user