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 lastGoodPositions = null;
|
||||||
let lastGoodPositionsAt = 0;
|
let lastGoodPositionsAt = 0;
|
||||||
let positionsRefreshSeq = 0;
|
let positionsRefreshSeq = 0;
|
||||||
|
let chainLoadSeq = 0;
|
||||||
let refreshAllTimer = null;
|
let refreshAllTimer = null;
|
||||||
let pendingRefreshTimer = null;
|
let pendingRefreshTimer = null;
|
||||||
let pendingTtlSeconds = 600;
|
let pendingTtlSeconds = 600;
|
||||||
@@ -484,8 +485,8 @@
|
|||||||
const sel = document.getElementById("opt-exp-select");
|
const sel = document.getElementById("opt-exp-select");
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
const prev = preserveSelection !== false ? sel.value : "";
|
const prev = preserveSelection !== false ? sel.value : "";
|
||||||
sel.innerHTML = '<option value="">选择到期日</option>';
|
|
||||||
const exps = (state.chain && state.chain.expiries) || [];
|
const exps = (state.chain && state.chain.expiries) || [];
|
||||||
|
sel.innerHTML = '<option value="">选择到期日</option>';
|
||||||
exps.forEach(function (e) {
|
exps.forEach(function (e) {
|
||||||
const o = document.createElement("option");
|
const o = document.createElement("option");
|
||||||
o.value = String(e.exp_time);
|
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() {
|
function renderExpiries() {
|
||||||
renderExpiryOptions(true);
|
renderExpiryOptions(true);
|
||||||
renderIndexLine();
|
renderIndexLine();
|
||||||
@@ -938,25 +953,93 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadChain() {
|
async function loadChain(opts) {
|
||||||
const d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(state.underlying));
|
const soft = !!(opts && opts.soft);
|
||||||
if (!d.ok) {
|
const uly = state.underlying;
|
||||||
alert(d.msg || "加载失败");
|
const seq = ++chainLoadSeq;
|
||||||
return;
|
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() {
|
async function openPosition() {
|
||||||
@@ -1756,7 +1839,7 @@
|
|||||||
refreshPendingOrders();
|
refreshPendingOrders();
|
||||||
startPendingOrdersPoll();
|
startPendingOrdersPoll();
|
||||||
const hasCache =
|
const hasCache =
|
||||||
panelCache.chain &&
|
chainHasExpiries(panelCache.chain) &&
|
||||||
panelCache.underlying === state.underlying &&
|
panelCache.underlying === state.underlying &&
|
||||||
panelCache.optType === state.optType;
|
panelCache.optType === state.optType;
|
||||||
if (hasCache) {
|
if (hasCache) {
|
||||||
@@ -1764,6 +1847,8 @@
|
|||||||
renderExpiries();
|
renderExpiries();
|
||||||
renderStrikes();
|
renderStrikes();
|
||||||
refreshAllPositions();
|
refreshAllPositions();
|
||||||
|
// 后台静默刷新,避免缓存过期后到期日变空
|
||||||
|
loadChain({ soft: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
requestAnimationFrame(function () {
|
requestAnimationFrame(function () {
|
||||||
|
|||||||
@@ -596,13 +596,10 @@ def fetch_option_instruments(
|
|||||||
ex: ccxt.okx,
|
ex: ccxt.okx,
|
||||||
inst_family: str,
|
inst_family: str,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
try:
|
rows = ex.public_get_public_instruments(
|
||||||
rows = ex.public_get_public_instruments(
|
{"instType": "OPTION", "instFamily": inst_family}
|
||||||
{"instType": "OPTION", "instFamily": inst_family}
|
).get("data") or []
|
||||||
).get("data") or []
|
return [r for r in rows if isinstance(r, dict) and r.get("state") == "live"]
|
||||||
return [r for r in rows if isinstance(r, dict) and r.get("state") == "live"]
|
|
||||||
except Exception:
|
|
||||||
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]]:
|
||||||
@@ -634,9 +631,27 @@ def build_option_chain(
|
|||||||
idx = index_px if index_px is not None else fetch_index_price(ex, uly)
|
idx = index_px if index_px is not None else fetch_index_price(ex, uly)
|
||||||
now_ms = time.time() * 1000
|
now_ms = time.time() * 1000
|
||||||
max_ms = now_ms + max_dte_days * 86400 * 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)
|
tickers = fetch_option_tickers(ex, family)
|
||||||
expiries: dict[str, list[dict[str, Any]]] = {}
|
expiries: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
skipped_no_index = 0
|
||||||
for meta in instruments:
|
for meta in instruments:
|
||||||
try:
|
try:
|
||||||
exp_ms = int(meta.get("expTime") or 0)
|
exp_ms = int(meta.get("expTime") or 0)
|
||||||
@@ -646,7 +661,10 @@ def build_option_chain(
|
|||||||
continue
|
continue
|
||||||
opt_type = str(meta.get("optType") or "")
|
opt_type = str(meta.get("optType") or "")
|
||||||
strike = _safe_float(meta.get("stk"))
|
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
|
continue
|
||||||
if itm_only and not is_shallow_itm(
|
if itm_only and not is_shallow_itm(
|
||||||
opt_type=opt_type,
|
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])):
|
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"]))
|
contracts.sort(key=lambda c: (c["opt_type"], c["strike"]))
|
||||||
exp_list.append({"exp_time": int(exp_ms_str), "contracts": contracts})
|
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]:
|
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:
|
if ex is None:
|
||||||
return jsonify({"ok": False, "msg": err})
|
return jsonify({"ok": False, "msg": err})
|
||||||
u = (request.args.get("underlying") or cfg["default_underly"]).upper()
|
u = (request.args.get("underlying") or cfg["default_underly"]).upper()
|
||||||
chain = cfg["build_option_chain"](
|
try:
|
||||||
ex,
|
chain = cfg["build_option_chain"](
|
||||||
u,
|
ex,
|
||||||
max_dte_days=cfg["chain_max_dte_days"],
|
u,
|
||||||
itm_only=False,
|
max_dte_days=cfg["chain_max_dte_days"],
|
||||||
itm_max_dist_usd=cfg["itm_max_dist"],
|
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"]})
|
return jsonify({"ok": True, **chain, "chain_max_dte_days": cfg["chain_max_dte_days"]})
|
||||||
|
|
||||||
@app.route("/api/options/quote")
|
@app.route("/api/options/quote")
|
||||||
|
|||||||
@@ -273,4 +273,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=34"></script>
|
<script src="/static/options_panel.js?v=35"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user