fix(options): cache instruments and backoff on OKX 50011
期权链拉取遇限频时退避重试并回退短缓存,前端提示更友好。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1216,16 +1216,25 @@
|
|||||||
try {
|
try {
|
||||||
let d = null;
|
let d = null;
|
||||||
let lastMsg = "";
|
let lastMsg = "";
|
||||||
for (let attempt = 0; attempt < 2; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
if (seq !== chainLoadSeq) return;
|
if (seq !== chainLoadSeq) return;
|
||||||
d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(uly));
|
d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(uly));
|
||||||
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)) || "暂无到期日";
|
||||||
|
const rateLimited =
|
||||||
|
!!(d && d.rate_limited) ||
|
||||||
|
/50011|Too Many Requests|过于频繁/i.test(String(lastMsg || ""));
|
||||||
d = null;
|
d = null;
|
||||||
if (attempt === 0) {
|
if (attempt < 2) {
|
||||||
if (!soft) setExpirySelectStatus("重试加载到期日…");
|
if (!soft) {
|
||||||
await new Promise(function (resolve) { setTimeout(resolve, 400); });
|
setExpirySelectStatus(
|
||||||
|
rateLimited ? "OKX 限频,稍后重试…" : "重试加载到期日…"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await new Promise(function (resolve) {
|
||||||
|
setTimeout(resolve, rateLimited ? 1200 * (attempt + 1) : 400);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (seq !== chainLoadSeq) return;
|
if (seq !== chainLoadSeq) return;
|
||||||
@@ -1240,13 +1249,19 @@
|
|||||||
if (soft) return;
|
if (soft) return;
|
||||||
setExpirySelectStatus("选择到期日");
|
setExpirySelectStatus("选择到期日");
|
||||||
const tbody = document.getElementById("opt-strike-tbody");
|
const tbody = document.getElementById("opt-strike-tbody");
|
||||||
|
const friendly =
|
||||||
|
/50011|Too Many Requests|过于频繁/i.test(String(lastMsg || ""))
|
||||||
|
? "OKX 请求过于频繁,请稍后再点「刷新链」"
|
||||||
|
: lastMsg || "暂无到期日,请点「刷新链」";
|
||||||
if (tbody) {
|
if (tbody) {
|
||||||
tbody.innerHTML =
|
tbody.innerHTML =
|
||||||
'<tr><td colspan="' + strikeTableColspan() + '" class="muted">' +
|
'<tr><td colspan="' +
|
||||||
(lastMsg || "暂无到期日,请点「刷新链」") +
|
strikeTableColspan() +
|
||||||
|
'" class="muted">' +
|
||||||
|
friendly +
|
||||||
"</td></tr>";
|
"</td></tr>";
|
||||||
}
|
}
|
||||||
alert(lastMsg || "加载到期日失败,请点「刷新链」重试");
|
alert(friendly);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const keepExp = soft ? (document.getElementById("opt-exp-select") || {}).value : "";
|
const keepExp = soft ? (document.getElementById("opt-exp-select") || {}).value : "";
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ _OKX_OPTION_ERR_ZH: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
|
_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:
|
def invalidate_options_balance_cache() -> None:
|
||||||
@@ -32,6 +37,14 @@ def invalidate_options_balance_cache() -> None:
|
|||||||
_OPTIONS_BALANCE_CACHE["data"] = 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:
|
def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str:
|
||||||
row: dict[str, Any] | None = None
|
row: dict[str, Any] | None = None
|
||||||
if isinstance(resp, dict):
|
if isinstance(resp, dict):
|
||||||
@@ -645,24 +658,80 @@ def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None:
|
|||||||
def fetch_option_instruments(
|
def fetch_option_instruments(
|
||||||
ex: ccxt.okx,
|
ex: ccxt.okx,
|
||||||
inst_family: str,
|
inst_family: str,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
rows = ex.public_get_public_instruments(
|
"""拉取 live 期权合约列表;短 TTL 缓存,遇 50011 退避重试并可回退过期缓存."""
|
||||||
{"instType": "OPTION", "instFamily": inst_family}
|
family = (inst_family or "").strip()
|
||||||
).get("data") or []
|
if not family:
|
||||||
return [r for r in rows if isinstance(r, dict) and r.get("state") == "live"]
|
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]]:
|
def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]:
|
||||||
out: dict[str, dict[str, Any]] = {}
|
out: dict[str, dict[str, Any]] = {}
|
||||||
try:
|
last_err: BaseException | None = None
|
||||||
rows = ex.public_get_market_tickers(
|
for attempt in range(3):
|
||||||
{"instType": "OPTION", "instFamily": inst_family}
|
try:
|
||||||
).get("data") or []
|
rows = ex.public_get_market_tickers(
|
||||||
for r in rows:
|
{"instType": "OPTION", "instFamily": inst_family}
|
||||||
if isinstance(r, dict) and r.get("instId"):
|
).get("data") or []
|
||||||
out[str(r["instId"])] = r
|
for r in rows:
|
||||||
except Exception:
|
if isinstance(r, dict) and r.get("instId"):
|
||||||
pass
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -683,22 +752,17 @@ def build_option_chain(
|
|||||||
max_ms = now_ms + max_dte_days * 86400 * 1000
|
max_ms = now_ms + max_dte_days * 86400 * 1000
|
||||||
instruments_err = ""
|
instruments_err = ""
|
||||||
instruments: list[dict[str, Any]] = []
|
instruments: list[dict[str, Any]] = []
|
||||||
for attempt in range(2):
|
rate_limited = False
|
||||||
try:
|
try:
|
||||||
instruments = fetch_option_instruments(ex, family)
|
instruments = fetch_option_instruments(ex, family)
|
||||||
instruments_err = ""
|
if not instruments:
|
||||||
if instruments:
|
|
||||||
break
|
|
||||||
instruments_err = "期权合约列表为空"
|
instruments_err = "期权合约列表为空"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
instruments = []
|
instruments = []
|
||||||
instruments_err = str(e) or e.__class__.__name__
|
instruments_err = str(e) or e.__class__.__name__
|
||||||
if attempt == 0:
|
rate_limited = _is_okx_rate_limit(e)
|
||||||
time.sleep(0.35)
|
if rate_limited:
|
||||||
continue
|
instruments_err = "OKX 请求过于频繁(50011),请稍后点「刷新链」重试"
|
||||||
break
|
|
||||||
if attempt == 0 and not instruments:
|
|
||||||
time.sleep(0.35)
|
|
||||||
tickers = fetch_option_tickers(ex, family)
|
tickers = fetch_option_tickers(ex, family)
|
||||||
expiries: dict[str, list[dict[str, Any]]] = {}
|
expiries: dict[str, list[dict[str, Any]]] = {}
|
||||||
skipped_no_index = 0
|
skipped_no_index = 0
|
||||||
@@ -777,6 +841,8 @@ def build_option_chain(
|
|||||||
"expiries": exp_list,
|
"expiries": exp_list,
|
||||||
"instruments_count": len(instruments),
|
"instruments_count": len(instruments),
|
||||||
}
|
}
|
||||||
|
if rate_limited:
|
||||||
|
out["rate_limited"] = True
|
||||||
if not exp_list:
|
if not exp_list:
|
||||||
if instruments_err:
|
if instruments_err:
|
||||||
out["chain_error"] = f"拉取期权合约失败: {instruments_err}"
|
out["chain_error"] = f"拉取期权合约失败: {instruments_err}"
|
||||||
|
|||||||
@@ -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=55"></script>
|
<script src="/static/options_panel.js?v=56"></script>
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user