fix: show header realtime PnL from exchange positions and order metrics

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-07 02:04:40 +08:00
parent 3b494932c7
commit 659c0969fe
7 changed files with 234 additions and 41 deletions
+89 -9
View File
@@ -4,25 +4,91 @@ from __future__ import annotations
from collections.abc import Callable
from typing import Any
from lib.hub.hub_monitor_totals_lib import position_unrealized_pnl
from lib.hub.hub_position_metrics import parse_position_unrealized_pnl
def _position_contracts(pos: dict[str, Any]) -> float:
try:
return abs(float(pos.get("contracts") or 0))
except (TypeError, ValueError):
def position_row_contracts(pos: dict[str, Any]) -> float:
"""持仓张数:与三所 app 内 _position_row_effective_contracts 规则一致。"""
if not isinstance(pos, dict):
return 0.0
info = pos.get("info") or {}
if not isinstance(info, dict):
info = {}
for val in (
pos.get("contracts"),
info.get("positionAmt"),
info.get("size"),
info.get("pos"),
info.get("availPos"),
):
if val is None or val == "":
continue
try:
x = abs(float(val))
if x > 0:
return x
except (TypeError, ValueError):
continue
return 0.0
def sum_unrealized_pnl_from_positions(positions: list[dict[str, Any]] | None) -> float:
def sum_unrealized_pnl_from_positions(positions: list[dict[str, Any]] | None) -> float | None:
total = 0.0
found = False
for p in positions or []:
if not isinstance(p, dict):
continue
if _position_contracts(p) <= 1e-12:
if position_row_contracts(p) <= 1e-12:
continue
total += position_unrealized_pnl(p)
return round(total, 2)
upnl = parse_position_unrealized_pnl(p)
if upnl is None:
continue
found = True
total += float(upnl)
return round(total, 2) if found else None
def _row_field(row: Any, key: str, default: str = "") -> str:
if row is None:
return default
try:
if hasattr(row, "keys") and key in row.keys():
val = row[key]
elif isinstance(row, dict):
val = row.get(key)
else:
val = None
except Exception:
val = None
return str(val or default).strip()
def sum_unrealized_pnl_from_metrics(
rows: list[dict[str, Any]] | list[Any],
get_metrics_fn: Callable[[str, str], dict[str, Any] | None],
) -> float | None:
"""按活跃监控单逐笔拉交易所 metrics 汇总(与持仓卡浮盈亏一致)。"""
total = 0.0
found = False
for row in rows or []:
ex_sym = _row_field(row, "exchange_symbol")
sym = _row_field(row, "symbol")
direction = _row_field(row, "direction", "long").lower() or "long"
target = ex_sym or sym
if not target:
continue
metrics = get_metrics_fn(target, direction)
if not isinstance(metrics, dict):
continue
upnl = metrics.get("unrealized_pnl")
if upnl is None:
continue
try:
total += float(upnl)
found = True
except (TypeError, ValueError):
continue
return round(total, 2) if found else None
def fetch_unrealized_pnl(fetch_positions_fn: Callable[[], list[dict[str, Any]] | None]) -> float | None:
@@ -30,3 +96,17 @@ def fetch_unrealized_pnl(fetch_positions_fn: Callable[[], list[dict[str, Any]] |
return sum_unrealized_pnl_from_positions(fetch_positions_fn() or [])
except Exception:
return None
def resolve_instance_unrealized_pnl(
fetch_positions_fn: Callable[[], list[dict[str, Any]] | None],
active_rows: list[Any] | None,
get_metrics_fn: Callable[[str, str], dict[str, Any] | None] | None,
) -> float | None:
"""先全量持仓汇总,失败或无数据时回退到活跃监控单 metrics。"""
total = fetch_unrealized_pnl(fetch_positions_fn)
if total is not None:
return total
if active_rows and get_metrics_fn:
return sum_unrealized_pnl_from_metrics(active_rows, get_metrics_fn)
return None
+30 -13
View File
@@ -1021,6 +1021,32 @@ function refreshOrderDefaults(){
}).catch(()=>{});
}
function paintRealtimePnl(v){
const pnlEl = document.getElementById("realtime-pnl");
if(!pnlEl) return;
if(v === null || v === undefined || Number.isNaN(Number(v))){
pnlEl.innerText = "—";
pnlEl.classList.remove("pnl-pos", "pnl-neg");
return;
}
const n = Number(v);
const sign = n > 0 ? "+" : "";
pnlEl.innerText = `${sign}${n.toFixed(2)}U`;
pnlEl.classList.toggle("pnl-pos", n > 0);
pnlEl.classList.toggle("pnl-neg", n < 0);
}
function sumOrdersFloatPnl(orders){
if(!orders || !orders.length) return null;
let total = 0, found = false;
orders.forEach(o=>{
if(o.float_pnl != null && !Number.isNaN(Number(o.float_pnl))){
total += Number(o.float_pnl);
found = true;
}
});
return found ? total : null;
}
function refreshAccountSnapshot(){
fetch("/api/account_snapshot").then(r=>r.json()).then(data=>{
if (typeof data.funding_usdt !== "undefined") {
@@ -1032,19 +1058,7 @@ function refreshAccountSnapshot(){
if(el) el.innerText = `${Number(data.current_capital).toFixed(2)}U`;
}
if (typeof data.unrealized_pnl !== "undefined") {
const pnlEl = document.getElementById("realtime-pnl");
if (pnlEl) {
if (data.unrealized_pnl === null || data.unrealized_pnl === undefined) {
pnlEl.innerText = "—";
pnlEl.classList.remove("pnl-pos", "pnl-neg");
} else {
const v = Number(data.unrealized_pnl);
const sign = v > 0 ? "+" : "";
pnlEl.innerText = `${sign}${v.toFixed(2)}U`;
pnlEl.classList.toggle("pnl-pos", v > 0);
pnlEl.classList.toggle("pnl-neg", v < 0);
}
}
paintRealtimePnl(data.unrealized_pnl);
}
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
latestAvailableUsdt = Number(data.available_trading_usdt);
@@ -1281,6 +1295,9 @@ function refreshPriceSnapshotConditional(){
});
tickOrderHoldDurations();
}
if(data.order_prices && data.order_prices.length){
paintRealtimePnl(sumOrdersFloatPnl(data.order_prices));
}
}).catch(()=>{});
}
function formatLiveHoldDurationFromMs(openedMs, nowMs){
+30 -13
View File
@@ -1567,6 +1567,32 @@ function refreshOrderDefaults(){
}).catch(()=>{});
}
function paintRealtimePnl(v){
const pnlEl = document.getElementById("realtime-pnl");
if(!pnlEl) return;
if(v === null || v === undefined || Number.isNaN(Number(v))){
pnlEl.innerText = "—";
pnlEl.classList.remove("pnl-pos", "pnl-neg");
return;
}
const n = Number(v);
const sign = n > 0 ? "+" : "";
pnlEl.innerText = `${sign}${n.toFixed(2)}U`;
pnlEl.classList.toggle("pnl-pos", n > 0);
pnlEl.classList.toggle("pnl-neg", n < 0);
}
function sumOrdersFloatPnl(orders){
if(!orders || !orders.length) return null;
let total = 0, found = false;
orders.forEach(o=>{
if(o.float_pnl != null && !Number.isNaN(Number(o.float_pnl))){
total += Number(o.float_pnl);
found = true;
}
});
return found ? total : null;
}
function refreshAccountSnapshot(){
fetch("/api/account_snapshot").then(r=>r.json()).then(data=>{
if (typeof data.funding_usdt !== "undefined") {
@@ -1578,19 +1604,7 @@ function refreshAccountSnapshot(){
if(el) el.innerText = `${Number(data.current_capital).toFixed(2)}U`;
}
if (typeof data.unrealized_pnl !== "undefined") {
const pnlEl = document.getElementById("realtime-pnl");
if (pnlEl) {
if (data.unrealized_pnl === null || data.unrealized_pnl === undefined) {
pnlEl.innerText = "—";
pnlEl.classList.remove("pnl-pos", "pnl-neg");
} else {
const v = Number(data.unrealized_pnl);
const sign = v > 0 ? "+" : "";
pnlEl.innerText = `${sign}${v.toFixed(2)}U`;
pnlEl.classList.toggle("pnl-pos", v > 0);
pnlEl.classList.toggle("pnl-neg", v < 0);
}
}
paintRealtimePnl(data.unrealized_pnl);
}
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
latestAvailableUsdt = Number(data.available_trading_usdt);
@@ -1870,6 +1884,9 @@ function refreshPriceSnapshotConditional(){
renderOrphanRecoverBanner(data.orphan_live_positions);
{% endif %}
}
if(data.order_prices && data.order_prices.length){
paintRealtimePnl(sumOrdersFloatPnl(data.order_prices));
}
}).catch(()=>{});
}
function formatLiveHoldDurationFromMs(openedMs, nowMs){