diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 502e56b..cb92e6a 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -2658,6 +2658,40 @@ html[data-theme="light"] .opt-be-dist-down { .options-page-wrap .options-order-grid .v { font-size: 0.8rem; } +.options-estimate-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin: 8px 0 10px; + padding: 8px 10px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + border: 1px dashed rgba(255, 255, 255, 0.08); + font-size: 0.74rem; +} +.options-estimate-row .opt-est-label { + color: #8892b0; +} +.options-estimate-row .opt-target-idx { + width: 120px; + font-size: 0.74rem; + padding: 3px 6px; +} +.options-estimate-row .k { + color: #8892b0; +} +.options-estimate-row .v { + font-size: 0.82rem; + font-weight: 600; +} +.options-estimate-row .opt-est-note { + font-size: 0.66rem; +} +html[data-theme="light"] .options-estimate-row { + background: rgba(0, 0, 0, 0.02); + border-color: rgba(0, 0, 0, 0.08); +} .options-hint { font-size: 0.75rem; margin-bottom: 6px; diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 36a23e1..4f4bbbe 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -13,6 +13,7 @@ optType: "C", chain: panelCache.chain || null, selectedInst: null, + orderQuote: null, }; function fmt(v, d) { @@ -72,7 +73,7 @@ const tr = document.createElement("tr"); tr.className = "opt-order-inline-row"; const td = document.createElement("td"); - td.colSpan = 6; + td.colSpan = 8; td.appendChild(panel); tr.appendChild(td); row.after(tr); @@ -158,6 +159,60 @@ return price + "/" + size; } + function pnlCls(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return ""; + const n = Number(v); + if (n > 0) return "pos-pnl-profit"; + if (n < 0) return "pos-pnl-loss"; + return ""; + } + + function estimateExpiryProfit(optType, strike, targetIdx, entryPx, ethAmount) { + if (strike == null || targetIdx == null || entryPx == null || ethAmount == null) return null; + const amt = Number(ethAmount); + const entry = Number(entryPx); + const tgt = Number(targetIdx); + const k = Number(strike); + if (!Number.isFinite(amt) || !Number.isFinite(entry) || !Number.isFinite(tgt) || !Number.isFinite(k) || amt <= 0) { + return null; + } + const o = (optType || "").toUpperCase(); + let intrinsic = 0; + if (o === "C") intrinsic = Math.max(0, tgt - k); + else if (o === "P") intrinsic = Math.max(0, k - tgt); + else return null; + return Math.round((intrinsic - entry) * amt * 10000) / 10000; + } + + function updateEstimatedProfit() { + const el = document.getElementById("opt-est-profit"); + const targetEl = document.getElementById("opt-target-idx"); + if (!el || !targetEl) return; + const q = state.orderQuote; + if (!q || !q.ok) { + el.textContent = "—"; + el.className = "v"; + return; + } + const targetRaw = targetEl.value; + if (targetRaw === "" || targetRaw == null) { + el.textContent = "—"; + el.className = "v"; + return; + } + const sz = q.sizing || {}; + const ethAmount = sz.eth_amount; + const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), q.ask, ethAmount); + if (profit == null || Number.isNaN(profit)) { + el.textContent = "—"; + el.className = "v"; + return; + } + const sign = profit > 0 ? "+" : ""; + el.textContent = sign + profit.toFixed(4) + " USDC"; + el.className = "v " + pnlCls(profit); + } + function fmtDist(v) { if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; const n = Number(v); @@ -211,8 +266,7 @@ "" + (c.expiry_be_px != null ? fmt(c.expiry_be_px, 0) : "—") + "" + '' + fmtDist(c.dist_expiry_be) + "" + '' + - ' ' + - '' + + '' + ""; tbody.appendChild(tr); if (c.inst_id === prevSelected) stillVisible = true; @@ -222,12 +276,6 @@ selectContract(btn.getAttribute("data-inst"), btn); }); }); - tbody.querySelectorAll(".opt-buy-btn").forEach(function (btn) { - btn.addEventListener("click", async function () { - await selectContract(btn.getAttribute("data-inst"), null); - await openPosition(); - }); - }); if (stillVisible && prevSelected) { selectContract(prevSelected, null, true); } else { @@ -236,6 +284,7 @@ } function fillOrderPanel(d) { + state.orderQuote = d && d.ok ? d : null; const sz = d.sizing || {}; document.getElementById("opt-order-inst").textContent = d.inst_id || state.selectedInst || ""; document.getElementById("opt-order-ask").textContent = fmtPxSz(d.ask, d.ask_sz); @@ -264,6 +313,7 @@ msgEl.textContent = ""; msgEl.classList.remove("opt-error"); } + updateEstimatedProfit(); } async function selectContract(instId, pickBtn, silent) { @@ -553,12 +603,19 @@ }); }); - ["opt-sheets-amount", "opt-eth-amount"].forEach(function (id) { + ["opt-sheets-amount", "opt-eth-amount", "opt-target-idx"].forEach(function (id) { const el = document.getElementById(id); if (!el) return; el.addEventListener("change", function () { + if (id === "opt-target-idx") { + updateEstimatedProfit(); + return; + } if (state.selectedInst) selectContract(state.selectedInst, null, true); }); + if (id === "opt-target-idx") { + el.addEventListener("input", updateEstimatedProfit); + } }); bootOptionsPanel(); diff --git a/lib/options/options_pricing_lib.py b/lib/options/options_pricing_lib.py index 24a8028..5be60da 100644 --- a/lib/options/options_pricing_lib.py +++ b/lib/options/options_pricing_lib.py @@ -234,3 +234,26 @@ def format_options_breakeven_line( if idx_px is not None and parts: return " ".join(parts) + f"(指数{idx_px:.0f})" return " ".join(parts) + + +def estimate_expiry_profit_at_index( + *, + opt_type: str, + strike: float | None, + target_idx: float | None, + entry_px: float | None, + eth_amount: float | None, +) -> float | None: + """到期测算:目标指数价下按内在价值减权利金(每 1 币报价 × 币量)。""" + if strike is None or target_idx is None or entry_px is None or eth_amount is None: + return None + if eth_amount <= 0: + return None + o = (opt_type or "").upper() + if o == "C": + intrinsic = max(0.0, float(target_idx) - float(strike)) + elif o == "P": + intrinsic = max(0.0, float(strike) - float(target_idx)) + else: + return None + return round((intrinsic - float(entry_px)) * float(eth_amount), 4) diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index ea4ba7d..e61ceaf 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -49,6 +49,13 @@
到期平衡
距平衡
+
+ + + 预计盈利 + + 到期测算,仅供参考 +
diff --git a/tests/test_options_pricing.py b/tests/test_options_pricing.py index f3e509e..54cd604 100644 --- a/tests/test_options_pricing.py +++ b/tests/test_options_pricing.py @@ -67,6 +67,21 @@ def test_option_moneyness(): assert option_moneyness_label("otm") == "虚值" +def test_estimate_expiry_profit_at_index(): + from lib.options.options_pricing_lib import estimate_expiry_profit_at_index + + # Call 1780, ask 12.2, 0.01 ETH, target 1793 -> (13-12.2)*0.01 = 0.008 + p = estimate_expiry_profit_at_index( + opt_type="C", strike=1780, target_idx=1793, entry_px=12.2, eth_amount=0.01 + ) + assert p == 0.008 + # OTM call loses premium + p2 = estimate_expiry_profit_at_index( + opt_type="C", strike=1780, target_idx=1770, entry_px=12.2, eth_amount=0.01 + ) + assert p2 == round(-12.2 * 0.01, 4) + + def test_format_quote_liquidity(): from lib.options.options_pricing_lib import format_quote_liquidity