diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js
index c2f7a69..de480a3 100644
--- a/lib/common/static/options_panel.js
+++ b/lib/common/static/options_panel.js
@@ -1262,10 +1262,14 @@
if (seq !== chainLoadSeq) return;
if (d && d.ok && chainHasExpiries(d)) break;
lastMsg = (d && (d.msg || d.chain_error)) || "暂无到期日";
+ const rateLimited =
+ /50011|Too Many Requests|RateLimit/i.test(String(lastMsg || ""));
d = null;
- if (attempt === 0) {
+ if (attempt === 0 && !rateLimited) {
if (!soft) setExpirySelectStatus("重试加载到期日…");
await new Promise(function (resolve) { setTimeout(resolve, 400); });
+ } else {
+ break;
}
}
if (seq !== chainLoadSeq) return;
diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py
index d477ab3..c709c71 100644
--- a/lib/exchange/okx_options_lib.py
+++ b/lib/exchange/okx_options_lib.py
@@ -26,12 +26,26 @@ _OKX_OPTION_ERR_ZH: dict[str, str] = {
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
+# public/instruments 全族缓存:合约列表变化慢,限频时用旧数据保活
+_OPTION_INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {}
+_OPTION_INSTRUMENTS_CACHE_LOCK = threading.Lock()
+_OPTION_INSTRUMENTS_CACHE_TTL = 90.0
+_OPTION_INSTRUMENTS_STALE_MAX = 600.0
+
def invalidate_options_balance_cache() -> None:
_OPTIONS_BALANCE_CACHE["updated_at"] = 0.0
_OPTIONS_BALANCE_CACHE["data"] = None
+def invalidate_option_instruments_cache(inst_family: str | None = None) -> None:
+ with _OPTION_INSTRUMENTS_CACHE_LOCK:
+ if inst_family:
+ _OPTION_INSTRUMENTS_CACHE.pop(str(inst_family), None)
+ else:
+ _OPTION_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):
@@ -407,25 +421,31 @@ def fetch_option_instrument_meta(ex: ccxt.okx, inst_id: str) -> dict[str, Any] |
family = inst_family_from_inst_id(inst_id)
if not family:
return None
+ # 优先从全族缓存取,避免每选一腿再打 instruments
+ try:
+ cached_rows = fetch_option_instruments(ex, family, allow_stale=True)
+ for r in cached_rows:
+ if isinstance(r, dict) and str(r.get("instId")) == inst_id:
+ return r
+ except Exception:
+ pass
last_err: BaseException | None = None
- for attempt in range(3):
+ for attempt in range(2):
try:
rows = ex.public_get_public_instruments(
{"instType": "OPTION", "instFamily": family, "instId": inst_id}
).get("data") or []
if rows and isinstance(rows[0], dict):
return rows[0]
- rows = ex.public_get_public_instruments(
- {"instType": "OPTION", "instFamily": family}
- ).get("data") or []
+ rows = fetch_option_instruments(ex, family, allow_stale=True)
for r in rows:
if isinstance(r, dict) and str(r.get("instId")) == inst_id:
return r
return None
except Exception as e:
last_err = e
- if _is_okx_rate_limit(e) and attempt < 2:
- time.sleep(0.45 * (attempt + 1))
+ if _is_okx_rate_limit(e) and attempt < 1:
+ time.sleep(1.2)
continue
break
if last_err is not None and _is_okx_rate_limit(last_err):
@@ -645,11 +665,42 @@ def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None:
def fetch_option_instruments(
ex: ccxt.okx,
inst_family: str,
+ *,
+ force: bool = False,
+ allow_stale: bool = True,
) -> 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"]
+ """拉取 OPTION instruments;进程内缓存,50011 时回退旧列表."""
+ family = str(inst_family or "").strip()
+ if not family:
+ return []
+ now = time.time()
+ with _OPTION_INSTRUMENTS_CACHE_LOCK:
+ entry = _OPTION_INSTRUMENTS_CACHE.get(family)
+ if (
+ not force
+ and entry is not None
+ and entry.get("rows") is not None
+ and now - float(entry.get("updated_at") or 0) < _OPTION_INSTRUMENTS_CACHE_TTL
+ ):
+ return list(entry["rows"])
+
+ try:
+ rows = ex.public_get_public_instruments(
+ {"instType": "OPTION", "instFamily": family}
+ ).get("data") or []
+ live = [r for r in rows if isinstance(r, dict) and r.get("state") == "live"]
+ with _OPTION_INSTRUMENTS_CACHE_LOCK:
+ _OPTION_INSTRUMENTS_CACHE[family] = {"updated_at": now, "rows": live}
+ return list(live)
+ except Exception as e:
+ if allow_stale:
+ with _OPTION_INSTRUMENTS_CACHE_LOCK:
+ entry = _OPTION_INSTRUMENTS_CACHE.get(family)
+ if entry is not None and entry.get("rows") is not None:
+ age = now - float(entry.get("updated_at") or 0)
+ if age <= _OPTION_INSTRUMENTS_STALE_MAX:
+ return list(entry["rows"])
+ raise
def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]:
@@ -683,22 +734,26 @@ 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
+ try:
+ instruments = fetch_option_instruments(ex, family)
+ if not instruments:
+ # 空列表可能是瞬时空;短退避后强制再拉一次(非 50011)
+ time.sleep(0.5)
+ instruments = fetch_option_instruments(ex, family, force=True)
+ 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__
+ # 限频:再等一下用 stale/缓存,不要连打
+ if _is_okx_rate_limit(e):
+ time.sleep(1.5)
+ try:
+ instruments = fetch_option_instruments(ex, family, allow_stale=True)
+ if instruments:
+ instruments_err = ""
+ except Exception as e2:
+ instruments_err = str(e2) or e2.__class__.__name__
tickers = fetch_option_tickers(ex, family)
expiries: dict[str, list[dict[str, Any]]] = {}
skipped_no_index = 0
diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html
index 161c831..363a8d8 100644
--- a/lib/options/templates/options_panel.html
+++ b/lib/options/templates/options_panel.html
@@ -324,4 +324,4 @@
-
+