From 6fad68f7b118938929b57987450fd7b78059ad5d Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 11 Aug 2026 12:06:16 +0800 Subject: [PATCH] fix(options): speed up chain refresh with fast path and non-blocking UI Skip full REST tickers when WS is warm, seed subscriptions off-request, and keep the old chain visible while refreshing. Co-authored-by: Cursor --- lib/common/static/options_panel.js | 44 ++++++++++++++------ lib/exchange/okx_options_lib.py | 43 +++++++++++++++++-- lib/options/options_quote_live_lib.py | 53 +++++++++++++++++++++++- lib/options/options_register.py | 30 +++++++++++++- lib/options/templates/options_panel.html | 2 +- 5 files changed, 153 insertions(+), 19 deletions(-) diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index fffd075..804446e 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -1450,8 +1450,14 @@ const uly = state.underlying; const seq = ++chainLoadSeq; const btn = document.getElementById("opt-load-chain"); - if (btn && !soft) btn.disabled = true; - if (!soft) { + const hadChain = chainHasExpiries(state.chain) && state.chain.underlying === uly; + if (btn && !soft) { + btn.disabled = true; + if (!btn.dataset.origText) btn.dataset.origText = btn.textContent || "刷新链"; + btn.textContent = "刷新中…"; + } + // 已有链时不先清空表格,避免「白屏等很久」的体感 + if (!soft && !hadChain) { setExpirySelectStatus("加载到期日中…"); const tbody = document.getElementById("opt-strike-tbody"); if (tbody) { @@ -1462,9 +1468,18 @@ try { let d = null; let lastMsg = ""; - for (let attempt = 0; attempt < 3; attempt++) { + const expMs = (document.getElementById("opt-exp-select") || {}).value || ""; + // WS 已热时走 fast,跳过最慢的整家族 REST tickers + const useFast = soft || quoteLiveWsOk || hadChain; + const maxAttempts = soft ? 2 : 3; + for (let attempt = 0; attempt < maxAttempts; attempt++) { if (seq !== chainLoadSeq) return; - d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(uly)); + let url = + "/api/options/chain?underlying=" + + encodeURIComponent(uly) + + (useFast ? "&fast=1" : ""); + if (expMs) url += "&exp_time=" + encodeURIComponent(expMs); + d = await apiJson(url); if (seq !== chainLoadSeq) return; if (d && d.ok && chainHasExpiries(d)) break; lastMsg = (d && (d.msg || d.chain_error)) || "暂无到期日"; @@ -1472,14 +1487,14 @@ !!(d && d.rate_limited) || /50011|Too Many Requests|过于频繁/i.test(String(lastMsg || "")); d = null; - if (attempt < 2) { - if (!soft) { + if (attempt < maxAttempts - 1) { + if (!soft && !hadChain) { setExpirySelectStatus( rateLimited ? "OKX 限频,稍后重试…" : "重试加载到期日…" ); } await new Promise(function (resolve) { - setTimeout(resolve, rateLimited ? 1200 * (attempt + 1) : 400); + setTimeout(resolve, rateLimited ? 1200 * (attempt + 1) : 300); }); } } @@ -1489,6 +1504,7 @@ if (!soft) { renderExpiries(); renderStrikes(); + void watchCurrentExpiryQuotes(); } return; } @@ -1510,7 +1526,7 @@ alert(friendly); return; } - const keepExp = soft ? (document.getElementById("opt-exp-select") || {}).value : ""; + const keepExp = soft || hadChain ? (document.getElementById("opt-exp-select") || {}).value : ""; state.chain = d; panelCache.chain = d; panelCache.underlying = uly; @@ -1518,7 +1534,7 @@ chainQuotedAt = Date.now(); lastChainSoftAt = chainQuotedAt; syncAskLiqFilterFromChain(d); - if (!soft) { + if (!soft && !hadChain) { state.selectedInst = null; resetMoneyFilterToAll(); state.strikeExpandAll = false; @@ -1528,18 +1544,19 @@ } updateUnderlyingLabel(); renderExpiries(); - if (soft && keepExp) { + if (keepExp) { const sel = document.getElementById("opt-exp-select"); if (sel && Array.from(sel.options).some(function (o) { return o.value === keepExp; })) { sel.value = keepExp; } } - // soft 时保留 selectedInst;renderStrikes 会先 park 再按 prevSelected 静默重挂下单面板 + // soft/已有链时保留 selectedInst;renderStrikes 会先 park 再按 prevSelected 静默重挂下单面板 renderStrikes(); void watchCurrentExpiryQuotes(); startQuoteLiveStream(); } catch (e) { if (seq !== chainLoadSeq || soft) return; + if (hadChain) return; setExpirySelectStatus("选择到期日"); const tbody = document.getElementById("opt-strike-tbody"); if (tbody) { @@ -1549,7 +1566,10 @@ ""; } } finally { - if (seq === chainLoadSeq && btn) btn.disabled = false; + if (seq === chainLoadSeq && btn) { + btn.disabled = false; + if (btn.dataset.origText) btn.textContent = btn.dataset.origText; + } } } diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 4fb4e15..5093e7b 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -30,6 +30,9 @@ _INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {} _INSTRUMENTS_CACHE_LOCK = threading.Lock() _INSTRUMENTS_CACHE_TTL_SEC = 90.0 _INSTRUMENTS_STALE_SEC = 600.0 +_TICKERS_CACHE: dict[str, dict[str, Any]] = {} +_TICKERS_CACHE_LOCK = threading.Lock() +_TICKERS_CACHE_TTL_SEC = 8.0 def invalidate_options_balance_cache() -> None: @@ -712,17 +715,40 @@ def fetch_option_instruments( return [] -def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]: +def fetch_option_tickers( + ex: ccxt.okx, + inst_family: str, + *, + force: bool = False, +) -> dict[str, dict[str, Any]]: + family = (inst_family or "").strip() + if not family: + return {} + now = time.time() + with _TICKERS_CACHE_LOCK: + cached = _TICKERS_CACHE.get(family) + if ( + not force + and cached + and now - float(cached.get("updated_at") or 0) < _TICKERS_CACHE_TTL_SEC + and isinstance(cached.get("rows"), dict) + and cached["rows"] + ): + return dict(cached["rows"]) + out: dict[str, dict[str, Any]] = {} last_err: BaseException | None = None for attempt in range(3): try: rows = ex.public_get_market_tickers( - {"instType": "OPTION", "instFamily": inst_family} + {"instType": "OPTION", "instFamily": family} ).get("data") or [] for r in rows: if isinstance(r, dict) and r.get("instId"): out[str(r["instId"])] = r + if out: + with _TICKERS_CACHE_LOCK: + _TICKERS_CACHE[family] = {"updated_at": time.time(), "rows": dict(out)} return out except Exception as e: last_err = e @@ -730,6 +756,8 @@ def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, time.sleep(0.6 * (attempt + 1)) continue break + if cached and isinstance(cached.get("rows"), dict) and cached["rows"]: + return dict(cached["rows"]) if last_err is not None and _is_okx_rate_limit(last_err): return out return out @@ -743,6 +771,9 @@ def build_option_chain( itm_only: bool = True, itm_max_dist_usd: float = 30.0, index_px: float | None = None, + tickers_override: dict[str, dict[str, Any]] | None = None, + fetch_tickers: bool = True, + force_tickers: bool = False, ) -> dict[str, Any]: u = (underlying or "ETH").upper() family = f"{u}-USD_UM" @@ -763,7 +794,13 @@ def build_option_chain( rate_limited = _is_okx_rate_limit(e) if rate_limited: instruments_err = "OKX 请求过于频繁(50011),请稍后点「刷新链」重试" - tickers = fetch_option_tickers(ex, family) + tickers: dict[str, dict[str, Any]] = {} + if fetch_tickers: + tickers = fetch_option_tickers(ex, family, force=force_tickers) + if tickers_override: + for iid, row in tickers_override.items(): + if isinstance(row, dict) and iid: + tickers[str(iid)] = {**(tickers.get(str(iid)) or {}), **row} expiries: dict[str, list[dict[str, Any]]] = {} skipped_no_index = 0 for meta in instruments: diff --git a/lib/options/options_quote_live_lib.py b/lib/options/options_quote_live_lib.py index 22a9e66..ba15b91 100644 --- a/lib/options/options_quote_live_lib.py +++ b/lib/options/options_quote_live_lib.py @@ -158,7 +158,58 @@ class OptionsQuoteLive: 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) + # 订阅可能分片 sleep,不能堵 Flask 请求线程 + threading.Thread( + target=self._ws.set_subscriptions, + args=(args,), + name="okx-options-quote-sub", + daemon=True, + ).start() + + def as_okx_tickers(self, underlying: str | None = None) -> dict[str, dict[str, Any]]: + """转成 build_option_chain 可用的 OKX ticker 字段.""" + u = (underlying or "").upper() + out: dict[str, dict[str, Any]] = {} + with self._lock: + for inst_id, q in self._tickers.items(): + if u and str(q.get("underlying") or "").upper() not in ("", u): + continue + row: dict[str, Any] = {"instId": inst_id} + if q.get("ask") is not None and not q.get("ask_estimated"): + row["askPx"] = q.get("ask") + row["askSz"] = q.get("ask_sz") + if q.get("bid") is not None: + row["bidPx"] = q.get("bid") + row["bidSz"] = q.get("bid_sz") + if q.get("mark_px") is not None: + row["markPx"] = q.get("mark_px") + out[inst_id] = row + return out + + def index_px_for(self, underlying: str) -> float | None: + u = (underlying or "").upper() + with self._lock: + return self._index_by_uly.get(u) + + def is_ws_fresh(self, *, max_age_sec: float = 15.0) -> bool: + if not self._ws.connected: + return False + last = float(self._ws.last_msg_at or 0) + return last > 0 and (time.time() - last) <= max_age_sec + + def schedule_seed_from_chain( + self, + chain: dict[str, Any], + *, + exp_time: str | int | None = None, + watcher_id: str | None = None, + ) -> None: + threading.Thread( + target=self.seed_from_chain, + kwargs={"chain": chain, "exp_time": exp_time, "watcher_id": watcher_id}, + name="options-quote-seed", + daemon=True, + ).start() def seed_from_chain( self, diff --git a/lib/options/options_register.py b/lib/options/options_register.py index c57c37c..79d0991 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -374,6 +374,24 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: u = (request.args.get("underlying") or cfg["default_underly"]).upper() # 热更新:链展示天数每次读 env,保存后刷新链即可 chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14)) + fast = (request.args.get("fast") or "").strip().lower() in ("1", "true", "yes") + force_tickers = (request.args.get("force_tickers") or "").strip().lower() in ("1", "true", "yes") + watch_exp = (request.args.get("exp_time") or "").strip() or None + live_index = None + live_tickers = None + ws_fresh = False + try: + from lib.options.options_quote_live_lib import options_quote_live + + ws_fresh = options_quote_live.is_ws_fresh() + live_index = options_quote_live.index_px_for(u) + live_tickers = options_quote_live.as_okx_tickers(u) or None + except Exception: + pass + # fast: WS 已热则跳过整家族 REST tickers(最慢的一步),用 WS 缓存覆盖 + fetch_tickers = True + if fast and ws_fresh and not force_tickers: + fetch_tickers = False try: chain = cfg["build_option_chain"]( ex, @@ -381,6 +399,10 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: max_dte_days=chain_max_dte, itm_only=False, itm_max_dist_usd=cfg["itm_max_dist"], + index_px=live_index, + tickers_override=live_tickers, + fetch_tickers=fetch_tickers, + force_tickers=force_tickers, ) except Exception as e: return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"}) @@ -393,8 +415,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: 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) + options_quote_live.schedule_seed_from_chain(chain, exp_time=watch_exp) except Exception: pass if not expiries: @@ -407,6 +428,8 @@ 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"], + "chain_fast": fast, + "ws_fresh": ws_fresh, } ) return jsonify( @@ -418,6 +441,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: "budget_buffer": budget_buffer, "trade_budget": cfg["trade_budget"], "quote_live": True, + "chain_fast": fast, + "ws_fresh": ws_fresh, + "tickers_fetched": fetch_tickers, } ) diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 84306f7..f2f7b0d 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -322,4 +322,4 @@ - +