From e15eca76f35711a02f8bdd987ea0363c9256a9d9 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 15 Jul 2026 20:54:15 +0800 Subject: [PATCH] Show option net P/L from bid recycle minus premium. Rename floating P/L to net P/L, align ROI, and display bid depth as price/liquidity for only the levels needed to close. Co-authored-by: Cursor --- lib/common/static/options_panel.js | 51 +++++++++++++++++---- lib/common/static/options_position_cards.js | 46 +++++++++++++++++-- lib/options/options_hub_lib.py | 10 ++-- lib/options/options_pricing_lib.py | 10 +++- manual_trading_hub/static/app.js | 15 ++++-- tests/test_options_pricing.py | 5 +- 6 files changed, 114 insertions(+), 23 deletions(-) diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 3737578..98b9527 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -372,16 +372,46 @@ return price + "/" + size; } + /** 买盘深度:价格/流动性;仅展示平仓所需档位(买一不够才出买二…). */ function fmtCloseLevels(preview, tickSz) { const levels = ((preview && preview.levels) || []).slice(0, 5); if (!levels.length) return "—"; return levels.map(function (x, idx) { const levelNo = x.level != null ? x.level : idx + 1; + const liq = x.available_sheets != null ? x.available_sheets : x.sz; const pxTxt = fmtOptionPx(x.px, tickSz); - return "买" + levelNo + " " + pxTxt; + if (liq === null || liq === undefined || liq === "" || Number.isNaN(Number(liq))) { + return "买" + levelNo + " " + pxTxt; + } + const s = Number(liq); + const size = Math.abs(s - Math.round(s)) < 1e-9 ? String(Math.round(s)) : String(s); + return "买" + levelNo + " " + pxTxt + "/" + size; }).join(" · "); } + function netPnlFromPos(p) { + const preview = (p && p.close_preview) || {}; + if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) { + return Number(preview.estimated_pnl); + } + const recv = Number(preview.total_received); + const prem = Number(p && p.premium_paid); + if (preview.total_received != null && !Number.isNaN(recv) && !Number.isNaN(prem)) { + return recv - prem; + } + return null; + } + + function netRoiFromPos(p, net) { + const preview = (p && p.close_preview) || {}; + if (preview.estimated_pnl_ratio_pct != null && !Number.isNaN(Number(preview.estimated_pnl_ratio_pct))) { + return Number(preview.estimated_pnl_ratio_pct); + } + const prem = Number(p && p.premium_paid); + if (net == null || Number.isNaN(prem) || prem <= 0) return null; + return (net / prem) * 100; + } + function fmtUsdc(v) { if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; return Number(v).toFixed(2); @@ -821,8 +851,9 @@ } function renderPositionCardInner(p) { - const upl = p.upl; - const uplCls = upl > 0 ? "pos-pnl-profit" : upl < 0 ? "pos-pnl-loss" : ""; + const net = netPnlFromPos(p); + const roi = netRoiFromPos(p, net); + const uplCls = pnlCls(net); const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long"; const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time; const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; @@ -853,9 +884,10 @@ '
指数价' + fmt(p.idx_px, 0) + "
" + '
到期平衡' + fmt(p.expiry_be_px, 0) + "
" + '
平掉回本' + fmt(p.close_be_px, 0) + "
" + - '
浮盈亏' + fmt(p.upl, 2) + "
" + + '
净盈亏' + + (net == null ? "—" : fmt(net, 2)) + "
" + '
收益率' + - (p.upl_ratio_pct != null ? fmt(p.upl_ratio_pct, 2) + "%" : "—") + "
" + + (roi == null ? "—" : fmt(roi, 2) + "%") + "" + '
买盘深度' + fmtCloseLevels(closePreview, tickSz) + "
" + '
按买盘回收' + fmtClosePreview(closePreview, p.premium_paid) + "
" + "" + @@ -957,8 +989,9 @@ } function renderPositionAccordionItem(p, expanded) { - const upl = p.upl; - const uplCls = upl > 0 ? "pos-pnl-profit" : upl < 0 ? "pos-pnl-loss" : ""; + const net = netPnlFromPos(p); + const roi = netRoiFromPos(p, net); + const uplCls = pnlCls(net); const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long"; const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time; const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; @@ -978,9 +1011,9 @@ (expAttr ? '到期 ' : "") + - '' + fmt(p.upl, 2) + " USDC" + + '' + (net == null ? "—" : fmt(net, 2) + " USDC") + "" + '' + - (p.upl_ratio_pct != null ? fmt(p.upl_ratio_pct, 2) + "%" : "—") + "" + + (roi == null ? "—" : fmt(roi, 2) + "%") + "" + "" + "" + '
' + diff --git a/lib/common/static/options_position_cards.js b/lib/common/static/options_position_cards.js index 7bba252..529117c 100644 --- a/lib/common/static/options_position_cards.js +++ b/lib/common/static/options_position_cards.js @@ -41,15 +41,49 @@ return ""; } + function fmtPxSz(px, sz, tickSz) { + if (px === null || px === undefined || Number.isNaN(Number(px))) return "—"; + let price = fmtOptionPx(px, tickSz); + if (sz === null || sz === undefined || sz === "" || Number.isNaN(Number(sz))) return price; + const s = Number(sz); + const size = Math.abs(s - Math.round(s)) < 1e-9 ? String(Math.round(s)) : String(s); + return price + "/" + size; + } + + /** 买盘深度:价格/流动性;仅展示平仓所需档位(买一不够才出买二…). */ function fmtCloseLevels(preview, tickSz) { const levels = ((preview && preview.levels) || []).slice(0, 5); if (!levels.length) return "—"; return levels.map(function (x, idx) { const levelNo = x.level != null ? x.level : idx + 1; - return "买" + levelNo + " " + fmtOptionPx(x.px, tickSz); + const liq = x.available_sheets != null ? x.available_sheets : x.sz; + return "买" + levelNo + " " + fmtPxSz(x.px, liq, tickSz); }).join(" · "); } + function netPnlFromPos(p) { + const preview = (p && p.close_preview) || {}; + if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) { + return Number(preview.estimated_pnl); + } + const recv = Number(preview.total_received); + const prem = Number(p && p.premium_paid); + if (preview.total_received != null && !Number.isNaN(recv) && !Number.isNaN(prem)) { + return recv - prem; + } + return null; + } + + function netRoiFromPos(p, net) { + const preview = (p && p.close_preview) || {}; + if (preview.estimated_pnl_ratio_pct != null && !Number.isNaN(Number(preview.estimated_pnl_ratio_pct))) { + return Number(preview.estimated_pnl_ratio_pct); + } + const prem = Number(p && p.premium_paid); + if (net == null || Number.isNaN(prem) || prem <= 0) return null; + return (net / prem) * 100; + } + function fmtClosePreview(preview, premiumPaid, hub) { if (!preview || preview.total_received == null) return "—"; const recvTxt = fmtUsdc(preview.total_received); @@ -73,8 +107,9 @@ opts = opts || {}; const hub = !!opts.hub; const readOnly = !!opts.readOnly; - const upl = p.upl; - const uplCls = pnlCls(upl, hub); + const net = netPnlFromPos(p); + const roi = netRoiFromPos(p, net); + const uplCls = pnlCls(net, hub); const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long"; const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time; const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; @@ -111,9 +146,10 @@ '
指数价' + fmt(p.idx_px, 0) + "
" + '
到期平衡' + fmt(p.expiry_be_px, 0) + "
" + '
平掉回本' + fmt(p.close_be_px, 0) + "
" + - '
浮盈亏' + fmt(p.upl, 2) + "
" + + '
净盈亏' + + (net == null ? "—" : fmt(net, 2)) + "
" + '
收益率' + - (p.upl_ratio_pct != null ? fmt(p.upl_ratio_pct, 2) + "%" : "—") + "
" + + (roi == null ? "—" : fmt(roi, 2) + "%") + "
" + '
买盘深度' + fmtCloseLevels(closePreview, tickSz) + "
" + '
按买盘回收' + fmtClosePreview(closePreview, p.premium_paid, hub) + "
" + "" + diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py index c859af9..19527d1 100644 --- a/lib/options/options_hub_lib.py +++ b/lib/options/options_hub_lib.py @@ -51,11 +51,15 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: upl_total = 0.0 has_upl = False for p in positions: - upl = p.get("upl") - if upl is None: + # 汇总优先用买盘净盈亏,与持仓卡「净盈亏」一致 + preview = p.get("close_preview") or {} + net = preview.get("estimated_pnl") + if net is None: + net = p.get("upl") + if net is None: continue has_upl = True - upl_total += float(upl) + upl_total += float(net) bal = cfg["fetch_options_balances"](ex) stats = _compute_options_stats(ex, cfg) return { diff --git a/lib/options/options_pricing_lib.py b/lib/options/options_pricing_lib.py index 0f50caa..ca381b3 100644 --- a/lib/options/options_pricing_lib.py +++ b/lib/options/options_pricing_lib.py @@ -70,6 +70,7 @@ def estimate_close_by_bids( "total_received": 0.0, "avg_px": None, "estimated_pnl": None, + "estimated_pnl_ratio_pct": None, } for i, level in enumerate(bids or [], start=1): if remaining <= 0: @@ -98,10 +99,14 @@ def estimate_close_by_bids( remaining -= take covered = target - remaining avg_px = (total_received / eth_amount_from_sheets(covered, ct_mult)) if covered > 0 else None + # 净盈亏 = 按买盘可回收 − 全部权利金(与「可落袋」口径一致;买一不够会展开更多档) estimated_pnl = None + estimated_pnl_ratio_pct = None if premium_paid is not None and covered > 0: - paid_basis = float(premium_paid) * (covered / target) - estimated_pnl = round(total_received - paid_basis, 4) + paid = float(premium_paid) + estimated_pnl = round(total_received - paid, 4) + if paid > 0: + estimated_pnl_ratio_pct = round(estimated_pnl / paid * 100.0, 2) return { "levels": levels, "covered_sheets": covered, @@ -109,6 +114,7 @@ def estimate_close_by_bids( "total_received": round(total_received, 4), "avg_px": round(avg_px, 4) if avg_px is not None else None, "estimated_pnl": estimated_pnl, + "estimated_pnl_ratio_pct": estimated_pnl_ratio_pct, } diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index be4bf72..31cce3d 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -3550,10 +3550,19 @@ function renderOptionsPositionsTable(pos) { if (!pos.length) return '
暂无期权持仓
'; let html = '
'; - html += ""; + html += ""; html += ""; pos.forEach((p) => { const optType = (p.opt_type || "").toUpperCase() === "C" ? "Call" : (p.opt_type || "").toUpperCase() === "P" ? "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 roi = preview.estimated_pnl_ratio_pct; + if (roi == null && net != null && Number(p.premium_paid) > 0) { + roi = (Number(net) / Number(p.premium_paid)) * 100; + } html += ` @@ -3562,8 +3571,8 @@ - - + + `; }); html += "
合约类型张数到期倒计时指数到期平衡平掉回本浮盈浮盈%合约类型张数到期倒计时指数到期平衡平掉回本净盈亏收益率
${esc(shortOptionsInst(p.inst_id))} ${esc(optType)}${p.idx_px != null ? fmt(p.idx_px, 0) : "—"} ${p.expiry_be_px != null ? fmt(p.expiry_be_px, 0) : "—"} ${p.close_be_px != null ? fmt(p.close_be_px, 0) : "—"}${fmt(p.upl, 2)}${p.upl_ratio_pct != null ? esc(p.upl_ratio_pct) + "%" : "—"}${net == null ? "—" : fmt(net, 2)}${roi == null ? "—" : esc(Number(roi).toFixed(2)) + "%"}
"; diff --git a/tests/test_options_pricing.py b/tests/test_options_pricing.py index d912efa..3e4b265 100644 --- a/tests/test_options_pricing.py +++ b/tests/test_options_pricing.py @@ -196,6 +196,7 @@ def test_estimate_close_by_bids_full_depth(): assert out["total_received"] == 0.488 assert out["avg_px"] == 12.2 assert out["estimated_pnl"] == 0.088 + assert out["estimated_pnl_ratio_pct"] == 22.0 assert [x["sheets"] for x in out["levels"]] == [2, 2] @@ -206,7 +207,9 @@ def test_estimate_close_by_bids_partial_depth(): assert out["covered_sheets"] == 1 assert out["uncovered_sheets"] == 2 assert out["total_received"] == 0.1 - assert out["estimated_pnl"] == -0.1 + # 净盈亏 = 回收 − 全部权利金(不按覆盖比例摊薄) + assert out["estimated_pnl"] == -0.5 + assert out["estimated_pnl_ratio_pct"] == round(-0.5 / 0.6 * 100, 2) def test_estimate_close_by_bids_empty():