Add T-shaped options chain view with straddle metrics (phase A).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-11 15:00:50 +08:00
parent c9c8388250
commit 6f6545ce52
5 changed files with 443 additions and 19 deletions
+93
View File
@@ -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);
+256 -13
View File
@@ -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 '<span class="muted">不可双买</span>';
return fmtUsdc(per) + " USDC";
}
function pickBtnHtml(instId) {
if (!instId) return "—";
return '<button type="button" class="btn-secondary opt-pick-btn" data-inst="' + instId + '">选择</button>';
}
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 = '<tr><td colspan="8" class="muted">请选择到期日</td></tr>';
tbody.innerHTML = '<tr><td colspan="' + cols + '" class="muted">请选择到期日</td></tr>';
state.selectedInst = null;
return;
}
@@ -441,7 +589,7 @@
parkOrderPanel();
const label = moneyFilterLabel();
const suffix = label ? label : optTypeLabel(state.optType);
tbody.innerHTML = '<tr><td colspan="8" class="muted">该到期日暂无' + suffix + "合约</td></tr>";
tbody.innerHTML = '<tr><td colspan="' + cols + '" class="muted">该到期日暂无' + suffix + "合约</td></tr>";
state.selectedInst = null;
return;
}
@@ -462,21 +610,86 @@
"<td>" + (c.expiry_be_px != null ? fmt(c.expiry_be_px, 0) : "—") + "</td>" +
'<td class="' + distBeClass(c.dist_expiry_be) + '">' + fmtDist(c.dist_expiry_be) + "</td>" +
'<td class="opt-row-actions">' +
'<button type="button" class="btn-secondary opt-pick-btn" data-inst="' + c.inst_id + '">选择</button>' +
pickBtnHtml(c.inst_id) +
"</td>";
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 = '<tr><td colspan="' + cols + '" class="muted">请选择到期日</td></tr>';
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 = '<tr><td colspan="' + cols + '" class="muted">该到期日暂无' + suffix + "行权价</td></tr>";
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 =
'<td class="opt-t-call opt-px-sz">' + (call ? fmtPxSz(call.ask, call.ask_sz, call.ask_estimated) : "—") + "</td>" +
'<td class="opt-t-call">' + (call ? moneynessBadge(call) : "—") + "</td>" +
'<td class="opt-t-call opt-row-actions">' + pickBtnHtml(call && call.inst_id) + "</td>" +
'<td class="opt-t-mid opt-t-strike"><strong>' + row.strike + "</strong></td>" +
'<td class="opt-t-mid opt-t-straddle-prem">' + formatStraddlePremiumCell(call && call.ask, put && put.ask) + "</td>" +
'<td class="opt-t-mid opt-t-straddle-band">' + formatStraddleBand(row.strike, combined) + "</td>" +
'<td class="opt-t-put">' + (put ? moneynessBadge(put) : "—") + "</td>" +
'<td class="opt-t-put opt-px-sz">' + (put ? fmtPxSz(put.ask, put.ask_sz, put.ask_estimated) : "—") + "</td>" +
'<td class="opt-t-put opt-row-actions">' + pickBtnHtml(put && put.inst_id) + "</td>";
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 = '<td colspan="' + cols + '" class="muted opt-strike-hint">默认显示 ATM ±5 档 · 勾选「展开全部」查看该到期全部行权价</td>';
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"); });
+46
View File
@@ -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}"
+31 -6
View File
@@ -7,23 +7,32 @@
<div class="options-dual-grid">
<div class="card options-order-card">
<h2>期权下单</h2>
<p class="muted options-hint">报价单位为每 1 ETH/BTC;1 张 = 0.01.卖一/买一列为 <strong>价格/张数</strong>;卖一无挂单时以标记价估算并标 <strong>~</strong>.链展示近 <span id="opt-chain-dte">14</span> 日到期,标注实值/虚值;<strong>到期平衡</strong>按卖一预估(无卖一按标记价).资金划转与 USDT/USDC 兑换见「系统设置 → 期权设置」.</p>
<p class="muted options-hint">报价单位为每 1 ETH/BTC;1 张 = 0.01.<strong>列表</strong>卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算.卖一无挂单时以标记价估算并标 <strong>~</strong>.链展示近 <span id="opt-chain-dte">14</span> 日到期.<strong>T 型</strong>默认 ATM ±5 档,可展开全部.</p>
<div class="form-row options-chain-toolbar">
<button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button>
<select id="opt-exp-select"><option value="">选择到期日</option></select>
<button type="button" class="btn-secondary opt-type-btn active" data-type="C">看涨 Call</button>
<button type="button" class="btn-secondary opt-type-btn" data-type="P">看跌 Put</button>
<span class="opt-chain-view-group">
<button type="button" class="btn-secondary opt-view-btn active" data-view="list">列表</button>
<button type="button" class="btn-secondary opt-view-btn" data-view="t">T 型</button>
</span>
<span id="opt-type-btn-group" class="opt-type-btn-group">
<button type="button" class="btn-secondary opt-type-btn active" data-type="C">看涨 Call</button>
<button type="button" class="btn-secondary opt-type-btn" data-type="P">看跌 Put</button>
</span>
<button type="button" class="btn-secondary opt-money-btn active" data-money="all">全部</button>
<button type="button" class="btn-secondary opt-money-btn" data-money="itm">实值</button>
<button type="button" class="btn-secondary opt-money-btn" data-money="otm">虚值</button>
<label id="opt-strike-expand-wrap" class="opt-strike-expand-label" hidden>
<input type="checkbox" id="opt-strike-expand-all"> 展开全部
</label>
<button type="button" class="btn-secondary" id="opt-load-chain">刷新链</button>
</div>
<div id="opt-index-line" class="muted"></div>
<div class="options-strike-table-wrap">
<div class="options-strike-table-wrap" id="opt-strike-table-wrap">
<table class="options-strike-table" id="opt-strike-table">
<thead>
<tr>
<tr id="opt-strike-head-list">
<th>行权价</th>
<th>类型</th>
<th>合约</th>
@@ -33,6 +42,22 @@
<th>距平衡</th>
<th>操作</th>
</tr>
<tr id="opt-strike-head-t" class="hidden">
<th colspan="3" class="opt-t-head-call">Call</th>
<th colspan="3" class="opt-t-head-mid">跨式</th>
<th colspan="3" class="opt-t-head-put">Put</th>
</tr>
<tr id="opt-strike-head-t-cols" class="hidden">
<th>卖一/张</th>
<th>类型</th>
<th>操作</th>
<th>行权价</th>
<th title="Call卖一+Put卖一(每1币)">双买/币</th>
<th title="到期测算平衡带">平衡带</th>
<th>类型</th>
<th>卖一/张</th>
<th>操作</th>
</tr>
</thead>
<tbody id="opt-strike-tbody">
<tr><td colspan="8" class="muted">请选择到期日</td></tr>
@@ -215,4 +240,4 @@
</div>
</div>
<script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=27"></script>
<script src="/static/options_panel.js?v=28"></script>
+17
View File
@@ -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,