Fix options select UI and false contract-missing quotes.
Keep the order host out of tbody wipes, sync pick-button labels, and retry/fallback OKX meta under rate limits so live contracts are not reported as missing. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3754,8 +3754,11 @@ html[data-theme="light"] .opt-pending-item {
|
|||||||
.options-page-wrap .opt-order-panel-inner .opt-order-title {
|
.options-page-wrap .opt-order-panel-inner .opt-order-title {
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
.opt-order-panel-host {
|
.opt-order-panel-host:not([hidden]) {
|
||||||
display: none;
|
display: block;
|
||||||
|
}
|
||||||
|
.opt-order-panel-host[hidden] {
|
||||||
|
display: none !important;
|
||||||
}
|
}
|
||||||
.options-order-grid {
|
.options-order-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -27,11 +27,16 @@
|
|||||||
let lastGoodPositionsAt = 0;
|
let lastGoodPositionsAt = 0;
|
||||||
let positionsRefreshSeq = 0;
|
let positionsRefreshSeq = 0;
|
||||||
let chainLoadSeq = 0;
|
let chainLoadSeq = 0;
|
||||||
|
let selectSeq = 0;
|
||||||
let refreshAllTimer = null;
|
let refreshAllTimer = null;
|
||||||
let pendingRefreshTimer = null;
|
let pendingRefreshTimer = null;
|
||||||
let pendingTtlSeconds = 600;
|
let pendingTtlSeconds = 600;
|
||||||
const POSITIONS_STALE_MS = 45000;
|
const POSITIONS_STALE_MS = 45000;
|
||||||
const PENDING_POLL_MS = 8000;
|
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) {
|
function fmt(v, d) {
|
||||||
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
|
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
|
||||||
@@ -75,59 +80,71 @@
|
|||||||
return document.getElementById("opt-order-panel-host");
|
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() {
|
function parkOrderPanel() {
|
||||||
stopPendingOrdersPoll();
|
stopPendingOrdersPoll();
|
||||||
const panel = orderPanel();
|
const panel = orderPanel();
|
||||||
const host = orderPanelHost();
|
const host = orderPanelHost();
|
||||||
// 先把面板移回 host,再删行内 tr,避免 tbody.innerHTML 清空时把 #opt-order-panel 一起销毁
|
// 把整块 host(含面板)移回原位,再删行内 tr,避免 tbody 重绘销毁下单 DOM
|
||||||
if (panel && host) {
|
if (host && orderPanelHome && host.parentElement !== orderPanelHome) {
|
||||||
if (panel.parentElement !== host) host.appendChild(panel);
|
orderPanelHome.appendChild(host);
|
||||||
host.hidden = true;
|
} else if (panel && host && panel.parentElement !== host) {
|
||||||
panel.style.display = "none";
|
host.appendChild(panel);
|
||||||
}
|
}
|
||||||
|
if (host) host.hidden = true;
|
||||||
|
if (panel) panel.style.display = "none";
|
||||||
const inline = document.querySelector(".opt-order-inline-row");
|
const inline = document.querySelector(".opt-order-inline-row");
|
||||||
if (inline) inline.remove();
|
if (inline) inline.remove();
|
||||||
document.querySelectorAll(".opt-strike-row").forEach(function (r) {
|
document.querySelectorAll(".opt-strike-row").forEach(function (r) {
|
||||||
r.classList.remove("opt-row-selected");
|
r.classList.remove("opt-row-selected");
|
||||||
});
|
});
|
||||||
document.querySelectorAll(".opt-pick-btn").forEach(function (b) {
|
syncPickButtons(null);
|
||||||
b.classList.remove("active");
|
|
||||||
b.disabled = false;
|
|
||||||
if (b.dataset.origText) b.textContent = b.dataset.origText;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function placeOrderPanelAfter(instId) {
|
function placeOrderPanelAfter(instId) {
|
||||||
const panel = orderPanel();
|
const panel = orderPanel();
|
||||||
if (!panel || !instId) return;
|
const host = orderPanelHost();
|
||||||
|
if (!panel || !host || !instId) {
|
||||||
|
syncPickButtons(instId || null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const row =
|
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-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-call-inst="' + CSS.escape(instId) + '"]') ||
|
||||||
document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-put-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) {
|
document.querySelectorAll(".opt-strike-row").forEach(function (r) {
|
||||||
r.classList.toggle("opt-row-selected", r === row);
|
r.classList.toggle("opt-row-selected", r === row);
|
||||||
});
|
});
|
||||||
document.querySelectorAll(".opt-pick-btn").forEach(function (btn) {
|
syncPickButtons(instId);
|
||||||
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 = "已选";
|
|
||||||
});
|
|
||||||
const oldInline = document.querySelector(".opt-order-inline-row");
|
const oldInline = document.querySelector(".opt-order-inline-row");
|
||||||
if (oldInline) oldInline.remove();
|
if (oldInline) oldInline.remove();
|
||||||
|
if (panel.parentElement !== host) host.appendChild(panel);
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.className = "opt-order-inline-row";
|
tr.className = "opt-order-inline-row";
|
||||||
const td = document.createElement("td");
|
const td = document.createElement("td");
|
||||||
td.colSpan = strikeTableColspan();
|
td.colSpan = strikeTableColspan();
|
||||||
td.appendChild(panel);
|
td.appendChild(host);
|
||||||
tr.appendChild(td);
|
tr.appendChild(td);
|
||||||
row.after(tr);
|
row.after(tr);
|
||||||
|
host.hidden = false;
|
||||||
panel.style.display = "";
|
panel.style.display = "";
|
||||||
orderPanelHost().hidden = true;
|
|
||||||
tr.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
tr.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
refreshPendingOrders();
|
refreshPendingOrders();
|
||||||
startPendingOrdersPoll();
|
startPendingOrdersPoll();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtPendingAge(sec) {
|
function fmtPendingAge(sec) {
|
||||||
@@ -920,6 +937,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectContract(instId, pickBtn, silent) {
|
async function selectContract(instId, pickBtn, silent) {
|
||||||
|
const seq = ++selectSeq;
|
||||||
state.selectedInst = instId;
|
state.selectedInst = instId;
|
||||||
placeOrderPanelAfter(instId);
|
placeOrderPanelAfter(instId);
|
||||||
if (pickBtn) {
|
if (pickBtn) {
|
||||||
@@ -929,15 +947,14 @@
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const d = await apiJson(quoteUrl(instId));
|
const d = await apiJson(quoteUrl(instId));
|
||||||
|
if (seq !== selectSeq) return d;
|
||||||
fillOrderPanel(d);
|
fillOrderPanel(d);
|
||||||
return d;
|
return d;
|
||||||
} finally {
|
} finally {
|
||||||
if (pickBtn) {
|
if (seq === selectSeq) {
|
||||||
pickBtn.disabled = false;
|
syncPickButtons(instId);
|
||||||
pickBtn.textContent = "已选";
|
if (!silent) placeOrderPanelAfter(instId);
|
||||||
pickBtn.classList.add("active");
|
|
||||||
}
|
}
|
||||||
if (!silent) placeOrderPanelAfter(instId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
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:
|
def fetch_option_instrument_meta(ex: ccxt.okx, inst_id: str) -> dict[str, Any] | None:
|
||||||
family = inst_family_from_inst_id(inst_id)
|
family = inst_family_from_inst_id(inst_id)
|
||||||
if not family:
|
if not family:
|
||||||
return None
|
return None
|
||||||
try:
|
last_err: BaseException | None = None
|
||||||
rows = ex.public_get_public_instruments(
|
for attempt in range(3):
|
||||||
{"instType": "OPTION", "instFamily": family, "instId": inst_id}
|
try:
|
||||||
).get("data") or []
|
rows = ex.public_get_public_instruments(
|
||||||
if rows and isinstance(rows[0], dict):
|
{"instType": "OPTION", "instFamily": family, "instId": inst_id}
|
||||||
return rows[0]
|
).get("data") or []
|
||||||
rows = ex.public_get_public_instruments(
|
if rows and isinstance(rows[0], dict):
|
||||||
{"instType": "OPTION", "instFamily": family}
|
return rows[0]
|
||||||
).get("data") or []
|
rows = ex.public_get_public_instruments(
|
||||||
for r in rows:
|
{"instType": "OPTION", "instFamily": family}
|
||||||
if isinstance(r, dict) and str(r.get("instId")) == inst_id:
|
).get("data") or []
|
||||||
return r
|
for r in rows:
|
||||||
except Exception:
|
if isinstance(r, dict) and str(r.get("instId")) == inst_id:
|
||||||
return None
|
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
|
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"}
|
return {"ok": False, "msg": "缺少 inst_id"}
|
||||||
try:
|
try:
|
||||||
meta = fetch_option_instrument_meta(ex, inst_id)
|
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 not meta:
|
||||||
|
if ticker_err is not None and _is_okx_rate_limit(ticker_err):
|
||||||
|
return {"ok": False, "msg": "行情限频,请稍后重试"}
|
||||||
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 {}
|
t = t_rows[0] if t_rows else {}
|
||||||
ask = _safe_float(t.get("askPx"))
|
ask = _safe_float(t.get("askPx"))
|
||||||
bid = _safe_float(t.get("bidPx"))
|
bid = _safe_float(t.get("bidPx"))
|
||||||
|
|||||||
@@ -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=35"></script>
|
<script src="/static/options_panel.js?v=36"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user