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:
@@ -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 () {
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user