Fix intermittent empty options expiry dropdown after coin select.

Retry instrument fetch, reject empty chains instead of caching them, and ignore stale loadChain races.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-16 12:04:08 +08:00
parent b50eebe37c
commit 12c4e55f24
4 changed files with 173 additions and 38 deletions
+105 -20
View File
@@ -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 = '<option value="">选择到期日</option>';
const exps = (state.chain && state.chain.expiries) || [];
sel.innerHTML = '<option value="">选择到期日</option>';
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 =
'<tr><td colspan="' + strikeTableColspan() + '" class="muted">加载期权链…</td></tr>';
}
}
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 =
'<tr><td colspan="' + strikeTableColspan() + '" class="muted">' +
(lastMsg || "暂无到期日,请点「刷新链」") +
"</td></tr>";
}
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 =
'<tr><td colspan="' + strikeTableColspan() + '" class="muted">加载失败: ' +
String((e && e.message) || e) +
"</td></tr>";
}
} 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 () {
+46 -10
View File
@@ -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]:
+21 -7
View File
@@ -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")
+1 -1
View File
@@ -273,4 +273,4 @@
</div>
</div>
<script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=34"></script>
<script src="/static/options_panel.js?v=35"></script>