diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js
index b8504d3..fffd075 100644
--- a/lib/common/static/options_panel.js
+++ b/lib/common/static/options_panel.js
@@ -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,
diff --git a/lib/exchange/okx_public_ws_lib.py b/lib/exchange/okx_public_ws_lib.py
new file mode 100644
index 0000000..d6b1865
--- /dev/null
+++ b/lib/exchange/okx_public_ws_lib.py
@@ -0,0 +1,199 @@
+"""OKX 公共 WebSocket(同步线程):订阅 tickers / index-tickers,自动重连."""
+from __future__ import annotations
+
+import json
+import logging
+import threading
+import time
+from collections.abc import Callable
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+OKX_PUBLIC_WS_URL = "wss://ws.okx.com:8443/ws/v5/public"
+_SUBSCRIBE_CHUNK = 40
+_APP_PING_SEC = 20.0
+
+
+class OkxPublicWs:
+ """单连接公共 WS;set_subscriptions 全量对齐目标频道."""
+
+ def __init__(
+ self,
+ *,
+ on_data: Callable[[dict[str, Any]], None],
+ url: str = OKX_PUBLIC_WS_URL,
+ name: str = "okx-public-ws",
+ ) -> None:
+ self._on_data = on_data
+ self._url = url
+ self._name = name
+ self._lock = threading.RLock()
+ self._desired: dict[str, dict[str, str]] = {}
+ self._active: set[str] = set()
+ self._stop = threading.Event()
+ self._thread: threading.Thread | None = None
+ self._ws: Any = None
+ self._connected = False
+ self._last_msg_at = 0.0
+
+ @property
+ def connected(self) -> bool:
+ return self._connected
+
+ @property
+ def last_msg_at(self) -> float:
+ return self._last_msg_at
+
+ def start(self) -> None:
+ if self._thread and self._thread.is_alive():
+ return
+ self._stop.clear()
+ self._thread = threading.Thread(target=self._run_loop, name=self._name, daemon=True)
+ self._thread.start()
+
+ def stop(self) -> None:
+ self._stop.set()
+ ws = self._ws
+ if ws is not None:
+ try:
+ ws.close()
+ except Exception:
+ pass
+ if self._thread and self._thread.is_alive():
+ self._thread.join(timeout=3.0)
+
+ def set_subscriptions(self, args: list[dict[str, str]]) -> None:
+ desired: dict[str, dict[str, str]] = {}
+ for raw in args:
+ if not isinstance(raw, dict):
+ continue
+ channel = str(raw.get("channel") or "").strip()
+ inst_id = str(raw.get("instId") or "").strip()
+ if not channel or not inst_id:
+ continue
+ key = f"{channel}:{inst_id}"
+ desired[key] = {"channel": channel, "instId": inst_id}
+ with self._lock:
+ self._desired = desired
+ ws = self._ws
+ connected = self._connected
+ active = set(self._active)
+ if connected and ws is not None:
+ self._sync_subs(ws, active, desired)
+
+ def _sync_subs(
+ self,
+ ws: Any,
+ active: set[str],
+ desired: dict[str, dict[str, str]],
+ ) -> None:
+ unsub_args: list[dict[str, str]] = []
+ for key in active - set(desired.keys()):
+ channel, _, inst_id = key.partition(":")
+ if channel and inst_id:
+ unsub_args.append({"channel": channel, "instId": inst_id})
+ sub_args = [desired[k] for k in (set(desired.keys()) - active)]
+ if unsub_args:
+ self._send_op(ws, "unsubscribe", unsub_args)
+ if sub_args:
+ self._send_op(ws, "subscribe", sub_args)
+ with self._lock:
+ self._active = set(desired.keys())
+
+ def _send_op(self, ws: Any, op: str, args: list[dict[str, str]]) -> None:
+ for i in range(0, len(args), _SUBSCRIBE_CHUNK):
+ chunk = args[i : i + _SUBSCRIBE_CHUNK]
+ try:
+ ws.send(json.dumps({"op": op, "args": chunk}, ensure_ascii=False))
+ except Exception as e:
+ logger.warning("%s %s failed: %s", self._name, op, e)
+ return
+ if i + _SUBSCRIBE_CHUNK < len(args):
+ time.sleep(0.08)
+
+ def _run_loop(self) -> None:
+ try:
+ import websocket
+ except ImportError:
+ logger.error("%s: websocket-client not installed", self._name)
+ return
+ backoff = 1.0
+ while not self._stop.is_set():
+ opened = False
+ try:
+ self._connected = False
+ with self._lock:
+ self._active.clear()
+
+ def on_open(ws: Any) -> None:
+ nonlocal opened
+ opened = True
+ self._connected = True
+ self._last_msg_at = time.time()
+ with self._lock:
+ desired = dict(self._desired)
+ self._sync_subs(ws, set(), desired)
+
+ def on_message(_ws: Any, message: str) -> None:
+ self._last_msg_at = time.time()
+ if message == "pong":
+ return
+ try:
+ payload = json.loads(message)
+ except Exception:
+ return
+ if not isinstance(payload, dict):
+ return
+ if payload.get("event") in ("subscribe", "unsubscribe", "error"):
+ if payload.get("event") == "error":
+ logger.warning("%s event error: %s", self._name, payload)
+ return
+ if payload.get("arg") and payload.get("data") is not None:
+ try:
+ self._on_data(payload)
+ except Exception:
+ logger.exception("%s on_data failed", self._name)
+
+ def on_error(_ws: Any, error: Any) -> None:
+ logger.warning("%s error: %s", self._name, error)
+
+ def on_close(_ws: Any, *_args: Any) -> None:
+ self._connected = False
+
+ self._ws = websocket.WebSocketApp(
+ self._url,
+ on_open=on_open,
+ on_message=on_message,
+ on_error=on_error,
+ on_close=on_close,
+ )
+ ping_stop = threading.Event()
+
+ def ping_loop() -> None:
+ while not self._stop.is_set() and not ping_stop.is_set():
+ ws = self._ws
+ if ws is not None and self._connected:
+ try:
+ ws.send("ping")
+ except Exception:
+ pass
+ if ping_stop.wait(_APP_PING_SEC):
+ break
+
+ ping_thread = threading.Thread(
+ target=ping_loop, name=f"{self._name}-ping", daemon=True
+ )
+ ping_thread.start()
+ self._ws.run_forever(ping_interval=0)
+ ping_stop.set()
+ except Exception as e:
+ logger.warning("%s run failed: %s", self._name, e)
+ finally:
+ self._connected = False
+ self._ws = None
+ if self._stop.is_set():
+ break
+ time.sleep(backoff)
+ backoff = 1.0 if opened else min(30.0, backoff * 1.7)
+
diff --git a/lib/options/options_quote_live_lib.py b/lib/options/options_quote_live_lib.py
new file mode 100644
index 0000000..22a9e66
--- /dev/null
+++ b/lib/options/options_quote_live_lib.py
@@ -0,0 +1,458 @@
+"""期权链实时报价:OKX 公共 WS tickers → 内存缓存 → SSE 推前端."""
+from __future__ import annotations
+
+import json
+import logging
+import os
+import queue
+import threading
+import time
+from collections.abc import Iterator
+from typing import Any, Callable
+
+from lib.exchange.okx_public_ws_lib import OkxPublicWs
+from lib.options.options_pricing_lib import (
+ expiry_breakeven_from_ask,
+ idx_distance_to_be,
+)
+
+logger = logging.getLogger(__name__)
+
+OPTIONS_QUOTE_SSE_HEARTBEAT_SEC = float(os.getenv("OKX_OPTIONS_QUOTE_SSE_HEARTBEAT_SEC", "20"))
+OPTIONS_QUOTE_FLUSH_MS = float(os.getenv("OKX_OPTIONS_QUOTE_FLUSH_MS", "120"))
+# OKX 单连接约 240 频道;当前到期日合约 + 指数通常够用
+OPTIONS_QUOTE_MAX_INST = int(os.getenv("OKX_OPTIONS_QUOTE_MAX_INST", "220"))
+
+
+def _safe_float(v: Any) -> float | None:
+ try:
+ if v is None or v == "":
+ return None
+ return float(v)
+ except (TypeError, ValueError):
+ return None
+
+
+class OptionsQuoteLive:
+ def __init__(self) -> None:
+ self._lock = threading.RLock()
+ self._watchers: dict[str, dict[str, Any]] = {}
+ self._meta: dict[str, dict[str, Any]] = {}
+ self._tickers: dict[str, dict[str, Any]] = {}
+ self._index_by_uly: dict[str, float] = {}
+ self._index_insts: set[str] = set()
+ self._dirty_inst: set[str] = set()
+ self._dirty_index: set[str] = set()
+ self._version = 0
+ self._subscribers: list[queue.Queue[str | None]] = []
+ self._stop = threading.Event()
+ self._flush_thread: threading.Thread | None = None
+ ws_url = (os.getenv("OKX_PUBLIC_WS_URL") or "").strip() or None
+ self._ws = OkxPublicWs(
+ on_data=self._on_ws_data,
+ name="okx-options-quote-ws",
+ **({"url": ws_url} if ws_url else {}),
+ )
+ self._started = False
+
+ def start(self) -> None:
+ if self._started:
+ return
+ self._started = True
+ self._stop.clear()
+ self._ws.start()
+ self._flush_thread = threading.Thread(
+ target=self._flush_loop, name="options-quote-flush", daemon=True
+ )
+ self._flush_thread.start()
+
+ def stop(self) -> None:
+ self._stop.set()
+ self._ws.stop()
+ self._broadcast(close=True)
+ self._started = False
+
+ def status(self) -> dict[str, Any]:
+ with self._lock:
+ uly = ""
+ exp = ""
+ index_inst = ""
+ if self._watchers:
+ last = next(reversed(list(self._watchers.values())))
+ uly = str(last.get("underlying") or "")
+ exp = str(last.get("exp_time") or "")
+ index_inst = str(last.get("index_inst") or "")
+ return {
+ "ok": True,
+ "started": self._started,
+ "ws_ok": self._ws.connected,
+ "underlying": uly,
+ "index_inst": index_inst,
+ "index_px": self._index_by_uly.get(uly),
+ "watch_exp": exp,
+ "watch_count": len(self._meta),
+ "watcher_count": len(self._watchers),
+ "version": self._version,
+ "last_msg_at": self._ws.last_msg_at,
+ }
+
+ def watch(
+ self,
+ *,
+ underlying: str,
+ exp_time: str | int | None,
+ contracts: list[dict[str, Any]],
+ index_inst_id: str | None = None,
+ watcher_id: str | None = None,
+ ) -> dict[str, Any]:
+ u = (underlying or "ETH").upper()
+ index_id = (index_inst_id or f"{u}-USD").strip()
+ wid = (watcher_id or "default").strip() or "default"
+ meta: dict[str, dict[str, Any]] = {}
+ for c in contracts or []:
+ if not isinstance(c, dict):
+ continue
+ inst_id = str(c.get("inst_id") or c.get("instId") or "").strip()
+ if not inst_id:
+ continue
+ meta[inst_id] = {
+ "inst_id": inst_id,
+ "opt_type": str(c.get("opt_type") or c.get("optType") or "").upper(),
+ "strike": _safe_float(c.get("strike")),
+ "tick_sz": c.get("tick_sz") or c.get("tickSz"),
+ "underlying": u,
+ }
+ if len(meta) >= max(1, OPTIONS_QUOTE_MAX_INST):
+ break
+ with self._lock:
+ self._watchers[wid] = {
+ "underlying": u,
+ "exp_time": str(exp_time or ""),
+ "index_inst": index_id,
+ "meta": meta,
+ }
+ self._rebuild_subscriptions_locked()
+ if not self._started:
+ self.start()
+ return self.status()
+
+ def _rebuild_subscriptions_locked(self) -> None:
+ merged: dict[str, dict[str, Any]] = {}
+ index_insts: set[str] = set()
+ for w in self._watchers.values():
+ index_insts.add(str(w.get("index_inst") or ""))
+ for inst_id, m in (w.get("meta") or {}).items():
+ if inst_id not in merged:
+ merged[inst_id] = dict(m)
+ if len(merged) >= max(1, OPTIONS_QUOTE_MAX_INST):
+ break
+ if len(merged) >= max(1, OPTIONS_QUOTE_MAX_INST):
+ break
+ index_insts = {x for x in index_insts if x}
+ self._meta = merged
+ self._index_insts = index_insts
+ keep = set(merged.keys())
+ for k in list(self._tickers.keys()):
+ if k not in keep:
+ self._tickers.pop(k, None)
+ args = [{"channel": "tickers", "instId": iid} for iid in merged]
+ for iid in sorted(index_insts):
+ args.append({"channel": "index-tickers", "instId": iid})
+ self._ws.set_subscriptions(args)
+
+ def seed_from_chain(
+ self,
+ chain: dict[str, Any],
+ *,
+ exp_time: str | int | None = None,
+ watcher_id: str | None = None,
+ ) -> None:
+ """REST 拉链后预填报价,并默认监视指定/最近到期."""
+ if not isinstance(chain, dict):
+ return
+ u = str(chain.get("underlying") or "ETH").upper()
+ index_px = _safe_float(chain.get("index_px"))
+ expiries = chain.get("expiries") or []
+ target = None
+ if exp_time is not None and str(exp_time):
+ for e in expiries:
+ if str(e.get("exp_time")) == str(exp_time):
+ target = e
+ break
+ if target is None and expiries:
+ target = expiries[0]
+ contracts = list((target or {}).get("contracts") or [])
+ if index_px is not None:
+ with self._lock:
+ self._index_by_uly[u] = index_px
+ self._dirty_index.add(u)
+ for c in contracts:
+ inst_id = str(c.get("inst_id") or "").strip()
+ if not inst_id:
+ continue
+ patch = {
+ "inst_id": inst_id,
+ "ask": c.get("ask"),
+ "bid": c.get("bid"),
+ "ask_sz": c.get("ask_sz"),
+ "bid_sz": c.get("bid_sz"),
+ "mark_px": c.get("mark_px"),
+ "ask_estimated": bool(c.get("ask_estimated")),
+ "expiry_be_px": c.get("expiry_be_px"),
+ "dist_expiry_be": c.get("dist_expiry_be"),
+ "underlying": u,
+ }
+ with self._lock:
+ self._tickers[inst_id] = patch
+ self._dirty_inst.add(inst_id)
+ self.watch(
+ underlying=u,
+ exp_time=(target or {}).get("exp_time"),
+ contracts=contracts,
+ index_inst_id=f"{u}-USD",
+ watcher_id=watcher_id or f"seed:{u}",
+ )
+
+ def _on_ws_data(self, payload: dict[str, Any]) -> None:
+ arg = payload.get("arg") or {}
+ channel = str(arg.get("channel") or "")
+ rows = payload.get("data") or []
+ if not isinstance(rows, list) or not rows:
+ return
+ if channel == "index-tickers":
+ row = rows[0] if isinstance(rows[0], dict) else {}
+ px = _safe_float(row.get("idxPx"))
+ inst = str(row.get("instId") or arg.get("instId") or "")
+ uly = inst.split("-")[0].upper() if inst else ""
+ if px is None or not uly:
+ return
+ with self._lock:
+ if self._index_by_uly.get(uly) == px:
+ return
+ self._index_by_uly[uly] = px
+ self._dirty_index.add(uly)
+ return
+ if channel != "tickers":
+ return
+ for row in rows:
+ if not isinstance(row, dict):
+ continue
+ inst_id = str(row.get("instId") or arg.get("instId") or "").strip()
+ if not inst_id:
+ continue
+ patch = self._ticker_to_patch(inst_id, row)
+ with self._lock:
+ prev = self._tickers.get(inst_id) or {}
+ if (
+ prev.get("ask") == patch.get("ask")
+ and prev.get("bid") == patch.get("bid")
+ and prev.get("ask_sz") == patch.get("ask_sz")
+ and prev.get("bid_sz") == patch.get("bid_sz")
+ and prev.get("mark_px") == patch.get("mark_px")
+ ):
+ continue
+ self._tickers[inst_id] = patch
+ self._dirty_inst.add(inst_id)
+
+ def _ticker_to_patch(self, inst_id: str, row: dict[str, Any]) -> dict[str, Any]:
+ ask = _safe_float(row.get("askPx"))
+ bid = _safe_float(row.get("bidPx"))
+ ask_sz = _safe_float(row.get("askSz"))
+ bid_sz = _safe_float(row.get("bidSz"))
+ mark = _safe_float(row.get("markPx"))
+ ask_estimated = False
+ with self._lock:
+ meta = dict(self._meta.get(inst_id) or {})
+ uly = str(meta.get("underlying") or inst_id.split("-")[0] or "").upper()
+ index_px = self._index_by_uly.get(uly)
+ if ask is None and mark is not None and mark > 0:
+ ask = mark
+ ask_estimated = True
+ ask_sz = None
+ if bid is None and mark is not None and mark > 0:
+ bid = mark
+ be = expiry_breakeven_from_ask(
+ opt_type=str(meta.get("opt_type") or ""),
+ strike=meta.get("strike"),
+ ask_px=None if ask_estimated else ask,
+ mark_px=mark,
+ )
+ dist = idx_distance_to_be(index_px, be)
+ return {
+ "inst_id": inst_id,
+ "underlying": uly,
+ "ask": ask,
+ "bid": bid,
+ "ask_sz": ask_sz,
+ "bid_sz": bid_sz,
+ "mark_px": mark,
+ "ask_estimated": ask_estimated,
+ "expiry_be_px": be,
+ "dist_expiry_be": dist,
+ }
+
+ def _flush_loop(self) -> None:
+ interval = max(0.05, OPTIONS_QUOTE_FLUSH_MS / 1000.0)
+ while not self._stop.is_set():
+ if self._stop.wait(interval):
+ break
+ event = self._build_flush_event()
+ if event is None:
+ continue
+ self._broadcast(event)
+
+ def _build_flush_event(self) -> str | None:
+ with self._lock:
+ if not self._dirty_inst and not self._dirty_index:
+ return None
+ dirty_uly = set(self._dirty_index)
+ self._dirty_index.clear()
+ quotes: list[dict[str, Any]] = []
+ for inst_id in list(self._dirty_inst):
+ q = self._tickers.get(inst_id)
+ if q:
+ quotes.append(dict(q))
+ self._dirty_inst.clear()
+ for uly in dirty_uly:
+ index_px = self._index_by_uly.get(uly)
+ if index_px is None:
+ continue
+ for inst_id, q in list(self._tickers.items()):
+ if str(q.get("underlying") or "").upper() != uly:
+ continue
+ be = q.get("expiry_be_px")
+ dist = idx_distance_to_be(index_px, be if be is not None else None)
+ if q.get("dist_expiry_be") != dist:
+ q2 = dict(q)
+ q2["dist_expiry_be"] = dist
+ self._tickers[inst_id] = q2
+ quotes.append(q2)
+ self._version += 1
+ # 多标的时 index_px 取「最近一次 watch」的标的,前端仍以 payload.underlying 过滤
+ uly = ""
+ exp = ""
+ if self._watchers:
+ last = next(reversed(list(self._watchers.values())))
+ uly = str(last.get("underlying") or "")
+ exp = str(last.get("exp_time") or "")
+ # 若本批只有单一 underlying 的 quotes/index,优先用它
+ quote_ulys = {str(q.get("underlying") or "").upper() for q in quotes if q.get("underlying")}
+ if len(dirty_uly) == 1:
+ uly = next(iter(dirty_uly))
+ elif len(quote_ulys) == 1:
+ uly = next(iter(quote_ulys))
+ payload = {
+ "ok": True,
+ "live": True,
+ "ws_ok": self._ws.connected,
+ "version": self._version,
+ "underlying": uly,
+ "watch_exp": exp,
+ "index_px": self._index_by_uly.get(uly),
+ "indexes": dict(self._index_by_uly),
+ "quotes": quotes,
+ "ts": int(time.time() * 1000),
+ }
+ return json.dumps(payload, ensure_ascii=False)
+
+ def _broadcast(self, event: str | None = None, *, close: bool = False) -> None:
+ with self._lock:
+ subs = list(self._subscribers)
+ dead: list[queue.Queue[str | None]] = []
+ for q in subs:
+ try:
+ q.put_nowait(None if close else event)
+ except Exception:
+ dead.append(q)
+ if dead:
+ with self._lock:
+ for q in dead:
+ if q in self._subscribers:
+ self._subscribers.remove(q)
+
+ def _subscribe(self) -> queue.Queue[str | None]:
+ q: queue.Queue[str | None] = queue.Queue(maxsize=64)
+ with self._lock:
+ self._subscribers.append(q)
+ return q
+
+ def _unsubscribe(self, q: queue.Queue[str | None]) -> None:
+ with self._lock:
+ if q in self._subscribers:
+ self._subscribers.remove(q)
+
+ def iter_sse(self) -> Iterator[str]:
+ q = self._subscribe()
+ try:
+ yield self._format_event(
+ {
+ "ok": True,
+ "reason": "connect",
+ **self.status(),
+ "quotes": [],
+ "ts": int(time.time() * 1000),
+ }
+ )
+ while True:
+ try:
+ raw = q.get(timeout=OPTIONS_QUOTE_SSE_HEARTBEAT_SEC)
+ except queue.Empty:
+ yield ": heartbeat\n\n"
+ continue
+ if raw is None:
+ break
+ yield f"event: quotes\ndata: {raw}\n\n"
+ finally:
+ self._unsubscribe(q)
+
+ @staticmethod
+ def _format_event(data: dict[str, Any]) -> str:
+ return "event: quotes\ndata: " + json.dumps(data, ensure_ascii=False) + "\n\n"
+
+
+options_quote_live = OptionsQuoteLive()
+
+
+def start_options_quote_live() -> OptionsQuoteLive:
+ options_quote_live.start()
+ return options_quote_live
+
+
+def register_options_quote_live_routes(app: Any, login_required: Callable) -> None:
+ from flask import Response, jsonify, request, stream_with_context
+
+ start_options_quote_live()
+
+ @app.route("/api/options/quotes/stream")
+ @login_required
+ def api_options_quotes_stream():
+ return Response(
+ stream_with_context(options_quote_live.iter_sse()),
+ mimetype="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ )
+
+ @app.route("/api/options/quotes/watch", methods=["POST"])
+ @login_required
+ def api_options_quotes_watch():
+ data = request.get_json(silent=True) or {}
+ contracts = data.get("contracts") or []
+ if not contracts and data.get("inst_ids"):
+ contracts = [{"inst_id": x} for x in (data.get("inst_ids") or [])]
+ st = options_quote_live.watch(
+ underlying=str(data.get("underlying") or "ETH"),
+ exp_time=data.get("exp_time"),
+ contracts=contracts,
+ index_inst_id=data.get("index_inst_id"),
+ watcher_id=str(data.get("watcher_id") or "default"),
+ )
+ return jsonify({"ok": True, **st})
+
+ @app.route("/api/options/quotes/status")
+ @login_required
+ def api_options_quotes_status():
+ return jsonify(options_quote_live.status())
diff --git a/lib/options/options_register.py b/lib/options/options_register.py
index 97865e0..c57c37c 100644
--- a/lib/options/options_register.py
+++ b/lib/options/options_register.py
@@ -61,6 +61,14 @@ def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None
register_options_routes(app, cfg)
_register_options_hub_bridge(app, cfg)
if enabled:
+ try:
+ from lib.options.options_quote_live_lib import register_options_quote_live_routes
+
+ register_options_quote_live_routes(app, cfg["login_required"])
+ except Exception as e:
+ import logging
+
+ logging.getLogger(__name__).exception("options quote live init failed: %s", e)
_start_monitor_thread(app, cfg)
@@ -381,6 +389,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
# 热更新:每次读 env,保存配置后刷新链即可生效
ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True)
budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
+ if expiries:
+ try:
+ from lib.options.options_quote_live_lib import options_quote_live
+
+ watch_exp = (request.args.get("exp_time") or "").strip() or None
+ options_quote_live.seed_from_chain(chain, exp_time=watch_exp)
+ except Exception:
+ pass
if not expiries:
return jsonify(
{
@@ -401,6 +417,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"ask_liq_filter_enabled": ask_liq_filter,
"budget_buffer": budget_buffer,
"trade_budget": cfg["trade_budget"],
+ "quote_live": True,
}
)
diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html
index 7ba1c7b..84306f7 100644
--- a/lib/options/templates/options_panel.html
+++ b/lib/options/templates/options_panel.html
@@ -322,4 +322,4 @@
-
+
diff --git a/requirements.txt b/requirements.txt
index c88f68a..6f01d27 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,6 +4,7 @@
flask>=3.0,<4
requests>=2.31,<3
ccxt>=4.2,<5
+websocket-client>=1.6,<2
werkzeug>=3.0,<4
PySocks>=1.7,<2
Pillow>=10.0,<12
diff --git a/tests/test_options_quote_live_lib.py b/tests/test_options_quote_live_lib.py
new file mode 100644
index 0000000..007164b
--- /dev/null
+++ b/tests/test_options_quote_live_lib.py
@@ -0,0 +1,64 @@
+"""options_quote_live_lib 单元测试."""
+from __future__ import annotations
+
+import json
+
+from lib.options.options_quote_live_lib import OptionsQuoteLive
+
+
+class _FakeWs:
+ connected = True
+ last_msg_at = 0.0
+
+ def start(self) -> None:
+ return None
+
+ def stop(self) -> None:
+ return None
+
+ def set_subscriptions(self, args) -> None:
+ self.last_args = list(args)
+
+
+def test_ticker_patch_and_flush():
+ live = OptionsQuoteLive()
+ live._ws = _FakeWs() # type: ignore[assignment]
+ live._started = True
+ live.watch(
+ underlying="ETH",
+ exp_time="1",
+ contracts=[{"inst_id": "ETH-USD-260811-2500-C", "opt_type": "C", "strike": 2500}],
+ index_inst_id="ETH-USD",
+ )
+ live._on_ws_data(
+ {
+ "arg": {"channel": "tickers", "instId": "ETH-USD-260811-2500-C"},
+ "data": [
+ {
+ "instId": "ETH-USD-260811-2500-C",
+ "askPx": "12.5",
+ "askSz": "3",
+ "bidPx": "11.0",
+ "bidSz": "2",
+ "markPx": "12.0",
+ }
+ ],
+ }
+ )
+ live._on_ws_data(
+ {
+ "arg": {"channel": "index-tickers", "instId": "ETH-USD"},
+ "data": [{"idxPx": "2600"}],
+ }
+ )
+ raw = live._build_flush_event()
+ assert raw is not None
+ payload = json.loads(raw)
+ assert payload["index_px"] == 2600.0
+ assert payload["quotes"]
+ q = next(x for x in payload["quotes"] if x["inst_id"] == "ETH-USD-260811-2500-C")
+ assert q["ask"] == 12.5
+ assert q["ask_sz"] == 3.0
+ assert q["expiry_be_px"] == 2512.5
+ st = live.status()
+ assert st["watch_count"] == 1