From c9c8388250495c280e8255de43671069460f9295 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 11 Jul 2026 14:37:53 +0800 Subject: [PATCH] Improve options order estimates and chain filter defaults. Co-authored-by: Cursor --- lib/common/static/options_panel.js | 171 +++++++++++++++++------ lib/options/options_pricing_lib.py | 35 ++++- lib/options/templates/options_panel.html | 11 +- tests/test_options_pricing.py | 50 +++++-- 4 files changed, 204 insertions(+), 63 deletions(-) diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index d163871..2d7d3c4 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -11,7 +11,7 @@ const state = { underlying: root.dataset.defaultUnderly || "ETH", optType: "C", - moneyFilter: "itm", + moneyFilter: "all", chain: panelCache.chain || null, selectedInst: null, orderQuote: null, @@ -137,12 +137,37 @@ function matchesMoneyFilter(moneyness) { const m = (moneyness || "").toLowerCase(); + if (state.moneyFilter === "all") return true; if (state.moneyFilter === "otm") return m === "otm"; return m === "itm" || m === "atm"; } function moneyFilterLabel() { - return state.moneyFilter === "otm" ? "虚值" : "实值"; + if (state.moneyFilter === "otm") return "虚值"; + if (state.moneyFilter === "itm") return "实值"; + return ""; + } + + function countContractsForType(contracts) { + return (contracts || []).filter(function (c) { + return c.opt_type === state.optType; + }).length; + } + + function syncMoneyFilterButtons() { + document.querySelectorAll(".opt-money-btn").forEach(function (b) { + b.classList.toggle("active", (b.getAttribute("data-money") || "") === state.moneyFilter); + }); + } + + function resetMoneyFilterToAll() { + state.moneyFilter = "all"; + syncMoneyFilterButtons(); + } + + function updateUnderlyingLabel() { + const el = document.getElementById("opt-order-eth-label"); + if (el) el.textContent = state.underlying + " 数量"; } function filterChainContracts(contracts) { @@ -173,24 +198,40 @@ } } - function renderExpiries() { - const sel = document.getElementById("opt-exp-select"); - sel.innerHTML = ''; - const exps = (state.chain && state.chain.expiries) || []; - exps.forEach(function (e) { - const o = document.createElement("option"); - o.value = String(e.exp_time); - o.textContent = expLabel(e.exp_time) + " (" + filterChainContracts(e.contracts).length + ")"; - sel.appendChild(o); - }); + function renderIndexLine() { const idx = state.chain && state.chain.index_px; const dte = state.chain && state.chain.chain_max_dte_days; if (dte != null) { const el = document.getElementById("opt-chain-dte"); if (el) el.textContent = String(Math.round(dte)); } - document.getElementById("opt-index-line").textContent = - "指数 " + state.underlying + " ≈ " + fmt(idx, 2) + " · 实值含平值 · 虚值=价外"; + const line = document.getElementById("opt-index-line"); + if (line) { + line.textContent = + "指数 " + state.underlying + " ≈ " + fmt(idx, 2) + " · 默认显示全部 · 实值含平值 · 虚值=价外"; + } + } + + function renderExpiryOptions(preserveSelection) { + const sel = document.getElementById("opt-exp-select"); + if (!sel) return; + const prev = preserveSelection !== false ? sel.value : ""; + sel.innerHTML = ''; + const exps = (state.chain && state.chain.expiries) || []; + exps.forEach(function (e) { + const o = document.createElement("option"); + o.value = String(e.exp_time); + o.textContent = expLabel(e.exp_time) + " (" + countContractsForType(e.contracts) + ")"; + sel.appendChild(o); + }); + if (prev && exps.some(function (e) { return String(e.exp_time) === String(prev); })) { + sel.value = prev; + } + } + + function renderExpiries() { + renderExpiryOptions(true); + renderIndexLine(); } function fmtPxSz(px, sz, estimated) { @@ -259,21 +300,28 @@ 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); + function expiryIntrinsicPerUnit(optType, strike, targetIdx) { 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; - } + if (!Number.isFinite(tgt) || !Number.isFinite(k)) 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; + if (o === "C") return Math.max(0, tgt - k); + if (o === "P") return Math.max(0, k - tgt); + return null; + } + + function estimateExpiryValue(optType, strike, targetIdx, ethAmount) { + const amt = Number(ethAmount); + const intrinsic = expiryIntrinsicPerUnit(optType, strike, targetIdx); + if (intrinsic == null || !Number.isFinite(amt) || amt <= 0) return null; + return Math.round(intrinsic * amt * 100) / 100; + } + + function estimateExpiryProfit(optType, strike, targetIdx, ethAmount, totalPremium) { + const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount); + const prem = Number(totalPremium); + if (value == null || !Number.isFinite(prem)) return null; + return Math.round((value - prem) * 100) / 100; } function calcContractLeverage(indexPx, ethAmount, totalPremium) { @@ -292,14 +340,23 @@ return "约 " + Number(v).toFixed(1) + "×"; } + function fmtUsdcSigned(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const sign = n > 0 ? "+" : ""; + return sign + fmtUsdc(n) + " USDC"; + } + function updateOrderEstimates() { const levEl = document.getElementById("opt-order-leverage"); + const valueEl = document.getElementById("opt-est-value"); const profitEl = document.getElementById("opt-est-profit"); const targetLevEl = document.getElementById("opt-est-leverage"); const targetEl = document.getElementById("opt-target-idx"); const q = state.orderQuote; if (!q || !q.ok) { if (levEl) levEl.textContent = "—"; + if (valueEl) valueEl.textContent = "—"; if (profitEl) { profitEl.textContent = "—"; profitEl.className = "v"; @@ -313,20 +370,26 @@ const lev = calcContractLeverage(q.index_px, ethAmount, premium); if (levEl) levEl.textContent = fmtLeverage(lev); - if (profitEl && targetEl) { + if (valueEl && profitEl && targetEl) { const targetRaw = targetEl.value; if (targetRaw === "" || targetRaw == null) { + valueEl.textContent = "—"; profitEl.textContent = "—"; profitEl.className = "v"; if (targetLevEl) targetLevEl.textContent = "—"; } else { - const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), q.ask, ethAmount); + const value = estimateExpiryValue(q.opt_type, q.strike, Number(targetRaw), ethAmount); + const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), ethAmount, premium); + if (value == null || Number.isNaN(value)) { + valueEl.textContent = "—"; + } else { + valueEl.textContent = fmtUsdc(value) + " USDC"; + } if (profit == null || Number.isNaN(profit)) { profitEl.textContent = "—"; profitEl.className = "v"; } else { - const sign = profit > 0 ? "+" : ""; - profitEl.textContent = sign + profit.toFixed(4) + " USDC"; + profitEl.textContent = fmtUsdcSigned(profit); profitEl.className = "v " + pnlCls(profit); } const targetLev = calcContractLeverage(Number(targetRaw), ethAmount, premium); @@ -358,9 +421,9 @@ const tbody = document.getElementById("opt-strike-tbody"); const expMs = document.getElementById("opt-exp-select").value; const prevSelected = state.selectedInst; - parkOrderPanel(); tbody.innerHTML = ""; if (!expMs || !state.chain) { + parkOrderPanel(); tbody.innerHTML = '请选择到期日'; state.selectedInst = null; return; @@ -368,14 +431,23 @@ const exp = (state.chain.expiries || []).find(function (e) { return String(e.exp_time) === String(expMs); }); - if (!exp) return; - const list = filterChainContracts(exp.contracts); - if (!list.length) { - tbody.innerHTML = '该到期日暂无' + moneyFilterLabel() + "合约"; + if (!exp) { + parkOrderPanel(); state.selectedInst = null; return; } - let stillVisible = false; + const list = filterChainContracts(exp.contracts); + if (!list.length) { + parkOrderPanel(); + const label = moneyFilterLabel(); + const suffix = label ? label : optTypeLabel(state.optType); + tbody.innerHTML = '该到期日暂无' + suffix + "合约"; + state.selectedInst = null; + return; + } + const stillVisible = !!prevSelected && list.some(function (c) { return c.inst_id === prevSelected; }); + if (!stillVisible) parkOrderPanel(); + let matchedSelected = false; list.forEach(function (c) { const tr = document.createElement("tr"); tr.className = "opt-strike-row"; @@ -393,14 +465,14 @@ '' + ""; tbody.appendChild(tr); - if (c.inst_id === prevSelected) stillVisible = true; + if (c.inst_id === prevSelected) matchedSelected = true; }); tbody.querySelectorAll(".opt-pick-btn").forEach(function (btn) { btn.addEventListener("click", function () { selectContract(btn.getAttribute("data-inst"), btn); }); }); - if (stillVisible && prevSelected) { + if (matchedSelected && prevSelected) { selectContract(prevSelected, null, true); } else { state.selectedInst = null; @@ -416,7 +488,8 @@ if (bidEl) bidEl.textContent = fmtPxSz(d.bid, d.bid_sz); document.getElementById("opt-order-sheets").textContent = sz.sheets != null ? sz.sheets : "—"; document.getElementById("opt-order-eth").textContent = sz.eth_amount != null ? sz.eth_amount : "—"; - document.getElementById("opt-order-premium").textContent = sz.total_premium != null ? fmt(sz.total_premium, 4) + " USDC" : "—"; + updateUnderlyingLabel(); + document.getElementById("opt-order-premium").textContent = sz.total_premium != null ? fmtUsdc(sz.total_premium) + " USDC" : "—"; const beEl = document.getElementById("opt-order-expiry-be"); const distEl = document.getElementById("opt-order-dist-be"); if (beEl) { @@ -473,7 +546,9 @@ panelCache.underlying = state.underlying; panelCache.optType = state.optType; state.selectedInst = null; + resetMoneyFilterToAll(); parkOrderPanel(); + updateUnderlyingLabel(); renderExpiries(); renderStrikes(); } @@ -973,8 +1048,15 @@ }, 120); } + function onExpiryChange() { + resetMoneyFilterToAll(); + renderStrikes(); + } + function bootOptionsPanel() { updateSizeInputs(); + syncMoneyFilterButtons(); + updateUnderlyingLabel(); const hasCache = panelCache.chain && panelCache.underlying === state.underlying && @@ -1006,22 +1088,21 @@ document.querySelectorAll(".opt-type-btn").forEach(function (b) { b.classList.remove("active"); }); btn.classList.add("active"); state.optType = btn.getAttribute("data-type"); - renderExpiries(); + resetMoneyFilterToAll(); + renderExpiryOptions(true); renderStrikes(); }); }); document.querySelectorAll(".opt-money-btn").forEach(function (btn) { btn.addEventListener("click", function () { - document.querySelectorAll(".opt-money-btn").forEach(function (b) { b.classList.remove("active"); }); - btn.classList.add("active"); - state.moneyFilter = btn.getAttribute("data-money") || "itm"; - renderExpiries(); + state.moneyFilter = btn.getAttribute("data-money") || "all"; + syncMoneyFilterButtons(); renderStrikes(); }); }); - document.getElementById("opt-exp-select").addEventListener("change", renderStrikes); + document.getElementById("opt-exp-select").addEventListener("change", onExpiryChange); document.getElementById("opt-load-chain").addEventListener("click", loadChain); document.getElementById("opt-refresh-positions").addEventListener("click", refreshAllPositions); document.getElementById("opt-open-btn").addEventListener("click", openPosition); diff --git a/lib/options/options_pricing_lib.py b/lib/options/options_pricing_lib.py index b05a8ac..aa944c1 100644 --- a/lib/options/options_pricing_lib.py +++ b/lib/options/options_pricing_lib.py @@ -298,16 +298,15 @@ def format_options_breakeven_line( return " ".join(parts) -def estimate_expiry_profit_at_index( +def estimate_expiry_value_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: + """到期测算:目标指数价下期权内在价值总额(不含已付权利金).""" + if strike is None or target_idx is None or eth_amount is None: return None if eth_amount <= 0: return None @@ -318,7 +317,33 @@ def estimate_expiry_profit_at_index( intrinsic = max(0.0, float(strike) - float(target_idx)) else: return None - return round((intrinsic - float(entry_px)) * float(eth_amount), 4) + return round(intrinsic * float(eth_amount), 2) + + +def estimate_expiry_profit_at_index( + *, + opt_type: str, + strike: float | None, + target_idx: float | None, + entry_px: float | None, + eth_amount: float | None, + total_premium: float | None = None, +) -> float | None: + """到期测算:目标指数价下净盈利 = 预计价值 − 权利金.""" + value = estimate_expiry_value_at_index( + opt_type=opt_type, + strike=strike, + target_idx=target_idx, + eth_amount=eth_amount, + ) + if value is None: + return None + prem = total_premium + if prem is None and entry_px is not None and eth_amount is not None: + prem = float(entry_px) * float(eth_amount) + if prem is None: + return None + return round(float(value) - float(prem), 2) def equivalent_contract_leverage( diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 24a0d77..c7bfaa0 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -14,7 +14,8 @@ - + + @@ -46,7 +47,7 @@
卖一/张
买一/张
张数
-
ETH/BTC 数量
+
ETH 数量
预估权利金
合约杠杆
到期平衡
@@ -55,7 +56,9 @@
- 预计盈利 + 预计价值 + + 盈利 目标杠杆 @@ -212,4 +215,4 @@
- + diff --git a/tests/test_options_pricing.py b/tests/test_options_pricing.py index 34e4031..7e003ca 100644 --- a/tests/test_options_pricing.py +++ b/tests/test_options_pricing.py @@ -75,19 +75,51 @@ def test_equivalent_contract_leverage(): assert lev == 144.9 -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 +def test_estimate_expiry_value_and_profit_at_index(): + from lib.options.options_pricing_lib import ( + estimate_expiry_profit_at_index, + estimate_expiry_value_at_index, ) - assert p == 0.008 + + value = estimate_expiry_value_at_index( + opt_type="C", strike=1800, target_idx=2000, eth_amount=1.0 + ) + assert value == 200.0 + + profit = estimate_expiry_profit_at_index( + opt_type="C", + strike=1800, + target_idx=2000, + entry_px=0.148, + eth_amount=1.0, + total_premium=14.8, + ) + assert profit == 185.2 + + # Call 1780, ask 12.2, 0.01 ETH, target 1793 -> value 0.13, profit 0.01 + v = estimate_expiry_value_at_index( + opt_type="C", strike=1780, target_idx=1793, eth_amount=0.01 + ) + assert v == 0.13 + p = estimate_expiry_profit_at_index( + opt_type="C", + strike=1780, + target_idx=1793, + entry_px=12.2, + eth_amount=0.01, + total_premium=0.122, + ) + assert p == 0.01 # 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 + opt_type="C", + strike=1780, + target_idx=1770, + entry_px=12.2, + eth_amount=0.01, + total_premium=0.122, ) - assert p2 == round(-12.2 * 0.01, 4) + assert p2 == -0.12 def test_resolve_chain_quote_otm_no_quote():