diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 75422dc..0cbecf0 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -26,6 +26,7 @@ let lastGoodPositions = null; let lastGoodPositionsAt = 0; let positionsRefreshSeq = 0; + let chainLoadSeq = 0; let refreshAllTimer = null; let pendingRefreshTimer = null; let pendingTtlSeconds = 600; @@ -484,8 +485,8 @@ const sel = document.getElementById("opt-exp-select"); if (!sel) return; const prev = preserveSelection !== false ? sel.value : ""; - sel.innerHTML = ''; const exps = (state.chain && state.chain.expiries) || []; + sel.innerHTML = ''; exps.forEach(function (e) { const o = document.createElement("option"); o.value = String(e.exp_time); @@ -497,6 +498,20 @@ } } + function setExpirySelectStatus(text) { + const sel = document.getElementById("opt-exp-select"); + if (!sel) return; + sel.innerHTML = ""; + const o = document.createElement("option"); + o.value = ""; + o.textContent = text || "选择到期日"; + sel.appendChild(o); + } + + function chainHasExpiries(chain) { + return !!(chain && Array.isArray(chain.expiries) && chain.expiries.length > 0); + } + function renderExpiries() { renderExpiryOptions(true); renderIndexLine(); @@ -938,25 +953,93 @@ } } - async function loadChain() { - const d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(state.underlying)); - if (!d.ok) { - alert(d.msg || "加载失败"); - return; + async function loadChain(opts) { + const soft = !!(opts && opts.soft); + const uly = state.underlying; + const seq = ++chainLoadSeq; + const btn = document.getElementById("opt-load-chain"); + if (btn && !soft) btn.disabled = true; + if (!soft) { + setExpirySelectStatus("加载到期日中…"); + const tbody = document.getElementById("opt-strike-tbody"); + if (tbody) { + tbody.innerHTML = + '加载期权链…'; + } + } + try { + let d = null; + let lastMsg = ""; + for (let attempt = 0; attempt < 2; 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)) || "暂无到期日"; + d = null; + if (attempt === 0) { + if (!soft) setExpirySelectStatus("重试加载到期日…"); + await new Promise(function (resolve) { setTimeout(resolve, 400); }); + } + } + if (seq !== chainLoadSeq) return; + if (!d || !d.ok || !chainHasExpiries(d)) { + if (chainHasExpiries(state.chain) && state.chain.underlying === uly) { + if (!soft) { + renderExpiries(); + renderStrikes(); + } + return; + } + if (soft) return; + setExpirySelectStatus("选择到期日"); + const tbody = document.getElementById("opt-strike-tbody"); + if (tbody) { + tbody.innerHTML = + '' + + (lastMsg || "暂无到期日,请点「刷新链」") + + ""; + } + alert(lastMsg || "加载到期日失败,请点「刷新链」重试"); + return; + } + const keepSel = soft && state.selectedInst; + const keepExp = soft ? (document.getElementById("opt-exp-select") || {}).value : ""; + state.chain = d; + panelCache.chain = d; + panelCache.underlying = uly; + panelCache.optType = state.optType; + if (!soft) { + state.selectedInst = null; + resetMoneyFilterToAll(); + state.strikeExpandAll = false; + const expandCb = document.getElementById("opt-strike-expand-all"); + if (expandCb) expandCb.checked = false; + parkOrderPanel(); + } + updateUnderlyingLabel(); + renderExpiries(); + if (soft && keepExp) { + const sel = document.getElementById("opt-exp-select"); + if (sel && Array.from(sel.options).some(function (o) { return o.value === keepExp; })) { + sel.value = keepExp; + } + } + renderStrikes(); + if (soft && keepSel) state.selectedInst = keepSel; + } catch (e) { + if (seq !== chainLoadSeq || soft) return; + setExpirySelectStatus("选择到期日"); + const tbody = document.getElementById("opt-strike-tbody"); + if (tbody) { + tbody.innerHTML = + '加载失败: ' + + String((e && e.message) || e) + + ""; + } + } finally { + if (seq === chainLoadSeq && btn) btn.disabled = false; } - state.chain = d; - panelCache.chain = d; - panelCache.underlying = state.underlying; - panelCache.optType = state.optType; - state.selectedInst = null; - resetMoneyFilterToAll(); - state.strikeExpandAll = false; - const expandCb = document.getElementById("opt-strike-expand-all"); - if (expandCb) expandCb.checked = false; - parkOrderPanel(); - updateUnderlyingLabel(); - renderExpiries(); - renderStrikes(); } async function openPosition() { @@ -1756,7 +1839,7 @@ refreshPendingOrders(); startPendingOrdersPoll(); const hasCache = - panelCache.chain && + chainHasExpiries(panelCache.chain) && panelCache.underlying === state.underlying && panelCache.optType === state.optType; if (hasCache) { @@ -1764,6 +1847,8 @@ renderExpiries(); renderStrikes(); refreshAllPositions(); + // 后台静默刷新,避免缓存过期后到期日变空 + loadChain({ soft: true }); return; } requestAnimationFrame(function () { diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index eab0f4d..fdf053b 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -596,13 +596,10 @@ def fetch_option_instruments( ex: ccxt.okx, inst_family: str, ) -> list[dict[str, Any]]: - try: - 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"] - except Exception: - return [] + 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"] def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]: @@ -634,9 +631,27 @@ def build_option_chain( idx = index_px if index_px is not None else fetch_index_price(ex, uly) now_ms = time.time() * 1000 max_ms = now_ms + max_dte_days * 86400 * 1000 - instruments = fetch_option_instruments(ex, family) + instruments_err = "" + instruments: list[dict[str, Any]] = [] + for attempt in range(2): + try: + instruments = fetch_option_instruments(ex, family) + instruments_err = "" + if instruments: + break + 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) tickers = fetch_option_tickers(ex, family) expiries: dict[str, list[dict[str, Any]]] = {} + skipped_no_index = 0 for meta in instruments: try: exp_ms = int(meta.get("expTime") or 0) @@ -646,7 +661,10 @@ def build_option_chain( continue opt_type = str(meta.get("optType") or "") strike = _safe_float(meta.get("stk")) - if strike is None or idx is None: + if strike is None: + continue + if idx is None: + skipped_no_index += 1 continue if itm_only and not is_shallow_itm( opt_type=opt_type, @@ -702,7 +720,25 @@ def build_option_chain( for exp_ms_str, contracts in sorted(expiries.items(), key=lambda x: int(x[0])): contracts.sort(key=lambda c: (c["opt_type"], c["strike"])) exp_list.append({"exp_time": int(exp_ms_str), "contracts": contracts}) - return {"underlying": u, "index_px": idx, "inst_family": family, "expiries": exp_list} + out: dict[str, Any] = { + "underlying": u, + "index_px": idx, + "inst_family": family, + "expiries": exp_list, + "instruments_count": len(instruments), + } + if not exp_list: + if instruments_err: + out["chain_error"] = f"拉取期权合约失败: {instruments_err}" + elif idx is None: + out["chain_error"] = "指数价获取失败,无法构建期权链" + elif skipped_no_index: + out["chain_error"] = "指数价缺失,合约已跳过" + elif instruments: + out["chain_error"] = f"近 {max_dte_days:g} 日内无可用到期(已过滤 {len(instruments)} 个合约)" + else: + out["chain_error"] = "期权合约列表为空,请稍后刷新" + return out def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]: diff --git a/lib/options/options_register.py b/lib/options/options_register.py index ea460df..fa5941e 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -353,13 +353,27 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: if ex is None: return jsonify({"ok": False, "msg": err}) u = (request.args.get("underlying") or cfg["default_underly"]).upper() - chain = cfg["build_option_chain"]( - ex, - u, - max_dte_days=cfg["chain_max_dte_days"], - itm_only=False, - itm_max_dist_usd=cfg["itm_max_dist"], - ) + try: + chain = cfg["build_option_chain"]( + ex, + u, + max_dte_days=cfg["chain_max_dte_days"], + itm_only=False, + itm_max_dist_usd=cfg["itm_max_dist"], + ) + except Exception as e: + return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"}) + expiries = chain.get("expiries") or [] + chain_err = chain.get("chain_error") + if not expiries: + return jsonify( + { + "ok": False, + "msg": chain_err or "暂无到期日,请稍后点「刷新链」", + **chain, + "chain_max_dte_days": cfg["chain_max_dte_days"], + } + ) return jsonify({"ok": True, **chain, "chain_max_dte_days": cfg["chain_max_dte_days"]}) @app.route("/api/options/quote") diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 934c5a8..7a32bfc 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -273,4 +273,4 @@ - +