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"); });