From 806350231efe6c60e9bd5fdec541729a4e36939a Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 4 Jun 2026 20:20:36 +0800 Subject: [PATCH] fix(hub): show contract-based unrealized PnL in monitor and chart Co-authored-by: Cursor --- hub_position_metrics.py | 63 +++++++++++++++++ manual_trading_hub/agent.py | 56 ++++++++------- manual_trading_hub/static/app.js | 83 +++++++++++++++++++--- manual_trading_hub/static/chart.js | 100 ++++++++++++++++++++------- manual_trading_hub/static/index.html | 4 +- tests/test_hub_agent_mark_price.py | 24 ++++++- 6 files changed, 267 insertions(+), 63 deletions(-) diff --git a/hub_position_metrics.py b/hub_position_metrics.py index 906e19e..9d9837d 100644 --- a/hub_position_metrics.py +++ b/hub_position_metrics.py @@ -56,6 +56,69 @@ def position_side_from_ccxt(p: dict[str, Any], contracts: float | None = None) - return "long" +def parse_position_entry_price(p: dict[str, Any]) -> float | None: + """四所 ccxt 持仓开仓均价。""" + if not isinstance(p, dict): + return None + info = p.get("info") or {} + if not isinstance(info, dict): + info = {} + return _coerce_float( + p.get("entryPrice"), + p.get("entry_price"), + p.get("average"), + info.get("entryPrice"), + info.get("entry_price"), + info.get("avgPx"), + info.get("avgEntryPrice"), + info.get("avg_entry_price"), + info.get("avgPrice"), + info.get("openAvgPx"), + ) + + +def estimate_linear_swap_upnl_usdt( + side: str, + entry: float | None, + mark: float | None, + contracts: float | None, + contract_size: float | None = None, +) -> float | None: + """U 本位线性永续:浮盈 = (标记价 - 开仓价) × 张数 × contractSize(空头取反)。""" + e = _finite_or_none(entry) + m = _finite_or_none(mark) + c = _finite_or_none(contracts) + if e is None or m is None or c is None or c <= 0: + return None + mult = _finite_or_none(contract_size) + if mult is None or mult <= 0: + mult = 1.0 + diff = (m - e) if (side or "long").strip().lower() == "long" else (e - m) + return round(diff * abs(c) * mult, 2) + + +def resolve_position_display_upnl( + side: str, + entry: float | None, + mark: float | None, + contracts: float | None, + contract_size: float | None, + exchange_upnl: float | None, +) -> float | None: + """展示用浮盈:优先与标记价/张数一致的推算;与交易所值偏差过大时用推算值。""" + computed = estimate_linear_swap_upnl_usdt( + side, entry, mark, contracts, contract_size + ) + if computed is None: + return exchange_upnl + if exchange_upnl is None: + return computed + ref = max(abs(computed), 1.0) + if abs(exchange_upnl - computed) / ref > 0.2: + return computed + return exchange_upnl + + def parse_position_unrealized_pnl(p: dict[str, Any]) -> float | None: """四所 ccxt 持仓统一解析未实现盈亏(Gate 常在 info.unrealised_pnl)。""" if not isinstance(p, dict): diff --git a/manual_trading_hub/agent.py b/manual_trading_hub/agent.py index c0cccaa..9cf96d3 100644 --- a/manual_trading_hub/agent.py +++ b/manual_trading_hub/agent.py @@ -31,7 +31,12 @@ _REPO_ROOT = Path(__file__).resolve().parents[1] if str(_REPO_ROOT) not in sys.path: sys.path.insert(0, str(_REPO_ROOT)) from hub_ohlcv_lib import format_price_by_tick, price_tick_from_market -from hub_position_metrics import parse_position_mark_price, parse_position_unrealized_pnl +from hub_position_metrics import ( + parse_position_entry_price, + parse_position_mark_price, + parse_position_unrealized_pnl, + resolve_position_display_upnl, +) import ccxt from fastapi import FastAPI, Header, HTTPException, Request @@ -388,25 +393,16 @@ def _position_price_fmt(ex: Any, symbol: str, price: float | None) -> tuple[floa def _position_entry_price(p: dict[str, Any]) -> float | None: """四所 ccxt 持仓统一解析开仓均价(Binance/OKX/Gate 字段名不一致)。""" - info = p.get("info") or {} - if not isinstance(info, dict): - info = {} - for key in ( - p.get("entryPrice"), - p.get("entry_price"), - p.get("average"), - info.get("entryPrice"), - info.get("entry_price"), - info.get("avgPx"), - info.get("avgEntryPrice"), - info.get("avg_entry_price"), - info.get("avgPrice"), - info.get("openAvgPx"), - ): - px = _finite_or_none(key) - if px is not None and px > 0: - return px - return None + return parse_position_entry_price(p) + + +def _position_contract_size(ex: Any, symbol: str) -> float: + try: + market = ex.market((symbol or "").strip()) + cs = float(market.get("contractSize") or 1) + return cs if cs > 0 else 1.0 + except Exception: + return 1.0 def _position_mark_price(p: dict[str, Any]) -> float | None: @@ -602,7 +598,20 @@ def _status_inner(x_control_token: str | None) -> Any: continue sym = p.get("symbol") or "" side = _position_side(p, c) - upnl_f = parse_position_unrealized_pnl(p) + entry_f = _position_entry_price(p) + mark_f = _position_mark_price(p) + if mark_f is None and sym: + mark_f = _ticker_mark_price(ex, sym) + cs = _position_contract_size(ex, sym) if sym else 1.0 + exchange_upnl = parse_position_unrealized_pnl(p) + upnl_f = resolve_position_display_upnl( + side, + entry_f, + mark_f, + abs(c), + cs, + exchange_upnl, + ) if upnl_f is None: upnl_f = 0.0 total_upnl += upnl_f @@ -611,11 +620,7 @@ def _status_inner(x_control_token: str | None) -> Any: notional_f = float(notional) if notional is not None else None except (TypeError, ValueError): notional_f = None - entry_f = _position_entry_price(p) _, entry_fmt, price_tick = _position_price_fmt(ex, sym, entry_f) - mark_f = _position_mark_price(p) - if mark_f is None and sym: - mark_f = _ticker_mark_price(ex, sym) _, mark_fmt, mark_tick = _position_price_fmt(ex, sym, mark_f) if price_tick is None and mark_tick is not None: price_tick = mark_tick @@ -631,6 +636,7 @@ def _status_inner(x_control_token: str | None) -> Any: "entry_price_fmt": entry_fmt, "mark_price": mark_f, "mark_price_fmt": mark_fmt, + "contract_size": _finite_or_none(cs), "price_tick": _finite_or_none(price_tick) if price_tick is not None else None, } ) diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index c3b0519..c1447cf 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -336,15 +336,73 @@ return "—"; } - function resolveTrendFloatingPnl(pos, trendPlan) { - let upnl = pos && pos.unrealized_pnl; - if (upnl != null && upnl !== "" && Number.isFinite(Number(upnl))) return Number(upnl); - const t = trendPlan || {}; - if (t.floating_pnl != null && t.floating_pnl !== "") { - const n = Number(t.floating_pnl); - if (Number.isFinite(n)) return n; + function estimateLinearSwapUpnl(side, entry, mark, contracts, contractSize) { + const e = Number(entry); + const m = Number(mark); + const c = Math.abs(Number(contracts)); + let mult = Number(contractSize); + if (!Number.isFinite(mult) || mult <= 0) mult = 1; + if (!Number.isFinite(e) || !Number.isFinite(m) || !Number.isFinite(c) || c <= 0) { + return null; } - return null; + const diff = + (side || "long").toLowerCase() === "long" ? m - e : e - m; + return Math.round(diff * c * mult * 100) / 100; + } + + /** 展示浮盈:子代理 unrealized_pnl;与 entry/mark/张数 推算偏差 >20% 时用推算值 */ + function resolvePositionUpnlUsdt(pos, trendPlan, markOverride) { + const p = pos || {}; + const t = trendPlan || {}; + let exchange = + p.unrealized_pnl != null && p.unrealized_pnl !== "" + ? Number(p.unrealized_pnl) + : null; + if (exchange != null && !Number.isFinite(exchange)) exchange = null; + const entry = + p.entry_price != null && p.entry_price !== "" + ? Number(p.entry_price) + : t.trigger_price != null + ? Number(t.trigger_price) + : null; + let mark = + markOverride != null && Number.isFinite(Number(markOverride)) + ? Number(markOverride) + : p.mark_price != null && p.mark_price !== "" + ? Number(p.mark_price) + : t.floating_mark != null + ? Number(t.floating_mark) + : t.last_mark_price != null + ? Number(t.last_mark_price) + : null; + const contracts = p.contracts; + const cs = + p.contract_size != null && p.contract_size !== "" + ? Number(p.contract_size) + : 1; + const computed = estimateLinearSwapUpnl( + p.side || t.direction, + entry, + mark, + contracts, + cs + ); + if (computed == null) { + if (exchange != null) return exchange; + if (t.floating_pnl != null && t.floating_pnl !== "") { + const n = Number(t.floating_pnl); + if (Number.isFinite(n)) return n; + } + return null; + } + if (exchange == null) return computed; + const ref = Math.max(Math.abs(computed), 1); + if (Math.abs(exchange - computed) / ref > 0.2) return computed; + return exchange; + } + + function resolveTrendFloatingPnl(pos, trendPlan, markOverride) { + return resolvePositionUpnlUsdt(pos, trendPlan, markOverride); } function formatFloatingPnlText(upnl, notionalUsdt) { @@ -1393,7 +1451,10 @@ amount: num(o.amount), }); }); - const upnl = resolveTrendFloatingPnl(pos, trendPlan); + const entryPx = num(pos.entry_price != null ? pos.entry_price : tpsl.entry); + const markPx = num(pos.mark_price); + const contractSize = num(pos.contract_size); + const upnl = resolvePositionUpnlUsdt(pos, trendPlan, markPx); const planMargin = trendPlan && trendPlan.plan_margin_capital != null ? num(trendPlan.plan_margin_capital) @@ -1410,12 +1471,14 @@ exchange_id: exchangeId || null, symbol: (pos.symbol || "").trim(), side: (pos.side || "long").toLowerCase(), - entry: num(tpsl.entry), + entry: entryPx, + mark_price: markPx, stop_loss: num(tpsl.sl), take_profit: num(tpsl.tp), tp_monitored: !!tpsl.tp_monitored, is_trend: !!tpsl.is_trend, contracts: num(pos.contracts), + contract_size: contractSize != null ? contractSize : 1, unrealized_pnl: upnl != null ? Number(upnl) : null, notional_usdt: num(pos.notional_usdt), plan_margin: planMargin, diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index d09efda..2fe4541 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -902,18 +902,48 @@ }, 3500); } + function estimateLinearSwapUpnl(side, entry, mark, contracts, contractSize) { + const e = Number(entry); + const m = Number(mark); + const c = Math.abs(Number(contracts)); + let mult = Number(contractSize); + if (!Number.isFinite(mult) || mult <= 0) mult = 1; + if (!Number.isFinite(e) || !Number.isFinite(m) || !Number.isFinite(c) || c <= 0) { + return null; + } + const diff = + (side || "long").toLowerCase() === "long" ? m - e : e - m; + return Math.round(diff * c * mult * 100) / 100; + } + function formatPosPnlText(ctx) { const upnl = ctx && ctx.unrealized_pnl; if (upnl == null || !Number.isFinite(Number(upnl))) return { text: "—", cls: "" }; const n = Number(upnl); let text = (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; - const margin = ctx.plan_margin; const notional = ctx.notional_usdt; - if (margin != null && Number(margin) > 1e-8) { - const pct = (n / Number(margin)) * 100; + const entry = Number(ctx.entry); + const contracts = Math.abs(Number(ctx.contracts)); + const cs = + ctx.contract_size != null && Number(ctx.contract_size) > 0 + ? Number(ctx.contract_size) + : 1; + let pctBase = null; + if (notional != null && Math.abs(Number(notional)) > 1e-8) { + pctBase = Math.abs(Number(notional)); + } else if ( + Number.isFinite(entry) && + entry > 0 && + Number.isFinite(contracts) && + contracts > 0 + ) { + pctBase = entry * contracts * cs; + } + if (pctBase != null && pctBase > 1e-8) { + const pct = (n / pctBase) * 100; text += " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%)"; - } else if (notional != null && Number(notional) > 1e-8) { - const pct = (n / Math.abs(Number(notional))) * 100; + } else if (ctx.plan_margin != null && Number(ctx.plan_margin) > 1e-8) { + const pct = (n / Number(ctx.plan_margin)) * 100; text += " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%)"; } return { text: text, cls: n > 0 ? "pnl-up" : n < 0 ? "pnl-down" : "" }; @@ -963,20 +993,16 @@ } } - /** 与四实例 calc_pnl 一致:保证金 × 杠杆 × 价格涨跌幅 */ - function calcPlanFloatingPnl(ctx, markPx) { + /** U 本位线性永续:(标记价-开仓价)×张数×contractSize(空头取反) */ + function calcContractsUpnl(ctx, markPx) { if (!ctx || markPx == null || !Number.isFinite(Number(markPx))) return null; - const entry = Number(ctx.entry); - const margin = Number(ctx.plan_margin); - const lev = Number(ctx.leverage); - if (!Number.isFinite(entry) || entry <= 0) return null; - if (!Number.isFinite(margin) || margin <= 0) return null; - if (!Number.isFinite(lev) || lev <= 0) return null; - const mark = Number(markPx); - const side = (ctx.side || "long").toLowerCase(); - const ratio = - side === "short" ? (entry - mark) / entry : (mark - entry) / entry; - return Math.round(margin * lev * ratio * 100) / 100; + return estimateLinearSwapUpnl( + ctx.side, + ctx.entry, + markPx, + ctx.contracts, + ctx.contract_size + ); } function latestChartMarkPrice() { @@ -996,12 +1022,22 @@ ? Number(posContext.mark_price) : null); if (mark == null) return false; - const live = calcPlanFloatingPnl(posContext, mark); - if (live == null) return false; - posContext.unrealized_pnl = live; - posContext.mark_price = mark; - renderPosPnlDisplay(posContext); - return true; + const live = calcContractsUpnl(posContext, mark); + if (live != null) { + posContext.unrealized_pnl = live; + posContext.mark_price = mark; + renderPosPnlDisplay(posContext); + return true; + } + if ( + posContext.unrealized_pnl != null && + Number.isFinite(Number(posContext.unrealized_pnl)) + ) { + posContext.mark_price = mark; + renderPosPnlDisplay(posContext); + return true; + } + return false; } function syncPosTpslFromAgentPosition(p) { @@ -1080,6 +1116,15 @@ const p = positions[j]; if ((p.side || "").toLowerCase() !== side) continue; if (normalizeMarketSymbol(p.symbol || "") !== sym) continue; + if (p.entry_price != null && Number.isFinite(Number(p.entry_price))) { + posContext.entry = Number(p.entry_price); + } + if (p.contract_size != null && Number.isFinite(Number(p.contract_size))) { + posContext.contract_size = Number(p.contract_size); + } + if (p.contracts != null && Number.isFinite(Number(p.contracts))) { + posContext.contracts = Number(p.contracts); + } if (p.mark_price != null && Number.isFinite(Number(p.mark_price))) { posContext.mark_price = Number(p.mark_price); } @@ -1093,7 +1138,12 @@ if (elPosTp && posContext.take_profit != null && !posContext.tp_monitored) { elPosTp.textContent = fmtPrice(posContext.take_profit); } - if (!updateLivePosPnl(p.mark_price)) { + const markForPnl = + latestChartMarkPrice() || + (p.mark_price != null && Number.isFinite(Number(p.mark_price)) + ? Number(p.mark_price) + : null); + if (!updateLivePosPnl(markForPnl)) { let upnl = p.unrealized_pnl != null && Number.isFinite(Number(p.unrealized_pnl)) ? Number(p.unrealized_pnl) diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 9159db9..e47dd4a 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -249,7 +249,7 @@
- - + + diff --git a/tests/test_hub_agent_mark_price.py b/tests/test_hub_agent_mark_price.py index 96ab1c1..6a24b04 100644 --- a/tests/test_hub_agent_mark_price.py +++ b/tests/test_hub_agent_mark_price.py @@ -11,7 +11,11 @@ sys.path.insert(0, str(ROOT / "manual_trading_hub")) from agent import _position_mark_price, _ticker_mark_price # noqa: E402 sys.path.insert(0, str(ROOT)) -from hub_position_metrics import parse_position_unrealized_pnl # noqa: E402 +from hub_position_metrics import ( # noqa: E402 + estimate_linear_swap_upnl_usdt, + parse_position_unrealized_pnl, + resolve_position_display_upnl, +) class TestHubAgentMarkPrice(unittest.TestCase): @@ -48,6 +52,24 @@ class TestHubAgentMarkPrice(unittest.TestCase): ) self.assertAlmostEqual(pnl, 6.81) + def test_estimate_short_hype_contract_size(self): + upnl = estimate_linear_swap_upnl_usdt( + "short", 73.187, 66.038, 11, 0.1 + ) + self.assertAlmostEqual(upnl, 7.86, places=1) + + def test_resolve_prefers_computed_when_exchange_off(self): + shown = resolve_position_display_upnl( + "short", 73.187, 66.038, 11, 1.0, 7.86 + ) + self.assertAlmostEqual(shown, 78.64, places=1) + + def test_resolve_keeps_exchange_when_aligned(self): + shown = resolve_position_display_upnl( + "short", 73.187, 66.038, 11, 0.1, 7.86 + ) + self.assertAlmostEqual(shown, 7.86, places=2) + if __name__ == "__main__": unittest.main()