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 += `
${esc(shortOptionsInst(p.inst_id))} |
${esc(optType)} |
@@ -3562,8 +3571,8 @@
${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)) + "%"} |
`;
});
html += "
";
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():