From 6f6545ce5245f35c9c55708ada352f3f48d0ee75 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 11 Jul 2026 15:00:50 +0800 Subject: [PATCH] Add T-shaped options chain view with straddle metrics (phase A). Co-authored-by: Cursor --- lib/common/static/instance_theme.css | 93 ++++++++ lib/common/static/options_panel.js | 269 +++++++++++++++++++++-- lib/options/options_pricing_lib.py | 46 ++++ lib/options/templates/options_panel.html | 37 +++- tests/test_options_pricing.py | 17 ++ 5 files changed, 443 insertions(+), 19 deletions(-) diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 0c85550..90e3fdb 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -3124,6 +3124,7 @@ html[data-theme="light"] .opt-be-dist-down { .opt-uly-btn.active, .opt-type-btn.active, .opt-money-btn.active, +.opt-view-btn.active, .opt-pos-tab.active { border-color: #5b8cff; color: #cfe0ff; @@ -3142,6 +3143,98 @@ html[data-theme="light"] .opt-be-dist-down { .opt-strike-row.opt-row-selected td:first-child { box-shadow: inset 3px 0 0 #5b8cff; } +.opt-chain-view-group { + display: inline-flex; + gap: 4px; +} +.opt-type-btn-group { + display: inline-flex; + gap: 4px; +} +.opt-strike-expand-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.78rem; + color: var(--text-soft, #9aa4b2); + white-space: nowrap; + cursor: pointer; + user-select: none; +} +.opt-strike-expand-label input { + margin: 0; +} +.options-strike-table-wrap--t { + max-height: 380px; +} +.options-strike-table--t thead th { + text-align: center; +} +.options-strike-table--t .opt-t-head-call { + text-align: center; + color: #8ec5ff; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.options-strike-table--t .opt-t-head-mid { + text-align: center; + color: #ffd48a; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.options-strike-table--t .opt-t-head-put { + text-align: center; + color: #ff9f9f; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.options-strike-table--t .opt-t-strike { + text-align: center; + font-variant-numeric: tabular-nums; + background: rgba(255, 255, 255, 0.03); +} +.options-strike-table--t .opt-t-mid { + text-align: center; + background: rgba(255, 212, 138, 0.04); +} +.options-strike-table--t .opt-t-straddle-prem { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.options-strike-table--t .opt-t-straddle-band { + font-variant-numeric: tabular-nums; + white-space: nowrap; + font-size: 0.72rem; + color: var(--text-soft, #9aa4b2); +} +.options-strike-table--t .opt-t-call, +.options-strike-table--t .opt-t-put { + text-align: center; +} +.options-strike-table--t .opt-strike-row-atm td { + background: rgba(255, 212, 138, 0.08); +} +.options-strike-table--t .opt-strike-row-atm .opt-t-strike strong { + color: #ffd48a; +} +.options-strike-table--t .opt-strike-row.opt-row-selected td { + background: rgba(74, 124, 255, 0.12); +} +.opt-strike-hint-row td { + text-align: center; + font-size: 0.72rem; + padding: 8px 4px; + border-bottom: none; +} +html[data-theme="light"] .options-strike-table--t .opt-t-head-call { + color: #1565c0; +} +html[data-theme="light"] .options-strike-table--t .opt-t-head-mid { + color: #e65100; +} +html[data-theme="light"] .options-strike-table--t .opt-t-head-put { + color: #c62828; +} +html[data-theme="light"] .options-strike-table--t .opt-strike-row-atm td { + background: rgba(255, 152, 0, 0.08); +} .opt-order-inline-row td { padding: 14px 16px !important; background: rgba(74, 124, 255, 0.07); diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 2d7d3c4..bc7efd3 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -12,6 +12,8 @@ underlying: root.dataset.defaultUnderly || "ETH", optType: "C", moneyFilter: "all", + chainView: "list", + strikeExpandAll: false, chain: panelCache.chain || null, selectedInst: null, orderQuote: null, @@ -84,7 +86,10 @@ function placeOrderPanelAfter(instId) { const panel = orderPanel(); if (!panel || !instId) return; - const row = document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-inst="' + CSS.escape(instId) + '"]'); + const row = + document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-inst="' + CSS.escape(instId) + '"]') || + document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-call-inst="' + CSS.escape(instId) + '"]') || + document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-put-inst="' + CSS.escape(instId) + '"]'); if (!row) return; document.querySelectorAll(".opt-strike-row").forEach(function (r) { r.classList.toggle("opt-row-selected", r === row); @@ -100,7 +105,7 @@ const tr = document.createElement("tr"); tr.className = "opt-order-inline-row"; const td = document.createElement("td"); - td.colSpan = 8; + td.colSpan = strikeTableColspan(); td.appendChild(panel); tr.appendChild(td); row.after(tr); @@ -135,6 +140,31 @@ return url; } + function strikeTableColspan() { + return state.chainView === "t" ? 9 : 8; + } + + function syncChainViewUI() { + const isT = state.chainView === "t"; + document.querySelectorAll(".opt-view-btn").forEach(function (b) { + b.classList.toggle("active", (b.getAttribute("data-view") || "") === state.chainView); + }); + const typeGroup = document.getElementById("opt-type-btn-group"); + if (typeGroup) typeGroup.hidden = isT; + const expandWrap = document.getElementById("opt-strike-expand-wrap"); + if (expandWrap) expandWrap.hidden = !isT; + const headList = document.getElementById("opt-strike-head-list"); + const headT = document.getElementById("opt-strike-head-t"); + const headTCols = document.getElementById("opt-strike-head-t-cols"); + if (headList) headList.classList.toggle("hidden", isT); + if (headT) headT.classList.toggle("hidden", !isT); + if (headTCols) headTCols.classList.toggle("hidden", !isT); + const wrap = document.getElementById("opt-strike-table-wrap"); + if (wrap) wrap.classList.toggle("options-strike-table-wrap--t", isT); + const table = document.getElementById("opt-strike-table"); + if (table) table.classList.toggle("options-strike-table--t", isT); + } + function matchesMoneyFilter(moneyness) { const m = (moneyness || "").toLowerCase(); if (state.moneyFilter === "all") return true; @@ -149,11 +179,105 @@ } function countContractsForType(contracts) { + if (state.chainView === "t") { + return countStraddleStrikes(contracts); + } return (contracts || []).filter(function (c) { return c.opt_type === state.optType; }).length; } + function countStraddleStrikes(contracts) { + const strikes = new Set(); + (contracts || []).forEach(function (c) { + if (c.strike != null) strikes.add(String(c.strike)); + }); + return strikes.size; + } + + function buildStraddleRows(contracts) { + const map = {}; + (contracts || []).forEach(function (c) { + const key = String(c.strike); + if (!map[key]) map[key] = { strike: c.strike, call: null, put: null }; + const o = (c.opt_type || "").toUpperCase(); + if (o === "C") map[key].call = c; + else if (o === "P") map[key].put = c; + }); + return Object.keys(map) + .map(function (k) { return map[k]; }) + .sort(function (a, b) { return Number(a.strike) - Number(b.strike); }); + } + + function findAtmStrike(rows, indexPx) { + if (!rows.length || indexPx == null || Number.isNaN(Number(indexPx))) return null; + let best = rows[0].strike; + let bestDist = Math.abs(Number(rows[0].strike) - Number(indexPx)); + rows.forEach(function (row) { + const d = Math.abs(Number(row.strike) - Number(indexPx)); + if (d < bestDist || (d === bestDist && Number(row.strike) < Number(best))) { + bestDist = d; + best = row.strike; + } + }); + return best; + } + + function matchesStrikeRowFilter(strike, indexPx, atmStrike) { + if (state.moneyFilter === "all") return true; + if (atmStrike != null && Number(strike) === Number(atmStrike)) return true; + if (indexPx == null || Number.isNaN(Number(indexPx))) return true; + if (state.moneyFilter === "itm") return Number(strike) <= Number(indexPx); + if (state.moneyFilter === "otm") return Number(strike) >= Number(indexPx); + return true; + } + + function filterStraddleRows(rows, indexPx) { + const atmStrike = findAtmStrike(rows, indexPx); + return rows.filter(function (row) { + return matchesStrikeRowFilter(row.strike, indexPx, atmStrike); + }); + } + + function sliceAtmWindow(rows, indexPx) { + if (state.strikeExpandAll || !rows.length) return rows; + const atmStrike = findAtmStrike(rows, indexPx); + const idx = rows.findIndex(function (r) { return Number(r.strike) === Number(atmStrike); }); + if (idx < 0) return rows.slice(0, Math.min(rows.length, 11)); + const start = Math.max(0, idx - 5); + const end = Math.min(rows.length, idx + 6); + return rows.slice(start, end); + } + + function straddleAskPerUnit(callAsk, putAsk) { + const c = Number(callAsk); + const p = Number(putAsk); + if (!Number.isFinite(c) || !Number.isFinite(p) || c <= 0 || p <= 0) return null; + return Math.round((c + p) * 10000) / 10000; + } + + function formatStraddleBand(strike, combinedAsk) { + const per = combinedAsk; + if (strike == null || per == null) return "—"; + const k = Number(strike); + const d = Number(per); + if (!Number.isFinite(k) || !Number.isFinite(d)) return "—"; + const lo = Math.round((k - d) * 10) / 10; + const hi = Math.round((k + d) * 10) / 10; + return lo.toFixed(0) + " ~ " + hi.toFixed(0); + } + + function formatStraddlePremiumCell(callAsk, putAsk) { + const per = straddleAskPerUnit(callAsk, putAsk); + if (per == null) return '不可双买'; + return fmtUsdc(per) + " USDC"; + } + + function pickBtnHtml(instId) { + if (!instId) return "—"; + return ''; + } + function syncMoneyFilterButtons() { document.querySelectorAll(".opt-money-btn").forEach(function (b) { b.classList.toggle("active", (b.getAttribute("data-money") || "") === state.moneyFilter); @@ -417,14 +541,38 @@ return ""; } + function bindStrikePickButtons(tbody) { + tbody.querySelectorAll(".opt-pick-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + selectContract(btn.getAttribute("data-inst"), btn); + }); + }); + } + + function finishStrikeRender(tbody, prevSelected, matchedSelected) { + bindStrikePickButtons(tbody); + if (matchedSelected && prevSelected) { + selectContract(prevSelected, null, true); + } else if (!matchedSelected) { + state.selectedInst = null; + } + } + function renderStrikes() { + syncChainViewUI(); + if (state.chainView === "t") renderStrikesT(); + else renderStrikesList(); + } + + function renderStrikesList() { const tbody = document.getElementById("opt-strike-tbody"); const expMs = document.getElementById("opt-exp-select").value; const prevSelected = state.selectedInst; + const cols = strikeTableColspan(); tbody.innerHTML = ""; if (!expMs || !state.chain) { parkOrderPanel(); - tbody.innerHTML = '请选择到期日'; + tbody.innerHTML = '请选择到期日'; state.selectedInst = null; return; } @@ -441,7 +589,7 @@ parkOrderPanel(); const label = moneyFilterLabel(); const suffix = label ? label : optTypeLabel(state.optType); - tbody.innerHTML = '该到期日暂无' + suffix + "合约"; + tbody.innerHTML = '该到期日暂无' + suffix + "合约"; state.selectedInst = null; return; } @@ -462,21 +610,86 @@ "" + (c.expiry_be_px != null ? fmt(c.expiry_be_px, 0) : "—") + "" + '' + fmtDist(c.dist_expiry_be) + "" + '' + - '' + + pickBtnHtml(c.inst_id) + ""; tbody.appendChild(tr); 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 (matchedSelected && prevSelected) { - selectContract(prevSelected, null, true); - } else { + finishStrikeRender(tbody, prevSelected, matchedSelected); + } + + function renderStrikesT() { + const tbody = document.getElementById("opt-strike-tbody"); + const expMs = document.getElementById("opt-exp-select").value; + const prevSelected = state.selectedInst; + const cols = strikeTableColspan(); + const indexPx = state.chain && state.chain.index_px; + tbody.innerHTML = ""; + if (!expMs || !state.chain) { + parkOrderPanel(); + tbody.innerHTML = '请选择到期日'; state.selectedInst = null; + return; } + const exp = (state.chain.expiries || []).find(function (e) { + return String(e.exp_time) === String(expMs); + }); + if (!exp) { + parkOrderPanel(); + state.selectedInst = null; + return; + } + let rows = filterStraddleRows(buildStraddleRows(exp.contracts), indexPx); + rows = sliceAtmWindow(rows, indexPx); + if (!rows.length) { + parkOrderPanel(); + const label = moneyFilterLabel(); + const suffix = label ? label + "区" : "匹配"; + tbody.innerHTML = '该到期日暂无' + suffix + "行权价"; + state.selectedInst = null; + return; + } + const atmStrike = findAtmStrike(rows, indexPx); + const visibleInsts = []; + rows.forEach(function (row) { + if (row.call && row.call.inst_id) visibleInsts.push(row.call.inst_id); + if (row.put && row.put.inst_id) visibleInsts.push(row.put.inst_id); + }); + const stillVisible = !!prevSelected && visibleInsts.indexOf(prevSelected) >= 0; + if (!stillVisible) parkOrderPanel(); + let matchedSelected = false; + rows.forEach(function (row) { + const call = row.call; + const put = row.put; + const combined = straddleAskPerUnit(call && call.ask, put && put.ask); + const tr = document.createElement("tr"); + tr.className = "opt-strike-row opt-strike-row-t"; + tr.setAttribute("data-strike", String(row.strike)); + if (Number(row.strike) === Number(atmStrike)) tr.classList.add("opt-strike-row-atm"); + if (call && call.inst_id) tr.setAttribute("data-call-inst", call.inst_id); + if (put && put.inst_id) tr.setAttribute("data-put-inst", put.inst_id); + tr.innerHTML = + '' + (call ? fmtPxSz(call.ask, call.ask_sz, call.ask_estimated) : "—") + "" + + '' + (call ? moneynessBadge(call) : "—") + "" + + '' + pickBtnHtml(call && call.inst_id) + "" + + '' + row.strike + "" + + '' + formatStraddlePremiumCell(call && call.ask, put && put.ask) + "" + + '' + formatStraddleBand(row.strike, combined) + "" + + '' + (put ? moneynessBadge(put) : "—") + "" + + '' + (put ? fmtPxSz(put.ask, put.ask_sz, put.ask_estimated) : "—") + "" + + '' + pickBtnHtml(put && put.inst_id) + ""; + tbody.appendChild(tr); + if (prevSelected && ((call && call.inst_id === prevSelected) || (put && put.inst_id === prevSelected))) { + matchedSelected = true; + } + }); + if (!state.strikeExpandAll && rows.length >= 1) { + const hint = document.createElement("tr"); + hint.className = "opt-strike-hint-row"; + hint.innerHTML = '默认显示 ATM ±5 档 · 勾选「展开全部」查看该到期全部行权价'; + tbody.appendChild(hint); + } + finishStrikeRender(tbody, prevSelected, matchedSelected); } function fillOrderPanel(d) { @@ -547,6 +760,9 @@ panelCache.optType = state.optType; state.selectedInst = null; resetMoneyFilterToAll(); + state.strikeExpandAll = false; + const expandCb = document.getElementById("opt-strike-expand-all"); + if (expandCb) expandCb.checked = false; parkOrderPanel(); updateUnderlyingLabel(); renderExpiries(); @@ -1050,12 +1266,16 @@ function onExpiryChange() { resetMoneyFilterToAll(); + state.strikeExpandAll = false; + const expandCb = document.getElementById("opt-strike-expand-all"); + if (expandCb) expandCb.checked = false; renderStrikes(); } function bootOptionsPanel() { updateSizeInputs(); syncMoneyFilterButtons(); + syncChainViewUI(); updateUnderlyingLabel(); const hasCache = panelCache.chain && @@ -1083,6 +1303,29 @@ }); }); + document.querySelectorAll(".opt-view-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + const view = btn.getAttribute("data-view") || "list"; + if (view === state.chainView) return; + state.chainView = view; + if (view === "t") { + state.strikeExpandAll = false; + const expandCb = document.getElementById("opt-strike-expand-all"); + if (expandCb) expandCb.checked = false; + } + syncChainViewUI(); + renderStrikes(); + }); + }); + + const expandAllCb = document.getElementById("opt-strike-expand-all"); + if (expandAllCb) { + expandAllCb.addEventListener("change", function () { + state.strikeExpandAll = !!expandAllCb.checked; + renderStrikes(); + }); + } + document.querySelectorAll(".opt-type-btn").forEach(function (btn) { btn.addEventListener("click", function () { document.querySelectorAll(".opt-type-btn").forEach(function (b) { b.classList.remove("active"); }); diff --git a/lib/options/options_pricing_lib.py b/lib/options/options_pricing_lib.py index aa944c1..0f50caa 100644 --- a/lib/options/options_pricing_lib.py +++ b/lib/options/options_pricing_lib.py @@ -358,3 +358,49 @@ def equivalent_contract_leverage( if eth_amount <= 0 or total_premium <= 0: return None return round(float(index_px) * float(eth_amount) / float(total_premium), 1) + + +def straddle_ask_per_unit( + call_ask: float | None, + put_ask: float | None, +) -> float | None: + """跨式双买:每 1 标的币的卖一报价之和.""" + if call_ask is None or put_ask is None: + return None + if float(call_ask) <= 0 or float(put_ask) <= 0: + return None + return round(float(call_ask) + float(put_ask), 4) + + +def straddle_premium_total( + call_ask: float | None, + put_ask: float | None, + eth_amount: float | None, +) -> float | None: + """跨式双买权利金总额(USDC).""" + per = straddle_ask_per_unit(call_ask, put_ask) + if per is None or eth_amount is None or float(eth_amount) <= 0: + return None + return round(per * float(eth_amount), 2) + + +def straddle_breakeven_band( + strike: float | None, + combined_ask_per_unit: float | None, +) -> tuple[float | None, float | None]: + """跨式到期平衡带:下平衡 ~ 上平衡(按双卖一报价和).""" + if strike is None or combined_ask_per_unit is None: + return None, None + k = float(strike) + d = float(combined_ask_per_unit) + return round(k - d, 2), round(k + d, 2) + + +def format_straddle_band( + strike: float | None, + combined_ask_per_unit: float | None, +) -> str: + lo, hi = straddle_breakeven_band(strike, combined_ask_per_unit) + if lo is None or hi is None: + return "" + return f"{lo:.0f} ~ {hi:.0f}" diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index c7bfaa0..877c4af 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -7,23 +7,32 @@

期权下单

-

报价单位为每 1 ETH/BTC;1 张 = 0.01.卖一/买一列为 价格/张数;卖一无挂单时以标记价估算并标 ~.链展示近 14 日到期,标注实值/虚值;到期平衡按卖一预估(无卖一按标记价).资金划转与 USDT/USDC 兑换见「系统设置 → 期权设置」.

+

报价单位为每 1 ETH/BTC;1 张 = 0.01.列表含卖一/买一;T 型仅卖一(买方开仓),中间为跨式双买测算.卖一无挂单时以标记价估算并标 ~.链展示近 14 日到期.T 型默认 ATM ±5 档,可展开全部.

- - + + + + + + + + +
-
+
- + @@ -33,6 +42,22 @@ + + + + + + + + + + + + + + + + @@ -215,4 +240,4 @@ - + diff --git a/tests/test_options_pricing.py b/tests/test_options_pricing.py index 7e003ca..ff17f81 100644 --- a/tests/test_options_pricing.py +++ b/tests/test_options_pricing.py @@ -75,6 +75,23 @@ def test_equivalent_contract_leverage(): assert lev == 144.9 +def test_straddle_pricing(): + from lib.options.options_pricing_lib import ( + format_straddle_band, + straddle_ask_per_unit, + straddle_breakeven_band, + straddle_premium_total, + ) + + assert straddle_ask_per_unit(0.148, 16.2) == 16.348 + assert straddle_premium_total(0.148, 16.2, 1.0) == 16.35 + lo, hi = straddle_breakeven_band(1800, 16.348) + assert lo == 1783.65 + assert hi == 1816.35 + assert format_straddle_band(1800, 16.348) == "1784 ~ 1816" + assert straddle_ask_per_unit(0.148, None) is None + + def test_estimate_expiry_value_and_profit_at_index(): from lib.options.options_pricing_lib import ( estimate_expiry_profit_at_index,
行权价 类型 合约距平衡 操作
请选择到期日