Fix hub options float summary blank when bid book is invalid.

Stop treating total_received=0 as a real bid recycle, fall back to exchange upl for display totals, and keep row/summary aligned.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-07 09:47:14 +08:00
parent d41028b766
commit 4bc238b014
12 changed files with 182 additions and 48 deletions
+14 -2
View File
@@ -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) {
+16 -4
View File
@@ -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
? ""
: '<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
(closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "</span></div>" +
(net == null ? "—" : fmt(net, 2)) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
(closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>";
(roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>";
return (
'<div class="pos-card-head">' +
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + "</strong>" +
+20 -4
View File
@@ -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
+3 -3
View File
@@ -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"))
+5 -5
View File
@@ -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,
+19 -5
View File
@@ -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)
+1 -1
View File
@@ -324,4 +324,4 @@
</div>
</div>
<script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=52"></script>
<script src="/static/options_panel.js?v=53"></script>
+2 -7
View File
@@ -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 {
+43 -8
View File
@@ -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;
+13 -2
View File
@@ -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) {
+3 -3
View File
@@ -1757,7 +1757,7 @@
<script src="/assets/archive.js?v=20260724-opt-archive"></script>
<script src="/assets/quotes.js?v=20260717-quotes-feed"></script>
<script src="/assets/funds.js?v=20260717-funds-scroll-fix"></script>
<script src="/assets/dashboard.js?v=20260723-hide-pnl"></script>
<script src="/assets/dashboard.js?v=20260807-opt-float"></script>
<script src="/assets/strategy.js?v=11"></script>
<script src="/assets/amp_stats.js?v=20260728-hedge"></script>
<script src="/assets/help.js?v=1"></script>
@@ -1765,8 +1765,8 @@
<script src="/assets/ai_review_render.js?v=3"></script>
<script src="/assets/time_close_ui.js?v=3"></script>
<script src="/assets/options_expiry_countdown.js?v=1"></script>
<script src="/assets/options_position_cards.js?v=3"></script>
<script src="/assets/options_position_cards.js?v=4"></script>
<script src="/assets/backup.js?v=1"></script>
<script src="/assets/app.js?v=20260805-intraday-close"></script>
<script src="/assets/app.js?v=20260807-opt-float"></script>
</body>
</html>
+43 -4
View File
@@ -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()