Add T-shaped options chain view with straddle metrics (phase A).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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"); });
|
||||
|
||||
Reference in New Issue
Block a user