diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 0ae34d1..70b81f2 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -753,15 +753,27 @@ function netPnlFromPos(p) { const preview = (p && p.close_preview) || {}; + if (preview.bid_invalid) { + const upl = p && p.upl != null ? Number(p.upl) : NaN; + return Number.isFinite(upl) ? upl : null; + } if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) { return Number(preview.estimated_pnl); } + const covered = Number(preview.covered_sheets); const recv = Number(preview.total_received); const prem = Number(p && p.premium_paid); - if (preview.total_received != null && !Number.isNaN(recv) && !Number.isNaN(prem)) { + if ( + preview.total_received != null && + Number.isFinite(covered) && + covered > 0 && + !Number.isNaN(recv) && + !Number.isNaN(prem) + ) { return recv - prem; } - return null; + const upl = p && p.upl != null ? Number(p.upl) : NaN; + return Number.isFinite(upl) ? upl : null; } function netRoiFromPos(p, net) { diff --git a/lib/common/static/options_position_cards.js b/lib/common/static/options_position_cards.js index 8e1e726..d2010d2 100644 --- a/lib/common/static/options_position_cards.js +++ b/lib/common/static/options_position_cards.js @@ -103,15 +103,27 @@ function netPnlFromPos(p) { const preview = (p && p.close_preview) || {}; + if (preview.bid_invalid) { + const upl = p && p.upl != null ? Number(p.upl) : NaN; + return Number.isFinite(upl) ? upl : null; + } if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) { return Number(preview.estimated_pnl); } + const covered = Number(preview.covered_sheets); const recv = Number(preview.total_received); const prem = Number(p && p.premium_paid); - if (preview.total_received != null && !Number.isNaN(recv) && !Number.isNaN(prem)) { + if ( + preview.total_received != null && + Number.isFinite(covered) && + covered > 0 && + !Number.isNaN(recv) && + !Number.isNaN(prem) + ) { return recv - prem; } - return null; + const upl = p && p.upl != null ? Number(p.upl) : NaN; + return Number.isFinite(upl) ? upl : null; } function netRoiFromPos(p, net) { @@ -170,9 +182,9 @@ const pnlCells = hidePnl ? "" : '
净盈亏' + - (closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "
" + + (net == null ? "—" : fmt(net, 2)) + "" + '
收益率' + - (closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "
"; + (roi == null ? "—" : fmt(roi, 2) + "%") + ""; return ( '
' + '
' + (p.inst_id || "") + "" + diff --git a/lib/hub/hub_options_funds_lib.py b/lib/hub/hub_options_funds_lib.py index 23448d9..602d4ef 100644 --- a/lib/hub/hub_options_funds_lib.py +++ b/lib/hub/hub_options_funds_lib.py @@ -54,11 +54,27 @@ def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[floa if snap.get("enabled") is False or snap.get("ok") is False: return None upl = snap.get("upl_total_usdc") - if upl is None: - return None + if upl is not None: + try: + return round(float(upl), 4) + except (TypeError, ValueError): + pass + # 快照偶发缺合计时,按持仓行回退汇总(与卡片展示一致) try: - return round(float(upl), 4) - except (TypeError, ValueError): + from lib.options.options_positions_lib import display_pnl_from_option_row + + total = 0.0 + found = False + for p in snap.get("positions") or []: + if not isinstance(p, dict): + continue + pnl = display_pnl_from_option_row(p) + if pnl is None: + continue + found = True + total += float(pnl) + return round(total, 4) if found else None + except Exception: return None diff --git a/lib/instance/instance_dashboard_lib.py b/lib/instance/instance_dashboard_lib.py index 7d7ff1d..39c96c4 100644 --- a/lib/instance/instance_dashboard_lib.py +++ b/lib/instance/instance_dashboard_lib.py @@ -142,12 +142,12 @@ def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]: inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-" opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT") - # 看板期权列固定用净盈亏(买一回收−权利金);残档买一则空. + # 看板期权列:优先买一净盈亏,残档回退交易所 upl pnl = None try: - from lib.options.options_positions_lib import net_pnl_from_display_row + from lib.options.options_positions_lib import display_pnl_from_option_row - pnl = net_pnl_from_display_row(p) + pnl = display_pnl_from_option_row(p) except Exception: pnl = None pos = _safe_float(p.get("pos")) diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py index bc3c774..cbe0d9b 100644 --- a/lib/options/options_hub_lib.py +++ b/lib/options/options_hub_lib.py @@ -66,17 +66,17 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: conn.close() except Exception: target_monitors = [] - from lib.options.options_positions_lib import net_pnl_from_display_row + from lib.options.options_positions_lib import display_pnl_from_option_row upl_total = 0.0 has_upl = False for p in positions: - # 与持仓卡「净盈亏」一致(买一回收−权利金);不用交易所标记价 upl - net = net_pnl_from_display_row(p) - if net is None: + # 与持仓卡展示一致:优先买一净盈亏,残档回退交易所 upl + pnl = display_pnl_from_option_row(p) + if pnl is None: continue has_upl = True - upl_total += float(net) + upl_total += float(pnl) bal = cfg["fetch_options_balances"](ex) return { "ok": True, diff --git a/lib/options/options_positions_lib.py b/lib/options/options_positions_lib.py index e94ef25..a917f2c 100644 --- a/lib/options/options_positions_lib.py +++ b/lib/options/options_positions_lib.py @@ -92,13 +92,26 @@ def net_pnl_from_display_row(row: dict[str, Any]) -> float | None: return float(net) except (TypeError, ValueError): pass + # 仅当实际吃到买盘张数时,才用 total_received − 权利金(避免 bid 无效时 total_received=0 算出 −权利金假亏) + try: + covered = float(preview.get("covered_sheets") or 0) + except (TypeError, ValueError): + covered = 0.0 recv = _safe_float(preview.get("total_received")) paid = _safe_float(row.get("premium_paid")) - if recv is not None and paid is not None: + if covered > 0 and recv is not None and paid is not None: return round(recv - paid, 4) return None +def display_pnl_from_option_row(row: dict[str, Any]) -> float | None: + """展示用盈亏:优先买一净盈亏;残档/无买一时回退交易所标记浮盈 upl.""" + net = net_pnl_from_display_row(row) + if net is not None: + return net + return _safe_float(row.get("upl")) + + def sum_options_net_pnl_usdc( cfg: dict[str, Any], ex: Any, @@ -106,7 +119,8 @@ def sum_options_net_pnl_usdc( ) -> float | None: """ 期权浮盈合计(USDC),与顶栏实时盈亏/中控口径对齐为「净盈亏」: - 各仓买一可回收 − 权利金之和.获取失败返回 None;无持仓返回 0. + 各仓买一可回收 − 权利金之和;残档则回退该仓交易所 upl. + 获取失败返回 None;无持仓返回 0. """ raw = raw_positions if raw is None: @@ -119,11 +133,11 @@ def sum_options_net_pnl_usdc( total = 0.0 found = False for p in positions: - net = net_pnl_from_display_row(p) - if net is None: + pnl = display_pnl_from_option_row(p) + if pnl is None: continue found = True - total += float(net) + total += float(pnl) return round(total, 4) if found else (0.0 if not positions else None) diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index eb66f31..4707dcc 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -324,4 +324,4 @@
- + diff --git a/manual_trading_hub/hub_ai/context.py b/manual_trading_hub/hub_ai/context.py index 4141778..95a8ef2 100644 --- a/manual_trading_hub/hub_ai/context.py +++ b/manual_trading_hub/hub_ai/context.py @@ -1307,9 +1307,9 @@ def format_dashboard_account_detail(ac: dict) -> dict[str, Any]: row["source_label"] = _options_source_label(p) row["target_monitor_text"] = _options_target_monitor_text(p) try: - from lib.options.options_positions_lib import net_pnl_from_display_row + from lib.options.options_positions_lib import display_pnl_from_option_row - net = net_pnl_from_display_row(row) + net = display_pnl_from_option_row(row) except Exception: net = None row["net_pnl"] = round(float(net), 4) if net is not None else None @@ -1324,11 +1324,6 @@ def format_dashboard_account_detail(ac: dict) -> dict[str, Any]: } if row.get("net_pnl") is not None: line["pnl"] = row["net_pnl"] - elif row.get("upl") is not None: - try: - line["pnl"] = round(float(row["upl"]), 4) - except (TypeError, ValueError): - pass position_lines.append(line) issues = [str(x) for x in (ac.get("issues") or [])[:3]] return { diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 0e048f1..450dfba 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -3821,6 +3821,30 @@ return vals.reduce((s, v) => s + v, 0); } + function optionsRowDisplayPnl(p) { + if (!p || typeof p !== "object") return null; + const preview = p.close_preview || {}; + // bid 无效时 total_received 常为 0,不能用 0−权利金冒充净亏 + if (preview.bid_invalid) { + const upl = p.upl != null ? Number(p.upl) : NaN; + return Number.isFinite(upl) ? upl : null; + } + let net = preview.estimated_pnl; + const covered = Number(preview.covered_sheets); + if ( + net == null && + preview.total_received != null && + Number.isFinite(covered) && + covered > 0 && + p.premium_paid != null + ) { + net = Number(preview.total_received) - Number(p.premium_paid); + } + if (net != null && Number.isFinite(Number(net))) return Number(net); + const upl = p.upl != null ? Number(p.upl) : NaN; + return Number.isFinite(upl) ? upl : null; + } + function optionsBalanceFields(opt) { if (!opt || typeof opt !== "object") { return { funding: null, trading: null, upl: null }; @@ -3828,6 +3852,23 @@ const bal = opt.balances && typeof opt.balances === "object" ? opt.balances : {}; const pick = (a, b) => (a != null && a !== "" ? a : b); + let upl = + opt.upl_total_usdc != null && Number.isFinite(Number(opt.upl_total_usdc)) + ? Number(opt.upl_total_usdc) + : null; + if (upl == null) { + const pos = Array.isArray(opt.positions) ? opt.positions : []; + let sum = 0; + let found = false; + pos.forEach((p) => { + const n = optionsRowDisplayPnl(p); + if (n != null) { + sum += n; + found = true; + } + }); + if (found) upl = sum; + } return { funding: sumUsdtEquiv( pick(bal.funding_usdt, opt.funding_usdt), @@ -3837,10 +3878,7 @@ pick(bal.trading_usdt, opt.trading_usdt), pick(bal.trading_usdc, opt.trading_usdc) ), - upl: - opt.upl_total_usdc != null && Number.isFinite(Number(opt.upl_total_usdc)) - ? Number(opt.upl_total_usdc) - : null, + upl, }; } @@ -3915,10 +3953,7 @@ ? "Put" : p.opt_type || "—"; const preview = p.close_preview || {}; - let net = preview.estimated_pnl; - if (net == null && preview.total_received != null && p.premium_paid != null) { - net = Number(preview.total_received) - Number(p.premium_paid); - } + let net = optionsRowDisplayPnl(p); let roi = preview.estimated_pnl_ratio_pct; if (roi == null && net != null && Number(p.premium_paid) > 0) { roi = (Number(net) / Number(p.premium_paid)) * 100; diff --git a/manual_trading_hub/static/dashboard.js b/manual_trading_hub/static/dashboard.js index 450742e..c8d389a 100644 --- a/manual_trading_hub/static/dashboard.js +++ b/manual_trading_hub/static/dashboard.js @@ -249,14 +249,25 @@ if (!p || typeof p !== "object") return null; if (p.net_pnl != null && Number.isFinite(Number(p.net_pnl))) return Number(p.net_pnl); const preview = p.close_preview || {}; + if (preview.bid_invalid) { + const upl = p.upl != null ? Number(p.upl) : NaN; + return Number.isFinite(upl) ? upl : null; + } if (preview.estimated_pnl != null && Number.isFinite(Number(preview.estimated_pnl))) { return Number(preview.estimated_pnl); } - if (preview.total_received != null && p.premium_paid != null) { + const covered = Number(preview.covered_sheets); + if ( + preview.total_received != null && + Number.isFinite(covered) && + covered > 0 && + p.premium_paid != null + ) { const n = Number(preview.total_received) - Number(p.premium_paid); return Number.isFinite(n) ? n : null; } - return null; + const upl = p.upl != null ? Number(p.upl) : NaN; + return Number.isFinite(upl) ? upl : null; } function optionsHedgePlanId(p) { diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index c20b568..003ed4b 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -1757,7 +1757,7 @@ - + @@ -1765,8 +1765,8 @@ - + - + diff --git a/tests/test_options_net_pnl_sum.py b/tests/test_options_net_pnl_sum.py index e3ad5a7..3c8cdda 100644 --- a/tests/test_options_net_pnl_sum.py +++ b/tests/test_options_net_pnl_sum.py @@ -2,7 +2,11 @@ from unittest import TestCase from unittest.mock import patch -from lib.options.options_positions_lib import net_pnl_from_display_row, sum_options_net_pnl_usdc +from lib.options.options_positions_lib import ( + display_pnl_from_option_row, + net_pnl_from_display_row, + sum_options_net_pnl_usdc, +) class OptionsNetPnlSumTests(TestCase): @@ -16,17 +20,52 @@ class OptionsNetPnlSumTests(TestCase): ) self.assertEqual( net_pnl_from_display_row( - {"close_preview": {"total_received": 2.15}, "premium_paid": 4.95} + { + "close_preview": {"total_received": 2.15, "covered_sheets": 1}, + "premium_paid": 4.95, + } ), round(2.15 - 4.95, 4), ) + # bid 无效时 total_received=0 不得算出 −权利金 + self.assertIsNone( + net_pnl_from_display_row( + { + "close_preview": { + "bid_invalid": True, + "total_received": 0.0, + "covered_sheets": 0, + }, + "premium_paid": 9.49, + "upl": -4.2, + } + ) + ) + + def test_display_pnl_falls_back_to_exchange_upl(self): + self.assertEqual( + display_pnl_from_option_row( + { + "close_preview": {"bid_invalid": True, "total_received": 0.0}, + "premium_paid": 9.49, + "upl": -4.2, + } + ), + -4.2, + ) @patch("lib.options.options_positions_lib.build_display_option_positions") def test_sum_options_net_pnl_usdc(self, mock_build): mock_build.return_value = [ {"close_preview": {"estimated_pnl": -2.8}}, {"close_preview": {"estimated_pnl": 1.0}}, - {"close_preview": {"bid_invalid": True, "estimated_pnl": 9}}, + {"close_preview": {"bid_invalid": True, "estimated_pnl": 9}, "upl": -0.5}, ] cfg = {"fetch_option_positions": lambda ex: [{"instId": "X"}]} - self.assertEqual(sum_options_net_pnl_usdc(cfg, object()), -1.8) + self.assertEqual(sum_options_net_pnl_usdc(cfg, object()), -2.3) + + +if __name__ == "__main__": + from unittest import main + + main()