diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 2e19ce9..7e5bf2b 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -3754,8 +3754,11 @@ html[data-theme="light"] .opt-pending-item { .options-page-wrap .opt-order-panel-inner .opt-order-title { font-size: 0.82rem; } -.opt-order-panel-host { - display: none; +.opt-order-panel-host:not([hidden]) { + display: block; +} +.opt-order-panel-host[hidden] { + display: none !important; } .options-order-grid { display: grid; diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 2c827e8..ff46fd2 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -27,11 +27,16 @@ let lastGoodPositionsAt = 0; let positionsRefreshSeq = 0; let chainLoadSeq = 0; + let selectSeq = 0; let refreshAllTimer = null; let pendingRefreshTimer = null; let pendingTtlSeconds = 600; const POSITIONS_STALE_MS = 45000; const PENDING_POLL_MS = 8000; + const orderPanelHome = (function () { + const host = document.getElementById("opt-order-panel-host"); + return host ? host.parentElement : null; + })(); function fmt(v, d) { if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; @@ -75,59 +80,71 @@ return document.getElementById("opt-order-panel-host"); } + function syncPickButtons(instId) { + document.querySelectorAll(".opt-pick-btn").forEach(function (btn) { + const on = !!instId && btn.getAttribute("data-inst") === instId; + btn.classList.toggle("active", on); + btn.disabled = false; + if (!btn.dataset.origText) btn.dataset.origText = "选择"; + btn.textContent = on ? "已选" : btn.dataset.origText; + }); + } + function parkOrderPanel() { stopPendingOrdersPoll(); const panel = orderPanel(); const host = orderPanelHost(); - // 先把面板移回 host,再删行内 tr,避免 tbody.innerHTML 清空时把 #opt-order-panel 一起销毁 - if (panel && host) { - if (panel.parentElement !== host) host.appendChild(panel); - host.hidden = true; - panel.style.display = "none"; + // 把整块 host(含面板)移回原位,再删行内 tr,避免 tbody 重绘销毁下单 DOM + if (host && orderPanelHome && host.parentElement !== orderPanelHome) { + orderPanelHome.appendChild(host); + } else if (panel && host && panel.parentElement !== host) { + host.appendChild(panel); } + if (host) host.hidden = true; + if (panel) panel.style.display = "none"; const inline = document.querySelector(".opt-order-inline-row"); if (inline) inline.remove(); document.querySelectorAll(".opt-strike-row").forEach(function (r) { r.classList.remove("opt-row-selected"); }); - document.querySelectorAll(".opt-pick-btn").forEach(function (b) { - b.classList.remove("active"); - b.disabled = false; - if (b.dataset.origText) b.textContent = b.dataset.origText; - }); + syncPickButtons(null); } function placeOrderPanelAfter(instId) { const panel = orderPanel(); - if (!panel || !instId) return; + const host = orderPanelHost(); + if (!panel || !host || !instId) { + syncPickButtons(instId || null); + return false; + } const row = document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-inst="' + CSS.escape(instId) + '"]') || document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-call-inst="' + CSS.escape(instId) + '"]') || document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-put-inst="' + CSS.escape(instId) + '"]'); - if (!row) return; + if (!row) { + syncPickButtons(null); + return false; + } document.querySelectorAll(".opt-strike-row").forEach(function (r) { r.classList.toggle("opt-row-selected", r === row); }); - document.querySelectorAll(".opt-pick-btn").forEach(function (btn) { - const on = btn.getAttribute("data-inst") === instId; - btn.classList.toggle("active", on); - if (!btn.dataset.origText) btn.dataset.origText = btn.textContent; - if (on) btn.textContent = "已选"; - }); + syncPickButtons(instId); const oldInline = document.querySelector(".opt-order-inline-row"); if (oldInline) oldInline.remove(); + if (panel.parentElement !== host) host.appendChild(panel); const tr = document.createElement("tr"); tr.className = "opt-order-inline-row"; const td = document.createElement("td"); td.colSpan = strikeTableColspan(); - td.appendChild(panel); + td.appendChild(host); tr.appendChild(td); row.after(tr); + host.hidden = false; panel.style.display = ""; - orderPanelHost().hidden = true; tr.scrollIntoView({ behavior: "smooth", block: "nearest" }); refreshPendingOrders(); startPendingOrdersPoll(); + return true; } function fmtPendingAge(sec) { @@ -920,6 +937,7 @@ } async function selectContract(instId, pickBtn, silent) { + const seq = ++selectSeq; state.selectedInst = instId; placeOrderPanelAfter(instId); if (pickBtn) { @@ -929,15 +947,14 @@ } try { const d = await apiJson(quoteUrl(instId)); + if (seq !== selectSeq) return d; fillOrderPanel(d); return d; } finally { - if (pickBtn) { - pickBtn.disabled = false; - pickBtn.textContent = "已选"; - pickBtn.classList.add("active"); + if (seq === selectSeq) { + syncPickButtons(instId); + if (!silent) placeOrderPanelAfter(instId); } - if (!silent) placeOrderPanelAfter(instId); } } diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index fdf053b..68f10a7 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -370,24 +370,62 @@ def normalize_option_exp_ms(exp_time: Any, inst_id: str = "") -> int | None: return expiry_ms_from_inst_id(inst_id) +def _is_okx_rate_limit(err: BaseException) -> bool: + text = str(err) or "" + name = err.__class__.__name__ + return "50011" in text or "Too Many Requests" in text or "RateLimit" in name + + +def _meta_from_inst_id_fallback(inst_id: str) -> dict[str, Any]: + """行情在但 instruments 限频时,用合约 ID 拼最小 meta,避免误报「合约不存在」.""" + family = inst_family_from_inst_id(inst_id) or "" + opt_type, strike = option_fields_from_inst_id(inst_id) + uly = family.replace("_UM", "") if family else "" + return { + "instId": inst_id, + "instFamily": family, + "uly": uly, + "optType": opt_type, + "stk": strike, + "ctMult": 0.01, + "minSz": "1", + "tickSz": "0.0001", + "state": "live", + } + + def fetch_option_instrument_meta(ex: ccxt.okx, inst_id: str) -> dict[str, Any] | None: family = inst_family_from_inst_id(inst_id) if not family: return None - try: - rows = ex.public_get_public_instruments( - {"instType": "OPTION", "instFamily": family, "instId": inst_id} - ).get("data") or [] - if rows and isinstance(rows[0], dict): - return rows[0] - rows = ex.public_get_public_instruments( - {"instType": "OPTION", "instFamily": family} - ).get("data") or [] - for r in rows: - if isinstance(r, dict) and str(r.get("instId")) == inst_id: - return r - except Exception: - return None + last_err: BaseException | None = None + for attempt in range(3): + try: + rows = ex.public_get_public_instruments( + {"instType": "OPTION", "instFamily": family, "instId": inst_id} + ).get("data") or [] + if rows and isinstance(rows[0], dict): + return rows[0] + rows = ex.public_get_public_instruments( + {"instType": "OPTION", "instFamily": family} + ).get("data") or [] + for r in rows: + if isinstance(r, dict) and str(r.get("instId")) == inst_id: + return r + return None + except Exception as e: + last_err = e + if _is_okx_rate_limit(e) and attempt < 2: + time.sleep(0.45 * (attempt + 1)) + continue + break + if last_err is not None and _is_okx_rate_limit(last_err): + try: + t_rows = ex.public_get_market_ticker({"instId": inst_id}).get("data") or [] + if t_rows: + return _meta_from_inst_id_fallback(inst_id) + except Exception: + pass return None @@ -747,9 +785,25 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]: return {"ok": False, "msg": "缺少 inst_id"} try: meta = fetch_option_instrument_meta(ex, inst_id) + t_rows: list[Any] = [] + ticker_err: BaseException | None = None + for attempt in range(3): + try: + t_rows = ex.public_get_market_ticker({"instId": inst_id}).get("data") or [] + ticker_err = None + break + except Exception as e: + ticker_err = e + if _is_okx_rate_limit(e) and attempt < 2: + time.sleep(0.45 * (attempt + 1)) + continue + break + if not meta and t_rows: + meta = _meta_from_inst_id_fallback(inst_id) if not meta: + if ticker_err is not None and _is_okx_rate_limit(ticker_err): + return {"ok": False, "msg": "行情限频,请稍后重试"} return {"ok": False, "msg": "合约不存在"} - t_rows = ex.public_get_market_ticker({"instId": inst_id}).get("data") or [] t = t_rows[0] if t_rows else {} ask = _safe_float(t.get("askPx")) bid = _safe_float(t.get("bidPx")) diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 7a32bfc..18de9d2 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -273,4 +273,4 @@ - +