diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 88d8505..677e3da 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -1216,16 +1216,25 @@ try { let d = null; let lastMsg = ""; - for (let attempt = 0; attempt < 2; attempt++) { + for (let attempt = 0; attempt < 3; attempt++) { if (seq !== chainLoadSeq) return; d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(uly)); if (seq !== chainLoadSeq) return; if (d && d.ok && chainHasExpiries(d)) break; lastMsg = (d && (d.msg || d.chain_error)) || "暂无到期日"; + const rateLimited = + !!(d && d.rate_limited) || + /50011|Too Many Requests|过于频繁/i.test(String(lastMsg || "")); d = null; - if (attempt === 0) { - if (!soft) setExpirySelectStatus("重试加载到期日…"); - await new Promise(function (resolve) { setTimeout(resolve, 400); }); + if (attempt < 2) { + if (!soft) { + setExpirySelectStatus( + rateLimited ? "OKX 限频,稍后重试…" : "重试加载到期日…" + ); + } + await new Promise(function (resolve) { + setTimeout(resolve, rateLimited ? 1200 * (attempt + 1) : 400); + }); } } if (seq !== chainLoadSeq) return; @@ -1240,13 +1249,19 @@ if (soft) return; setExpirySelectStatus("选择到期日"); const tbody = document.getElementById("opt-strike-tbody"); + const friendly = + /50011|Too Many Requests|过于频繁/i.test(String(lastMsg || "")) + ? "OKX 请求过于频繁,请稍后再点「刷新链」" + : lastMsg || "暂无到期日,请点「刷新链」"; if (tbody) { tbody.innerHTML = - '' + - (lastMsg || "暂无到期日,请点「刷新链」") + + '' + + friendly + ""; } - alert(lastMsg || "加载到期日失败,请点「刷新链」重试"); + alert(friendly); return; } const keepExp = soft ? (document.getElementById("opt-exp-select") || {}).value : ""; diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index d477ab3..4fb4e15 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -25,6 +25,11 @@ _OKX_OPTION_ERR_ZH: dict[str, str] = { } _OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None} +# 期权合约列表变化慢;短缓存+限频退避,避免 50011 拖垮期权链 +_INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {} +_INSTRUMENTS_CACHE_LOCK = threading.Lock() +_INSTRUMENTS_CACHE_TTL_SEC = 90.0 +_INSTRUMENTS_STALE_SEC = 600.0 def invalidate_options_balance_cache() -> None: @@ -32,6 +37,14 @@ def invalidate_options_balance_cache() -> None: _OPTIONS_BALANCE_CACHE["data"] = None +def invalidate_option_instruments_cache(inst_family: str | None = None) -> None: + with _INSTRUMENTS_CACHE_LOCK: + if inst_family: + _INSTRUMENTS_CACHE.pop(str(inst_family), None) + else: + _INSTRUMENTS_CACHE.clear() + + def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str: row: dict[str, Any] | None = None if isinstance(resp, dict): @@ -645,24 +658,80 @@ def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None: def fetch_option_instruments( ex: ccxt.okx, inst_family: str, + *, + force: bool = False, ) -> list[dict[str, Any]]: - rows = ex.public_get_public_instruments( - {"instType": "OPTION", "instFamily": inst_family} - ).get("data") or [] - return [r for r in rows if isinstance(r, dict) and r.get("state") == "live"] + """拉取 live 期权合约列表;短 TTL 缓存,遇 50011 退避重试并可回退过期缓存.""" + family = (inst_family or "").strip() + if not family: + return [] + now = time.time() + with _INSTRUMENTS_CACHE_LOCK: + cached = _INSTRUMENTS_CACHE.get(family) + if ( + not force + and cached + and now - float(cached.get("updated_at") or 0) < _INSTRUMENTS_CACHE_TTL_SEC + and isinstance(cached.get("rows"), list) + and cached["rows"] + ): + return list(cached["rows"]) + + last_err: BaseException | None = None + rows: list[dict[str, Any]] = [] + for attempt in range(4): + try: + raw = ex.public_get_public_instruments( + {"instType": "OPTION", "instFamily": family} + ).get("data") or [] + rows = [r for r in raw if isinstance(r, dict) and r.get("state") == "live"] + last_err = None + break + except Exception as e: + last_err = e + if _is_okx_rate_limit(e) and attempt < 3: + time.sleep(0.8 * (2**attempt)) + continue + break + + if rows: + with _INSTRUMENTS_CACHE_LOCK: + _INSTRUMENTS_CACHE[family] = {"updated_at": time.time(), "rows": list(rows)} + return rows + + # 限频/短暂失败:优先用未过期太久的缓存,避免整页「拉取失败」 + if cached and isinstance(cached.get("rows"), list) and cached["rows"]: + age = now - float(cached.get("updated_at") or 0) + if age < _INSTRUMENTS_STALE_SEC and ( + last_err is None or _is_okx_rate_limit(last_err) or not rows + ): + return list(cached["rows"]) + + if last_err is not None: + raise last_err + return [] def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} - try: - rows = ex.public_get_market_tickers( - {"instType": "OPTION", "instFamily": inst_family} - ).get("data") or [] - for r in rows: - if isinstance(r, dict) and r.get("instId"): - out[str(r["instId"])] = r - except Exception: - pass + last_err: BaseException | None = None + for attempt in range(3): + try: + rows = ex.public_get_market_tickers( + {"instType": "OPTION", "instFamily": inst_family} + ).get("data") or [] + for r in rows: + if isinstance(r, dict) and r.get("instId"): + out[str(r["instId"])] = r + return out + except Exception as e: + last_err = e + if _is_okx_rate_limit(e) and attempt < 2: + time.sleep(0.6 * (attempt + 1)) + continue + break + if last_err is not None and _is_okx_rate_limit(last_err): + return out return out @@ -683,22 +752,17 @@ def build_option_chain( max_ms = now_ms + max_dte_days * 86400 * 1000 instruments_err = "" instruments: list[dict[str, Any]] = [] - for attempt in range(2): - try: - instruments = fetch_option_instruments(ex, family) - instruments_err = "" - if instruments: - break + rate_limited = False + try: + instruments = fetch_option_instruments(ex, family) + if not instruments: instruments_err = "期权合约列表为空" - except Exception as e: - instruments = [] - instruments_err = str(e) or e.__class__.__name__ - if attempt == 0: - time.sleep(0.35) - continue - break - if attempt == 0 and not instruments: - time.sleep(0.35) + except Exception as e: + instruments = [] + instruments_err = str(e) or e.__class__.__name__ + rate_limited = _is_okx_rate_limit(e) + if rate_limited: + instruments_err = "OKX 请求过于频繁(50011),请稍后点「刷新链」重试" tickers = fetch_option_tickers(ex, family) expiries: dict[str, list[dict[str, Any]]] = {} skipped_no_index = 0 @@ -777,6 +841,8 @@ def build_option_chain( "expiries": exp_list, "instruments_count": len(instruments), } + if rate_limited: + out["rate_limited"] = True if not exp_list: if instruments_err: out["chain_error"] = f"拉取期权合约失败: {instruments_err}" diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index cb6496a..f345380 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -322,4 +322,4 @@ - + diff --git a/tests/test_okx_option_instruments_cache.py b/tests/test_okx_option_instruments_cache.py new file mode 100644 index 0000000..4bf920f --- /dev/null +++ b/tests/test_okx_option_instruments_cache.py @@ -0,0 +1,55 @@ +"""期权合约列表缓存与限频退避.""" +from __future__ import annotations + +import time +import unittest +from unittest.mock import MagicMock, patch + +from lib.exchange import okx_options_lib as m + + +class FetchOptionInstrumentsCacheTests(unittest.TestCase): + def setUp(self): + m.invalidate_option_instruments_cache() + + def tearDown(self): + m.invalidate_option_instruments_cache() + + def test_cache_hit_skips_second_api_call(self): + ex = MagicMock() + ex.public_get_public_instruments.return_value = { + "data": [ + { + "instId": "ETH-USD_UM-260812-2000-C", + "state": "live", + "expTime": "9999999999999", + } + ] + } + a = m.fetch_option_instruments(ex, "ETH-USD_UM") + b = m.fetch_option_instruments(ex, "ETH-USD_UM") + self.assertEqual(len(a), 1) + self.assertEqual(len(b), 1) + self.assertEqual(ex.public_get_public_instruments.call_count, 1) + + @patch("lib.exchange.okx_options_lib.time.sleep", return_value=None) + def test_rate_limit_falls_back_to_stale_cache(self, _sleep): + ex = MagicMock() + ex.public_get_public_instruments.return_value = { + "data": [{"instId": "ETH-USD_UM-260812-2000-C", "state": "live"}] + } + first = m.fetch_option_instruments(ex, "ETH-USD_UM") + self.assertEqual(len(first), 1) + # 过期 TTL,但仍在 stale 窗口 + with m._INSTRUMENTS_CACHE_LOCK: + m._INSTRUMENTS_CACHE["ETH-USD_UM"]["updated_at"] = time.time() - 120 + ex.public_get_public_instruments.side_effect = Exception( + 'okx {"msg":"Too Many Requests","code":"50011"}' + ) + second = m.fetch_option_instruments(ex, "ETH-USD_UM") + self.assertEqual(len(second), 1) + self.assertEqual(second[0]["instId"], "ETH-USD_UM-260812-2000-C") + + +if __name__ == "__main__": + unittest.main()