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 @@
+
+
+ 合计盈亏
+ —
+
+
+ 已平净盈亏
+ —
+
+
+ 持仓浮盈
+ —
+
+
-
+
diff --git a/tests/test_options_hub_lib.py b/tests/test_options_hub_lib.py
index acd9859..7c3fb15 100644
--- a/tests/test_options_hub_lib.py
+++ b/tests/test_options_hub_lib.py
@@ -14,7 +14,13 @@ class OptionsHubLibTests(TestCase):
@patch("lib.options.options_positions_lib.build_display_option_positions")
def test_build_options_hub_snapshot_positions(self, mock_positions, _mock_stats):
mock_positions.return_value = [
- {"inst_id": "ETH-USD_UM-260703-1800-C", "pos": 2, "upl": 1.5, "mark_px": 0.1}
+ {
+ "inst_id": "ETH-USD_UM-260703-1800-C",
+ "pos": 2,
+ "upl": 9.9,
+ "mark_px": 0.1,
+ "close_preview": {"estimated_pnl": 1.5},
+ }
]
conn = MagicMock()
conn.__enter__ = MagicMock(return_value=conn)
diff --git a/tests/test_options_net_pnl_sum.py b/tests/test_options_net_pnl_sum.py
new file mode 100644
index 0000000..e3ad5a7
--- /dev/null
+++ b/tests/test_options_net_pnl_sum.py
@@ -0,0 +1,32 @@
+"""期权净盈亏汇总与持仓卡口径一致."""
+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
+
+
+class OptionsNetPnlSumTests(TestCase):
+ def test_net_pnl_from_display_row(self):
+ self.assertEqual(
+ net_pnl_from_display_row({"close_preview": {"estimated_pnl": -2.8}, "premium_paid": 4.95}),
+ -2.8,
+ )
+ self.assertIsNone(
+ net_pnl_from_display_row({"close_preview": {"bid_invalid": True, "estimated_pnl": -1}})
+ )
+ self.assertEqual(
+ net_pnl_from_display_row(
+ {"close_preview": {"total_received": 2.15}, "premium_paid": 4.95}
+ ),
+ round(2.15 - 4.95, 4),
+ )
+
+ @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}},
+ ]
+ cfg = {"fetch_option_positions": lambda ex: [{"instId": "X"}]}
+ self.assertEqual(sum_options_net_pnl_usdc(cfg, object()), -1.8)
diff --git a/tests/test_options_stats_lib.py b/tests/test_options_stats_lib.py
index 9c9201c..62ab1cf 100644
--- a/tests/test_options_stats_lib.py
+++ b/tests/test_options_stats_lib.py
@@ -83,3 +83,4 @@ class OptionsStatsLibTests(TestCase):
self.assertAlmostEqual(out["avg_loss"], 2.66, places=2)
self.assertAlmostEqual(out["profit_loss_ratio"], 0.33, places=2)
self.assertEqual(out["open_count"], 1)
+ self.assertAlmostEqual(out["net_realized_pnl"], round(0.87 - 3.99 - 1.33, 4), places=4)