diff --git a/lib/hub/hub_options_funds_lib.py b/lib/hub/hub_options_funds_lib.py index e466747..6c030a3 100644 --- a/lib/hub/hub_options_funds_lib.py +++ b/lib/hub/hub_options_funds_lib.py @@ -74,21 +74,50 @@ def options_balances_usdt_equiv(options_snap: dict[str, Any] | None) -> dict[str def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[float]: + """期权浮盈合计(USDT).币本位按指数换算,勿把 ETH/BTC 数量当 U.""" snap = options_snap if isinstance(options_snap, dict) else {} if snap.get("enabled") is False or snap.get("ok") is False: return None - upl = snap.get("upl_total_usdc") - if upl is not None: + + def _safe(v: Any) -> float | None: + if v is None or v == "": + return None try: - return round(float(upl), 4) + return float(v) except (TypeError, ValueError): - pass - # 快照偶发缺合计时,按持仓行回退汇总(与卡片展示一致) + return None + + def _index_px() -> float | None: + px = _safe(snap.get("options_index_px") or snap.get("index_px")) + if px is not None and px > 0: + return px + for p in snap.get("positions") or []: + if not isinstance(p, dict): + continue + px = _safe(p.get("idx_px") or p.get("idxPx")) + if px is not None and px > 0: + return px + return None + + def _row_is_coin(p: dict[str, Any]) -> bool: + ccy = str(p.get("premium_ccy") or "").strip().upper() + if ccy in ("ETH", "BTC"): + return True + if str(p.get("margin_mode") or "").strip().lower() == "coin": + return True + mid = str(p.get("inst_id") or "") + return "-USD-" in mid.upper() and "_UM" not in mid.upper() + + mode = str(snap.get("options_margin_mode") or snap.get("margin_mode") or "").strip().lower() + snap_coin = mode == "coin" + idx = _index_px() + try: from lib.options.options_positions_lib import display_pnl_from_option_row - total = 0.0 + total_u = 0.0 found = False + missing_fx = False for p in snap.get("positions") or []: if not isinstance(p, dict): continue @@ -96,10 +125,38 @@ def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[floa if pnl is None: continue found = True - total += float(pnl) - return round(total, 4) if found else None + if snap_coin or _row_is_coin(p): + px = _safe(p.get("idx_px") or p.get("idxPx")) + if px is None or px <= 0: + px = idx + if px is None or px <= 0: + missing_fx = True + continue + total_u += float(pnl) * float(px) + else: + total_u += float(pnl) + if found and not missing_fx: + return round(total_u, 4) + if found and missing_fx and abs(total_u) > 1e-12: + # 部分腿已换算成功时仍返回可得合计 + return round(total_u, 4) except Exception: - return None + pass + + upl = snap.get("upl_total_usdc") + if upl is not None: + try: + raw = float(upl) + except (TypeError, ValueError): + return None + if snap_coin or any( + isinstance(p, dict) and _row_is_coin(p) for p in (snap.get("positions") or []) + ): + if idx is None or idx <= 0: + return None + return round(raw * float(idx), 4) + return round(raw, 4) + return None def options_open_position_count(options_snap: dict[str, Any] | None) -> int: diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index af90416..1f1420d 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -817,6 +817,22 @@ return `${sign}${fmt(n, 2)}U`; } + /** 中控期权浮盈汇总框:只显示换算后的 U(持仓表净盈亏仍用 fmtOptPnlText 双显). */ + function fmtOptPnlUsdtOnly(v, ccy, spotPx) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const unit = String(ccy || "USDC").toUpperCase(); + let uu = n; + if (unit === "ETH" || unit === "BTC") { + const px = Number(spotPx); + if (!Number.isFinite(px) || !(px > 0)) return "—"; + uu = n * px; + } + const uAbs = Math.abs(uu).toFixed(2); + const uSign = uu < 0 ? "-" : uu > 0 ? "+" : ""; + return `${uSign}${uAbs}U`; + } + /** 交易所持仓开仓价(三所子代理 entry_price) */ function positionEntryPrice(pos) { if (!pos) return null; @@ -3975,7 +3991,7 @@ const spotPx = spotPxFromOptMeta(optMeta); const pnlTxt = upnl == null || Number.isNaN(Number(upnl)) ? "—" - : `${fmtOptPnlText(upnl, pnlCcy, spotPx)}`; + : `${fmtOptPnlUsdtOnly(upnl, pnlCcy, spotPx)}`; return `
资金账户
${fundTxt}
交易账户
${tradeTxt}
@@ -4655,10 +4671,10 @@ parts.push(`交易 ${fmt(bal.trading, 2)}U`); } if (optUpl != null && Number.isFinite(Number(optUpl))) { - parts.push(`浮盈 ${fmtOptPnlText(optUpl, optCcy, spotPxFromOptMeta(opt))}`); + parts.push(`浮盈 ${fmtOptPnlUsdtOnly(optUpl, optCcy, spotPxFromOptMeta(opt))}`); } if (optUpl != null && Number.isFinite(Number(optUpl)) && openCount === 0) { - // 永续空仓时主数字优先展示期权浮盈,避免一直显示 0U + // 永续空仓时主数字优先展示期权浮盈(只显示 U) pnlShow = optUpl; pnlSuffix = "期权"; pnlUnit = optCcy; @@ -4673,7 +4689,7 @@ const strategyStats = renderCardStrategyStats(row, hm, flaskOk); const tilePnlHtml = pnlUnit === "ETH" || pnlUnit === "BTC" - ? `${fmtOptPnlText(pnlShow, pnlUnit, pnlSpotPx)} ${pnlSuffix ? esc(pnlSuffix) : ""}` + ? `${fmtOptPnlUsdtOnly(pnlShow, pnlUnit, pnlSpotPx)} ${pnlSuffix ? esc(pnlSuffix) : ""}` : `${fmt(pnlShow, 2)} U${pnlSuffix ? " · " + esc(pnlSuffix) : ""}`; return `
diff --git a/tests/test_hub_monitor_totals_lib.py b/tests/test_hub_monitor_totals_lib.py index 238e3aa..eb3468d 100644 --- a/tests/test_hub_monitor_totals_lib.py +++ b/tests/test_hub_monitor_totals_lib.py @@ -66,6 +66,33 @@ class TestHubMonitorTotals(unittest.TestCase): self.assertEqual(out["options_float_pnl_u"], 1.5) self.assertEqual(out["float_pnl_u"], 1.5) + def test_aggregate_monitor_board_totals_coin_options_to_usdt(self): + rows = [ + { + "capabilities": ["options"], + "options": { + "ok": True, + "enabled": True, + "options_margin_mode": "coin", + "options_index_px": 2000.0, + "positions": [ + { + "inst_id": "ETH-USD-260822-2250-C", + "margin_mode": "coin", + "premium_ccy": "ETH", + "upl": 0.002, + "idx_px": 2000.0, + } + ], + "upl_total_usdc": 0.002, + }, + "agent": {"positions": [], "total_unrealized_pnl": 10.0}, + } + ] + out = aggregate_monitor_board_totals(rows, trading_day="2026-08-20", reset_hour=8) + self.assertEqual(out["options_float_pnl_u"], 4.0) + self.assertEqual(out["float_pnl_u"], 14.0) + def test_aggregate_excludes_option_like_agent_positions(self): """子代理误把期权当永续上报时:不算进持仓数,浮盈只用期权 snap.""" rows = [ @@ -74,7 +101,7 @@ class TestHubMonitorTotals(unittest.TestCase): "options": { "ok": True, "enabled": True, - "positions": [{"inst_id": "ETH-USD-260806-1875-C"}], + "positions": [{"inst_id": "ETH-USD_UM-260806-1875-C"}], "upl_total_usdc": -0.4, }, "agent": { diff --git a/tests/test_hub_options_funds_lib.py b/tests/test_hub_options_funds_lib.py index 323efd9..dcf9b23 100644 --- a/tests/test_hub_options_funds_lib.py +++ b/tests/test_hub_options_funds_lib.py @@ -63,6 +63,28 @@ class HubOptionsFundsLibTests(TestCase): self.assertEqual(out["options_open_position_count"], 1) self.assertEqual(out["options_float_pnl_u"], 0.5) + def test_options_float_pnl_usdt_coin_converts_by_index(self): + from lib.hub.hub_options_funds_lib import options_float_pnl_usdt + + snap = { + "ok": True, + "enabled": True, + "options_margin_mode": "coin", + "options_index_px": 2280.0, + "upl_total_usdc": 0.0016, + "positions": [ + { + "inst_id": "ETH-USD-260822-2250-C", + "margin_mode": "coin", + "premium_ccy": "ETH", + "upl": 0.0016, + "idx_px": 2280.0, + } + ], + } + out = options_float_pnl_usdt(snap) + self.assertAlmostEqual(out, round(0.0016 * 2280.0, 4), places=4) + def test_repair_double_counted_fund_entry(self): raw = { "funding_usdt": 586.82,