feat(options): push chain asks/bids via OKX WS + SSE
Replace soft REST polling with OKX public tickers WS ingest and browser SSE patches so list quotes stay live while watching an expiry. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,10 @@
|
||||
root.setAttribute("data-options-booted", "1");
|
||||
|
||||
const panelCache = (window.__optionsPanelCache = window.__optionsPanelCache || {});
|
||||
if (!panelCache.quoteWatcherId) {
|
||||
panelCache.quoteWatcherId =
|
||||
"w" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
const state = {
|
||||
underlying: root.dataset.defaultUnderly || "ETH",
|
||||
@@ -40,11 +44,17 @@
|
||||
let chainSoftTimer = null;
|
||||
let lastChainSoftAt = 0;
|
||||
let chainQuotedAt = 0;
|
||||
let quoteLiveEs = null;
|
||||
let quoteLiveReconnectTimer = null;
|
||||
let quoteLiveOk = false;
|
||||
let quoteLiveWsOk = false;
|
||||
let lastOrderQuoteLiveAt = 0;
|
||||
let pendingTtlSeconds = 600;
|
||||
const POSITIONS_STALE_MS = 45000;
|
||||
const PENDING_POLL_MS = 8000;
|
||||
/** 链卖一/买一静默刷新节流:无推送,靠拉;过密会撞 OKX 50011 */
|
||||
const CHAIN_SOFT_POLL_MS = 15000;
|
||||
/** SSE/WS 断开时的 REST 兜底;连上后停用 */
|
||||
const CHAIN_SOFT_POLL_MS = 30000;
|
||||
const ORDER_QUOTE_LIVE_MIN_MS = 800;
|
||||
const orderPanelHome = (function () {
|
||||
const host = document.getElementById("opt-order-panel-host");
|
||||
return host ? host.parentElement : null;
|
||||
@@ -659,16 +669,214 @@
|
||||
const line = document.getElementById("opt-index-line");
|
||||
if (line) {
|
||||
const liqHint = askLiqFilterOn() ? "仅显示卖一深度≥1张" : "显示全部卖一(含估算~)";
|
||||
const ageHint = chainQuotedAt ? " · 链报价 " + fmtChainQuotedAt() + "(约每15s静默刷新)" : "";
|
||||
let liveHint = "";
|
||||
if (quoteLiveOk && quoteLiveWsOk) {
|
||||
liveHint = chainQuotedAt
|
||||
? " · WS实时 " + fmtChainQuotedAt()
|
||||
: " · WS实时";
|
||||
} else if (quoteLiveOk) {
|
||||
liveHint = " · 推送已连,等待 OKX WS…";
|
||||
} else if (chainQuotedAt) {
|
||||
liveHint = " · 链报价 " + fmtChainQuotedAt() + "(REST兜底)";
|
||||
}
|
||||
line.textContent =
|
||||
"指数 " + state.underlying + " ≈ " + fmt(idx, 2) +
|
||||
" · 默认最近一期 · " + liqHint + " · 实值含平值 · 虚值=价外" + ageHint;
|
||||
" · 默认最近一期 · " + liqHint + " · 实值含平值 · 虚值=价外" + liveHint;
|
||||
}
|
||||
}
|
||||
|
||||
function findChainContract(instId) {
|
||||
if (!state.chain || !instId) return null;
|
||||
const exps = state.chain.expiries || [];
|
||||
for (let i = 0; i < exps.length; i++) {
|
||||
const contracts = exps[i].contracts || [];
|
||||
for (let j = 0; j < contracts.length; j++) {
|
||||
if (String(contracts[j].inst_id) === String(instId)) return contracts[j];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function currentExpiryContracts() {
|
||||
if (!state.chain) return [];
|
||||
const expMs = (document.getElementById("opt-exp-select") || {}).value;
|
||||
const exp = (state.chain.expiries || []).find(function (e) {
|
||||
return String(e.exp_time) === String(expMs);
|
||||
});
|
||||
return (exp && exp.contracts) || [];
|
||||
}
|
||||
|
||||
async function watchCurrentExpiryQuotes() {
|
||||
if (!state.chain) return;
|
||||
const expMs = (document.getElementById("opt-exp-select") || {}).value;
|
||||
const contracts = currentExpiryContracts().map(function (c) {
|
||||
return {
|
||||
inst_id: c.inst_id,
|
||||
opt_type: c.opt_type,
|
||||
strike: c.strike,
|
||||
tick_sz: c.tick_sz,
|
||||
};
|
||||
});
|
||||
if (!contracts.length) return;
|
||||
try {
|
||||
await apiJson("/api/options/quotes/watch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
underlying: state.underlying,
|
||||
exp_time: expMs,
|
||||
contracts: contracts,
|
||||
index_inst_id: state.underlying + "-USD",
|
||||
watcher_id: panelCache.quoteWatcherId,
|
||||
}),
|
||||
});
|
||||
} catch (_) {
|
||||
/* ignore watch errors; soft poll fallback remains */
|
||||
}
|
||||
}
|
||||
|
||||
function patchListRowDom(instId, c) {
|
||||
const tr = document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-inst="' + instId + '"]');
|
||||
if (!tr || !c) return;
|
||||
const indexPx = state.chain && state.chain.index_px;
|
||||
const tds = tr.children;
|
||||
if (tds.length < 8) return;
|
||||
tds[3].textContent = "";
|
||||
tds[3].className = "opt-px-sz";
|
||||
tds[3].innerHTML = fmtPxSz(c.ask, c.ask_sz, c.ask_estimated);
|
||||
tds[4].className = "opt-chain-lev";
|
||||
tds[4].textContent = fmtChainLeverage(calcAskLeverage(indexPx, c.ask));
|
||||
tds[5].className = "opt-px-sz";
|
||||
tds[5].innerHTML = fmtPxSz(c.bid, c.bid_sz);
|
||||
tds[6].textContent = c.expiry_be_px != null ? fmt(c.expiry_be_px, 0) : "—";
|
||||
tds[7].className = distBeClass(c.dist_expiry_be);
|
||||
tds[7].textContent = fmtDist(c.dist_expiry_be);
|
||||
}
|
||||
|
||||
function patchTRowDom(instId, c) {
|
||||
if (!c) return;
|
||||
const callTr = document.querySelector(
|
||||
'#opt-strike-tbody tr.opt-strike-row-t[data-call-inst="' + instId + '"]'
|
||||
);
|
||||
const putTr = document.querySelector(
|
||||
'#opt-strike-tbody tr.opt-strike-row-t[data-put-inst="' + instId + '"]'
|
||||
);
|
||||
const tr = callTr || putTr;
|
||||
if (!tr) return;
|
||||
const callInst = tr.getAttribute("data-call-inst");
|
||||
const putInst = tr.getAttribute("data-put-inst");
|
||||
const call = callInst ? findChainContract(callInst) : null;
|
||||
const put = putInst ? findChainContract(putInst) : null;
|
||||
const callOk = call && (!askLiqFilterOn() || hasAskLiquidity(call)) ? call : null;
|
||||
const putOk = put && (!askLiqFilterOn() || hasAskLiquidity(put)) ? put : null;
|
||||
const tds = tr.children;
|
||||
if (tds.length < 9) return;
|
||||
tds[0].innerHTML = callOk ? fmtPxSz(callOk.ask, callOk.ask_sz, callOk.ask_estimated) : "—";
|
||||
tds[7].innerHTML = putOk ? fmtPxSz(putOk.ask, putOk.ask_sz, putOk.ask_estimated) : "—";
|
||||
const combined = straddleAskPerUnit(callOk && callOk.ask, putOk && putOk.ask);
|
||||
tds[4].innerHTML = formatStraddlePremiumCell(callOk && callOk.ask, putOk && putOk.ask);
|
||||
tds[5].innerHTML = formatStraddleBand(tr.getAttribute("data-strike"), combined);
|
||||
}
|
||||
|
||||
function applyLiveQuotes(payload) {
|
||||
if (!payload || !state.chain) return;
|
||||
const uly = String(state.underlying || "").toUpperCase();
|
||||
if (payload.indexes && payload.indexes[uly] != null && Number.isFinite(Number(payload.indexes[uly]))) {
|
||||
state.chain.index_px = Number(payload.indexes[uly]);
|
||||
} else if (payload.underlying && String(payload.underlying).toUpperCase() === uly) {
|
||||
if (payload.index_px != null && Number.isFinite(Number(payload.index_px))) {
|
||||
state.chain.index_px = Number(payload.index_px);
|
||||
}
|
||||
} else if (payload.underlying && String(payload.underlying).toUpperCase() !== uly) {
|
||||
// 别的标的推送:仍可 patch 本页已有合约
|
||||
}
|
||||
const quotes = payload.quotes || [];
|
||||
quotes.forEach(function (q) {
|
||||
const instId = q && q.inst_id;
|
||||
if (!instId) return;
|
||||
if (q.underlying && String(q.underlying).toUpperCase() !== uly) return;
|
||||
const c = findChainContract(instId);
|
||||
if (!c) return;
|
||||
if (q.ask !== undefined) c.ask = q.ask;
|
||||
if (q.bid !== undefined) c.bid = q.bid;
|
||||
if (q.ask_sz !== undefined) c.ask_sz = q.ask_sz;
|
||||
if (q.bid_sz !== undefined) c.bid_sz = q.bid_sz;
|
||||
if (q.mark_px !== undefined) c.mark_px = q.mark_px;
|
||||
if (q.ask_estimated !== undefined) c.ask_estimated = !!q.ask_estimated;
|
||||
if (q.expiry_be_px !== undefined) c.expiry_be_px = q.expiry_be_px;
|
||||
if (q.dist_expiry_be !== undefined) c.dist_expiry_be = q.dist_expiry_be;
|
||||
if (state.chainView === "t") patchTRowDom(instId, c);
|
||||
else patchListRowDom(instId, c);
|
||||
});
|
||||
if (payload.ts) chainQuotedAt = Number(payload.ts) || Date.now();
|
||||
else if (quotes.length || payload.index_px != null) chainQuotedAt = Date.now();
|
||||
quoteLiveWsOk = payload.ws_ok !== false;
|
||||
renderIndexLine();
|
||||
if (state.selectedInst && quotes.some(function (q) { return q && q.inst_id === state.selectedInst; })) {
|
||||
const now = Date.now();
|
||||
if (now - lastOrderQuoteLiveAt >= ORDER_QUOTE_LIVE_MIN_MS) {
|
||||
lastOrderQuoteLiveAt = now;
|
||||
void selectContract(state.selectedInst, null, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopQuoteLiveStream() {
|
||||
if (quoteLiveReconnectTimer) {
|
||||
clearTimeout(quoteLiveReconnectTimer);
|
||||
quoteLiveReconnectTimer = null;
|
||||
}
|
||||
if (quoteLiveEs) {
|
||||
try { quoteLiveEs.close(); } catch (_) {}
|
||||
quoteLiveEs = null;
|
||||
}
|
||||
quoteLiveOk = false;
|
||||
quoteLiveWsOk = false;
|
||||
}
|
||||
|
||||
function startQuoteLiveStream() {
|
||||
if (quoteLiveEs) return;
|
||||
if (typeof EventSource === "undefined") return;
|
||||
try {
|
||||
quoteLiveEs = new EventSource("/api/options/quotes/stream");
|
||||
} catch (_) {
|
||||
quoteLiveOk = false;
|
||||
return;
|
||||
}
|
||||
quoteLiveEs.addEventListener("quotes", function (ev) {
|
||||
try {
|
||||
const data = JSON.parse(ev.data || "{}");
|
||||
quoteLiveOk = true;
|
||||
if (data.reason === "connect") {
|
||||
quoteLiveWsOk = !!data.ws_ok;
|
||||
renderIndexLine();
|
||||
return;
|
||||
}
|
||||
applyLiveQuotes(data);
|
||||
} catch (_) {}
|
||||
});
|
||||
quoteLiveEs.onopen = function () {
|
||||
quoteLiveOk = true;
|
||||
renderIndexLine();
|
||||
void watchCurrentExpiryQuotes();
|
||||
};
|
||||
quoteLiveEs.onerror = function () {
|
||||
quoteLiveOk = false;
|
||||
quoteLiveWsOk = false;
|
||||
renderIndexLine();
|
||||
stopQuoteLiveStream();
|
||||
quoteLiveReconnectTimer = setTimeout(function () {
|
||||
quoteLiveReconnectTimer = null;
|
||||
startQuoteLiveStream();
|
||||
}, 8000);
|
||||
};
|
||||
}
|
||||
|
||||
function softRefreshChainThrottled(force) {
|
||||
if (document.hidden) return;
|
||||
if (!document.getElementById("options-root")) return;
|
||||
// WS 推送正常时不靠 REST 刷卖一,避免 50011;仅结构兜底可 force
|
||||
if (!force && quoteLiveOk && quoteLiveWsOk) return;
|
||||
const now = Date.now();
|
||||
if (!force && now - lastChainSoftAt < CHAIN_SOFT_POLL_MS) return;
|
||||
lastChainSoftAt = now;
|
||||
@@ -1328,6 +1536,8 @@
|
||||
}
|
||||
// soft 时保留 selectedInst;renderStrikes 会先 park 再按 prevSelected 静默重挂下单面板
|
||||
renderStrikes();
|
||||
void watchCurrentExpiryQuotes();
|
||||
startQuoteLiveStream();
|
||||
} catch (e) {
|
||||
if (seq !== chainLoadSeq || soft) return;
|
||||
setExpirySelectStatus("选择到期日");
|
||||
@@ -2236,6 +2446,7 @@
|
||||
const expandCb = document.getElementById("opt-strike-expand-all");
|
||||
if (expandCb) expandCb.checked = false;
|
||||
renderStrikes();
|
||||
void watchCurrentExpiryQuotes();
|
||||
}
|
||||
|
||||
function bootOptionsPanel() {
|
||||
@@ -2247,6 +2458,7 @@
|
||||
refreshPendingOrders();
|
||||
startPendingOrdersPoll();
|
||||
startChainSoftPoll();
|
||||
startQuoteLiveStream();
|
||||
const hasCache =
|
||||
chainHasExpiries(panelCache.chain) &&
|
||||
panelCache.underlying === state.underlying &&
|
||||
@@ -2256,7 +2468,8 @@
|
||||
renderExpiries();
|
||||
renderStrikes();
|
||||
refreshAllPositions();
|
||||
// 后台静默刷新,避免缓存过期后到期日变空 / 卖一过期
|
||||
void watchCurrentExpiryQuotes();
|
||||
// 后台静默刷新结构;卖一优先走 WS
|
||||
softRefreshChainThrottled(true);
|
||||
return;
|
||||
}
|
||||
@@ -2381,7 +2594,7 @@
|
||||
window.OptionsPanelLive = {
|
||||
refreshSoft: function () {
|
||||
refreshAllPositions();
|
||||
// embed SSE 只通知「该拉了」,不推送链报价;这里节流拉新鲜卖一/买一
|
||||
// 有 WS 实时报价时不再 REST 刷链;断开时才兜底
|
||||
softRefreshChainThrottled(false);
|
||||
},
|
||||
refreshChain: loadChain,
|
||||
|
||||
Reference in New Issue
Block a user