From ee7be3e7f35ee59473addf7c8fe06b4988c145fe Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 15 Jul 2026 22:00:45 +0800 Subject: [PATCH] Align options realtime PnL with bid-net and show totals in stats. Header float PnL now uses bid recycle minus premium like position cards. Stats adds realized/open/total net PnL. Co-authored-by: Cursor --- crypto_monitor_okx/app.py | 21 ++++++++--- lib/common/static/instance_theme.css | 18 ++++++++++ lib/common/static/options_panel.js | 22 ++++++++++++ lib/exchange/okx_options_lib.py | 6 +++- lib/instance/templates/embed_shell.html | 2 +- lib/instance/templates/index.html | 2 +- lib/options/options_hub_lib.py | 9 +++-- lib/options/options_positions_lib.py | 46 ++++++++++++++++++++++++ lib/options/options_register.py | 18 +++++++++- lib/options/options_stats_lib.py | 16 ++++++--- lib/options/templates/options_panel.html | 16 ++++++++- tests/test_options_hub_lib.py | 8 ++++- tests/test_options_net_pnl_sum.py | 32 +++++++++++++++++ tests/test_options_stats_lib.py | 1 + 14 files changed, 198 insertions(+), 19 deletions(-) create mode 100644 tests/test_options_net_pnl_sum.py diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 5fe185b..fe63210 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -6961,10 +6961,17 @@ def api_account_snapshot(): options_unrealized_pnl = None if OKX_OPTIONS_ENABLED and exchange_options.apiKey: try: - from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc from lib.instance.instance_live_pnl_lib import merge_unrealized_pnl_components + from lib.options.options_positions_lib import sum_options_net_pnl_usdc - options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) + opt_cfg = app.extensions.get("options_cfg") + if opt_cfg: + # 与持仓卡「净盈亏」同口径(买一回收−权利金),不用交易所标记价 upl + options_unrealized_pnl = sum_options_net_pnl_usdc(opt_cfg, exchange_options) + else: + from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc + + options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) unrealized_pnl = merge_unrealized_pnl_components(unrealized_pnl, options_unrealized_pnl) except Exception: options_unrealized_pnl = None @@ -7372,9 +7379,15 @@ def api_price_snapshot(): options_unrealized_pnl = None if OKX_OPTIONS_ENABLED and exchange_options.apiKey: try: - from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc + from lib.options.options_positions_lib import sum_options_net_pnl_usdc - options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) + opt_cfg = app.extensions.get("options_cfg") + if opt_cfg: + options_unrealized_pnl = sum_options_net_pnl_usdc(opt_cfg, exchange_options) + else: + from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc + + options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) except Exception: options_unrealized_pnl = None diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index c4f0ba5..566b64b 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -3929,6 +3929,24 @@ html[data-theme="light"] .options-estimate-row { .options-pos-stats-card { flex-shrink: 0; } +.options-stats-pnl-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} +.options-stats-pnl-summary .options-stat-item { + padding: 10px 12px; + border-radius: 8px; + background: rgba(127, 127, 127, 0.12); +} +.options-stats-pnl-summary .opt-stats-net-item .v { + font-size: 1.15em; + font-weight: 650; +} +html[data-theme="light"] .options-stats-pnl-summary .options-stat-item { + background: rgba(0, 0, 0, 0.04); +} .options-stats-panel { display: flex; flex-direction: column; diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 1a1f77d..0180529 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -1476,6 +1476,19 @@ } } + function paintPnlStat(el, value) { + if (!el) return; + if (value == null || value === "" || Number.isNaN(Number(value))) { + el.textContent = "—"; + el.classList.remove("pos-pnl-profit", "pos-pnl-loss"); + return; + } + const n = Number(value); + el.textContent = (n > 0 ? "+" : "") + fmt(n, 2) + " USDC"; + el.classList.toggle("pos-pnl-profit", n > 0); + el.classList.toggle("pos-pnl-loss", n < 0); + } + async function refreshStats() { const d = await apiJson("/api/options/stats"); const winEl = document.getElementById("opt-stats-winrate"); @@ -1487,14 +1500,23 @@ const winHoldEl = document.getElementById("opt-stats-win-hold"); const lossHoldEl = document.getElementById("opt-stats-loss-hold"); const openHoldEl = document.getElementById("opt-stats-open-hold"); + const totalPnlEl = document.getElementById("opt-stats-total-pnl"); + const netRealizedEl = document.getElementById("opt-stats-net-realized"); + const openFloatEl = document.getElementById("opt-stats-open-float"); const statEls = [winEl, plrEl, closedEl, profitEl, lossEl, avgHoldEl, winHoldEl, lossHoldEl, openHoldEl]; if (!d.ok) { statEls.forEach(function (el) { if (el) el.textContent = "—"; }); + paintPnlStat(totalPnlEl, null); + paintPnlStat(netRealizedEl, null); + paintPnlStat(openFloatEl, null); paintStatsCharts(null); return; } + paintPnlStat(totalPnlEl, d.total_pnl); + paintPnlStat(netRealizedEl, d.net_realized_pnl); + paintPnlStat(openFloatEl, d.open_float_pnl); if (winEl) winEl.textContent = d.total_closed ? d.win_rate + "%" : "0%"; if (plrEl) { plrEl.textContent = d.profit_loss_ratio != null ? String(d.profit_loss_ratio) : "—"; diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 0a92c8a..9c9d2aa 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -1161,7 +1161,11 @@ def resolve_option_close_from_history( def fetch_options_unrealized_pnl_usdc(ex: ccxt.okx) -> float | None: - """期权持仓未实现盈亏合计(USDC,统计口径与 USDT 1:1).""" + """ + 期权浮盈合计(USDC≈U). + 优先返回交易所标记价 upl;实例顶栏应改用 + `options_positions_lib.sum_options_net_pnl_usdc`(买一净盈亏)以与持仓卡一致. + """ positions = fetch_option_positions(ex) if positions is None: return None diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index 0791ce0..514fe0d 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -7,7 +7,7 @@ - + {{ exchange_display }} · 加密货币 | 交易监控复盘系统 diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index 3abdfb9..db5f71b 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -17,7 +17,7 @@ {{ exchange_display }} · 加密货币 | 交易监控复盘系统 - + dict[str, Any]: conn.close() except Exception: target_monitors = [] + from lib.options.options_positions_lib import net_pnl_from_display_row + upl_total = 0.0 has_upl = False for p in positions: - # 汇总优先用买盘净盈亏,与持仓卡「净盈亏」一致 - preview = p.get("close_preview") or {} - net = preview.get("estimated_pnl") - if net is None: - net = p.get("upl") + # 与持仓卡「净盈亏」一致(买一回收−权利金);不用交易所标记价 upl + net = net_pnl_from_display_row(p) if net is None: continue has_upl = True diff --git a/lib/options/options_positions_lib.py b/lib/options/options_positions_lib.py index cf84f9a..569c853 100644 --- a/lib/options/options_positions_lib.py +++ b/lib/options/options_positions_lib.py @@ -81,6 +81,52 @@ def forget_close_gate_for_inst(inst_id: str) -> None: clear_close_gate(inst_id) +def net_pnl_from_display_row(row: dict[str, Any]) -> float | None: + """与持仓卡「净盈亏」同口径:买一可回收 − 权利金;残档买一则无净值.""" + preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {} + if preview.get("bid_invalid"): + return None + net = preview.get("estimated_pnl") + if net is not None: + try: + return float(net) + except (TypeError, ValueError): + pass + recv = _safe_float(preview.get("total_received")) + paid = _safe_float(row.get("premium_paid")) + if recv is not None and paid is not None: + return round(recv - paid, 4) + return None + + +def sum_options_net_pnl_usdc( + cfg: dict[str, Any], + ex: Any, + raw_positions: list[dict[str, Any]] | None = None, +) -> float | None: + """ + 期权浮盈合计(USDC),与顶栏实时盈亏/中控口径对齐为「净盈亏」: + 各仓买一可回收 − 权利金之和.获取失败返回 None;无持仓返回 0. + """ + raw = raw_positions + if raw is None: + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return None + if not raw: + return 0.0 + positions = build_display_option_positions(cfg, ex, raw) + total = 0.0 + found = False + for p in positions: + net = net_pnl_from_display_row(p) + if net is None: + continue + found = True + total += float(net) + return round(total, 4) if found else (0.0 if not positions else None) + + def build_display_option_positions( cfg: dict[str, Any], ex: Any, diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 6ba216c..c94a40a 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -973,13 +973,29 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: if ex is None: return jsonify({"ok": False, "msg": err}) from lib.options.options_history_lib import load_options_history + from lib.options.options_positions_lib import sum_options_net_pnl_usdc from lib.options.options_stats_lib import compute_options_stats_from_history raw_live = cfg["fetch_option_positions"](ex) if raw_live is None: return jsonify({"ok": False, "msg": "获取期权持仓失败"}) history = load_options_history(ex, cfg) - return jsonify({"ok": True, **compute_options_stats_from_history(history)}) + stats = compute_options_stats_from_history(history) + open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live) + net_realized = _safe_float(stats.get("net_realized_pnl")) or 0.0 + total_pnl = None + if open_float is not None: + total_pnl = round(net_realized + float(open_float), 4) + elif stats.get("total_closed"): + total_pnl = round(net_realized, 4) + return jsonify( + { + "ok": True, + **stats, + "open_float_pnl": open_float, + "total_pnl": total_pnl, + } + ) @app.route("/api/options/history/", methods=["DELETE"]) @lr diff --git a/lib/options/options_stats_lib.py b/lib/options/options_stats_lib.py index cefef5a..052c43e 100644 --- a/lib/options/options_stats_lib.py +++ b/lib/options/options_stats_lib.py @@ -75,6 +75,9 @@ def compute_options_stats_from_history(history: list[dict[str, Any]]) -> dict[st avg_win = sum(wins) / len(wins) if wins else None avg_loss = sum(losses) / len(losses) if losses else None + total_profit = round(sum(wins), 4) if wins else 0.0 + total_loss = round(abs(sum(losses)), 4) if losses else 0.0 + net_realized = round(sum(wins) + sum(losses), 4) return { "total_closed": total_closed, "win_count": len(wins), @@ -83,8 +86,9 @@ def compute_options_stats_from_history(history: list[dict[str, Any]]) -> dict[st "profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss), "avg_win": round(avg_win, 4) if avg_win is not None else None, "avg_loss": round(abs(avg_loss), 4) if avg_loss is not None else None, - "total_profit": round(sum(wins), 4) if wins else 0.0, - "total_loss": round(abs(sum(losses)), 4) if losses else 0.0, + "total_profit": total_profit, + "total_loss": total_loss, + "net_realized_pnl": net_realized, "avg_hold_sec": _avg_seconds(all_holds), "avg_win_hold_sec": _avg_seconds(win_holds), "avg_loss_hold_sec": _avg_seconds(loss_holds), @@ -147,6 +151,9 @@ def compute_options_stats(get_db) -> dict[str, Any]: avg_win = sum(wins) / len(wins) if wins else None avg_loss = sum(losses) / len(losses) if losses else None + total_profit = round(sum(wins), 4) if wins else 0.0 + total_loss = round(abs(sum(losses)), 4) if losses else 0.0 + net_realized = round(sum(wins) + sum(losses), 4) return { "total_closed": total_closed, "win_count": len(wins), @@ -155,8 +162,9 @@ def compute_options_stats(get_db) -> dict[str, Any]: "profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss), "avg_win": round(avg_win, 4) if avg_win is not None else None, "avg_loss": round(abs(avg_loss), 4) if avg_loss is not None else None, - "total_profit": round(sum(wins), 4) if wins else 0.0, - "total_loss": round(abs(sum(losses)), 4) if losses else 0.0, + "total_profit": total_profit, + "total_loss": total_loss, + "net_realized_pnl": net_realized, "avg_hold_sec": _avg_seconds(all_holds), "avg_win_hold_sec": _avg_seconds(win_holds), "avg_loss_hold_sec": _avg_seconds(loss_holds), diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 7a3c90d..864f9ff 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -152,6 +152,20 @@