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()