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 <cursoragent@cursor.com>
This commit is contained in:
@@ -1450,8 +1450,14 @@
|
|||||||
const uly = state.underlying;
|
const uly = state.underlying;
|
||||||
const seq = ++chainLoadSeq;
|
const seq = ++chainLoadSeq;
|
||||||
const btn = document.getElementById("opt-load-chain");
|
const btn = document.getElementById("opt-load-chain");
|
||||||
if (btn && !soft) btn.disabled = true;
|
const hadChain = chainHasExpiries(state.chain) && state.chain.underlying === uly;
|
||||||
if (!soft) {
|
if (btn && !soft) {
|
||||||
|
btn.disabled = true;
|
||||||
|
if (!btn.dataset.origText) btn.dataset.origText = btn.textContent || "刷新链";
|
||||||
|
btn.textContent = "刷新中…";
|
||||||
|
}
|
||||||
|
// 已有链时不先清空表格,避免「白屏等很久」的体感
|
||||||
|
if (!soft && !hadChain) {
|
||||||
setExpirySelectStatus("加载到期日中…");
|
setExpirySelectStatus("加载到期日中…");
|
||||||
const tbody = document.getElementById("opt-strike-tbody");
|
const tbody = document.getElementById("opt-strike-tbody");
|
||||||
if (tbody) {
|
if (tbody) {
|
||||||
@@ -1462,9 +1468,18 @@
|
|||||||
try {
|
try {
|
||||||
let d = null;
|
let d = null;
|
||||||
let lastMsg = "";
|
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;
|
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 (seq !== chainLoadSeq) return;
|
||||||
if (d && d.ok && chainHasExpiries(d)) break;
|
if (d && d.ok && chainHasExpiries(d)) break;
|
||||||
lastMsg = (d && (d.msg || d.chain_error)) || "暂无到期日";
|
lastMsg = (d && (d.msg || d.chain_error)) || "暂无到期日";
|
||||||
@@ -1472,14 +1487,14 @@
|
|||||||
!!(d && d.rate_limited) ||
|
!!(d && d.rate_limited) ||
|
||||||
/50011|Too Many Requests|过于频繁/i.test(String(lastMsg || ""));
|
/50011|Too Many Requests|过于频繁/i.test(String(lastMsg || ""));
|
||||||
d = null;
|
d = null;
|
||||||
if (attempt < 2) {
|
if (attempt < maxAttempts - 1) {
|
||||||
if (!soft) {
|
if (!soft && !hadChain) {
|
||||||
setExpirySelectStatus(
|
setExpirySelectStatus(
|
||||||
rateLimited ? "OKX 限频,稍后重试…" : "重试加载到期日…"
|
rateLimited ? "OKX 限频,稍后重试…" : "重试加载到期日…"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await new Promise(function (resolve) {
|
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) {
|
if (!soft) {
|
||||||
renderExpiries();
|
renderExpiries();
|
||||||
renderStrikes();
|
renderStrikes();
|
||||||
|
void watchCurrentExpiryQuotes();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1510,7 +1526,7 @@
|
|||||||
alert(friendly);
|
alert(friendly);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const keepExp = soft ? (document.getElementById("opt-exp-select") || {}).value : "";
|
const keepExp = soft || hadChain ? (document.getElementById("opt-exp-select") || {}).value : "";
|
||||||
state.chain = d;
|
state.chain = d;
|
||||||
panelCache.chain = d;
|
panelCache.chain = d;
|
||||||
panelCache.underlying = uly;
|
panelCache.underlying = uly;
|
||||||
@@ -1518,7 +1534,7 @@
|
|||||||
chainQuotedAt = Date.now();
|
chainQuotedAt = Date.now();
|
||||||
lastChainSoftAt = chainQuotedAt;
|
lastChainSoftAt = chainQuotedAt;
|
||||||
syncAskLiqFilterFromChain(d);
|
syncAskLiqFilterFromChain(d);
|
||||||
if (!soft) {
|
if (!soft && !hadChain) {
|
||||||
state.selectedInst = null;
|
state.selectedInst = null;
|
||||||
resetMoneyFilterToAll();
|
resetMoneyFilterToAll();
|
||||||
state.strikeExpandAll = false;
|
state.strikeExpandAll = false;
|
||||||
@@ -1528,18 +1544,19 @@
|
|||||||
}
|
}
|
||||||
updateUnderlyingLabel();
|
updateUnderlyingLabel();
|
||||||
renderExpiries();
|
renderExpiries();
|
||||||
if (soft && keepExp) {
|
if (keepExp) {
|
||||||
const sel = document.getElementById("opt-exp-select");
|
const sel = document.getElementById("opt-exp-select");
|
||||||
if (sel && Array.from(sel.options).some(function (o) { return o.value === keepExp; })) {
|
if (sel && Array.from(sel.options).some(function (o) { return o.value === keepExp; })) {
|
||||||
sel.value = keepExp;
|
sel.value = keepExp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// soft 时保留 selectedInst;renderStrikes 会先 park 再按 prevSelected 静默重挂下单面板
|
// soft/已有链时保留 selectedInst;renderStrikes 会先 park 再按 prevSelected 静默重挂下单面板
|
||||||
renderStrikes();
|
renderStrikes();
|
||||||
void watchCurrentExpiryQuotes();
|
void watchCurrentExpiryQuotes();
|
||||||
startQuoteLiveStream();
|
startQuoteLiveStream();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (seq !== chainLoadSeq || soft) return;
|
if (seq !== chainLoadSeq || soft) return;
|
||||||
|
if (hadChain) return;
|
||||||
setExpirySelectStatus("选择到期日");
|
setExpirySelectStatus("选择到期日");
|
||||||
const tbody = document.getElementById("opt-strike-tbody");
|
const tbody = document.getElementById("opt-strike-tbody");
|
||||||
if (tbody) {
|
if (tbody) {
|
||||||
@@ -1549,7 +1566,10 @@
|
|||||||
"</td></tr>";
|
"</td></tr>";
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === chainLoadSeq && btn) btn.disabled = false;
|
if (seq === chainLoadSeq && btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
if (btn.dataset.origText) btn.textContent = btn.dataset.origText;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ _INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {}
|
|||||||
_INSTRUMENTS_CACHE_LOCK = threading.Lock()
|
_INSTRUMENTS_CACHE_LOCK = threading.Lock()
|
||||||
_INSTRUMENTS_CACHE_TTL_SEC = 90.0
|
_INSTRUMENTS_CACHE_TTL_SEC = 90.0
|
||||||
_INSTRUMENTS_STALE_SEC = 600.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:
|
def invalidate_options_balance_cache() -> None:
|
||||||
@@ -712,17 +715,40 @@ def fetch_option_instruments(
|
|||||||
return []
|
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]] = {}
|
out: dict[str, dict[str, Any]] = {}
|
||||||
last_err: BaseException | None = None
|
last_err: BaseException | None = None
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
try:
|
try:
|
||||||
rows = ex.public_get_market_tickers(
|
rows = ex.public_get_market_tickers(
|
||||||
{"instType": "OPTION", "instFamily": inst_family}
|
{"instType": "OPTION", "instFamily": family}
|
||||||
).get("data") or []
|
).get("data") or []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
if isinstance(r, dict) and r.get("instId"):
|
if isinstance(r, dict) and r.get("instId"):
|
||||||
out[str(r["instId"])] = r
|
out[str(r["instId"])] = r
|
||||||
|
if out:
|
||||||
|
with _TICKERS_CACHE_LOCK:
|
||||||
|
_TICKERS_CACHE[family] = {"updated_at": time.time(), "rows": dict(out)}
|
||||||
return out
|
return out
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_err = 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))
|
time.sleep(0.6 * (attempt + 1))
|
||||||
continue
|
continue
|
||||||
break
|
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):
|
if last_err is not None and _is_okx_rate_limit(last_err):
|
||||||
return out
|
return out
|
||||||
return out
|
return out
|
||||||
@@ -743,6 +771,9 @@ def build_option_chain(
|
|||||||
itm_only: bool = True,
|
itm_only: bool = True,
|
||||||
itm_max_dist_usd: float = 30.0,
|
itm_max_dist_usd: float = 30.0,
|
||||||
index_px: float | None = None,
|
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]:
|
) -> dict[str, Any]:
|
||||||
u = (underlying or "ETH").upper()
|
u = (underlying or "ETH").upper()
|
||||||
family = f"{u}-USD_UM"
|
family = f"{u}-USD_UM"
|
||||||
@@ -763,7 +794,13 @@ def build_option_chain(
|
|||||||
rate_limited = _is_okx_rate_limit(e)
|
rate_limited = _is_okx_rate_limit(e)
|
||||||
if rate_limited:
|
if rate_limited:
|
||||||
instruments_err = "OKX 请求过于频繁(50011),请稍后点「刷新链」重试"
|
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]]] = {}
|
expiries: dict[str, list[dict[str, Any]]] = {}
|
||||||
skipped_no_index = 0
|
skipped_no_index = 0
|
||||||
for meta in instruments:
|
for meta in instruments:
|
||||||
|
|||||||
@@ -158,7 +158,58 @@ class OptionsQuoteLive:
|
|||||||
args = [{"channel": "tickers", "instId": iid} for iid in merged]
|
args = [{"channel": "tickers", "instId": iid} for iid in merged]
|
||||||
for iid in sorted(index_insts):
|
for iid in sorted(index_insts):
|
||||||
args.append({"channel": "index-tickers", "instId": iid})
|
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(
|
def seed_from_chain(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -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()
|
u = (request.args.get("underlying") or cfg["default_underly"]).upper()
|
||||||
# 热更新:链展示天数每次读 env,保存后刷新链即可
|
# 热更新:链展示天数每次读 env,保存后刷新链即可
|
||||||
chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14))
|
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:
|
try:
|
||||||
chain = cfg["build_option_chain"](
|
chain = cfg["build_option_chain"](
|
||||||
ex,
|
ex,
|
||||||
@@ -381,6 +399,10 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
max_dte_days=chain_max_dte,
|
max_dte_days=chain_max_dte,
|
||||||
itm_only=False,
|
itm_only=False,
|
||||||
itm_max_dist_usd=cfg["itm_max_dist"],
|
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:
|
except Exception as e:
|
||||||
return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"})
|
return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"})
|
||||||
@@ -393,8 +415,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
try:
|
try:
|
||||||
from lib.options.options_quote_live_lib import options_quote_live
|
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.schedule_seed_from_chain(chain, exp_time=watch_exp)
|
||||||
options_quote_live.seed_from_chain(chain, exp_time=watch_exp)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if not expiries:
|
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,
|
"ask_liq_filter_enabled": ask_liq_filter,
|
||||||
"budget_buffer": budget_buffer,
|
"budget_buffer": budget_buffer,
|
||||||
"trade_budget": cfg["trade_budget"],
|
"trade_budget": cfg["trade_budget"],
|
||||||
|
"chain_fast": fast,
|
||||||
|
"ws_fresh": ws_fresh,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return jsonify(
|
return jsonify(
|
||||||
@@ -418,6 +441,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
"budget_buffer": budget_buffer,
|
"budget_buffer": budget_buffer,
|
||||||
"trade_budget": cfg["trade_budget"],
|
"trade_budget": cfg["trade_budget"],
|
||||||
"quote_live": True,
|
"quote_live": True,
|
||||||
|
"chain_fast": fast,
|
||||||
|
"ws_fresh": ws_fresh,
|
||||||
|
"tickers_fetched": fetch_tickers,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -322,4 +322,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||||
<script src="/static/options_panel.js?v=58"></script>
|
<script src="/static/options_panel.js?v=59"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user