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 @@
-
+