From ab8b2a74e273031bde6ada31a36e45cb99b86349 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 15 Jul 2026 21:22:29 +0800 Subject: [PATCH] Show cancellable pending option orders beside the order form. Fetch live OKX option hangs on the right after submit, with refresh and one-click cancel. Co-authored-by: Cursor --- lib/common/static/instance_theme.css | 95 ++++++++++++++++++++ lib/common/static/options_panel.js | 106 ++++++++++++++++++++++- lib/exchange/okx_options_lib.py | 60 +++++++++++++ lib/options/options_register.py | 59 +++++++++++++ lib/options/templates/options_panel.html | 77 +++++++++------- 5 files changed, 364 insertions(+), 33 deletions(-) diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 63fcd86..c4f0ba5 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -3639,6 +3639,101 @@ html[data-theme="light"] .options-strike-table--t .opt-strike-row-atm td { .opt-order-panel-inner { border-radius: 8px; } +.opt-order-layout { + display: flex; + align-items: stretch; + gap: 14px; +} +.opt-order-main { + flex: 1 1 auto; + min-width: 0; +} +.opt-order-pending { + flex: 0 0 280px; + max-width: 320px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid rgba(158, 192, 255, 0.2); + background: rgba(0, 0, 0, 0.18); +} +.opt-order-pending-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} +.opt-order-pending-title { + margin: 0; + font-size: 0.82rem; + color: #9ec0ff; + font-weight: 600; +} +.opt-order-pending-head .btn-secondary { + font-size: 0.68rem; + padding: 2px 8px; +} +.opt-pending-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 220px; + overflow: auto; +} +.opt-pending-empty { + font-size: 0.72rem; +} +.opt-pending-item { + padding: 8px 9px; + border-radius: 7px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} +.opt-pending-item-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} +.opt-pending-side { + font-size: 0.72rem; + font-weight: 600; +} +.opt-pending-side.is-buy { color: #3dd68c; } +.opt-pending-side.is-sell { color: #ff6b7a; } +.opt-pending-inst { + font-size: 0.68rem; + color: #c5d0ee; + word-break: break-all; + margin-bottom: 4px; +} +.opt-pending-meta { + font-size: 0.68rem; + color: #8892b0; + line-height: 1.35; +} +.opt-pending-item .opt-pending-cancel { + font-size: 0.68rem; + padding: 2px 8px; +} +html[data-theme="light"] .opt-order-pending { + background: rgba(0, 0, 0, 0.03); + border-color: rgba(0, 0, 0, 0.08); +} +html[data-theme="light"] .opt-pending-item { + background: #fff; + border-color: rgba(0, 0, 0, 0.08); +} +@media (max-width: 900px) { + .opt-order-layout { + flex-direction: column; + } + .opt-order-pending { + flex: 1 1 auto; + max-width: none; + } +} .opt-order-panel-inner .opt-order-title { margin: 0 0 8px; font-size: 0.85rem; diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index b6a321c..a1be281 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -27,7 +27,9 @@ let lastGoodPositionsAt = 0; let positionsRefreshSeq = 0; let refreshAllTimer = null; + let pendingRefreshTimer = null; const POSITIONS_STALE_MS = 45000; + const PENDING_POLL_MS = 8000; function fmt(v, d) { if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; @@ -68,6 +70,7 @@ } function parkOrderPanel() { + stopPendingOrdersPoll(); const panel = orderPanel(); const host = orderPanelHost(); const inline = document.querySelector(".opt-order-inline-row"); @@ -116,6 +119,99 @@ panel.style.display = ""; orderPanelHost().hidden = true; tr.scrollIntoView({ behavior: "smooth", block: "nearest" }); + refreshPendingOrders(); + startPendingOrdersPoll(); + } + + function paintPendingOrders(orders) { + const host = document.getElementById("opt-pending-list"); + if (!host) return; + const rows = Array.isArray(orders) ? orders : []; + if (!rows.length) { + host.innerHTML = '
暂无未成交委托
'; + return; + } + host.innerHTML = rows.map(function (o) { + const side = String(o.side || "").toLowerCase(); + const sideCls = side === "buy" ? "is-buy" : side === "sell" ? "is-sell" : ""; + const remain = (o.sz != null && o.fill_sz != null) ? Math.max(0, Number(o.sz) - Number(o.fill_sz)) : o.sz; + const pxTxt = o.px != null ? fmtOptionPx(o.px, null) : "—"; + return ( + '
' + + '
' + + '' + (o.side_label || side || "—") + "" + + '' + + "
" + + '
' + (o.inst_id || "—") + "
" + + '
价 ' + pxTxt + + " · 张数 " + (o.sz != null ? o.sz : "—") + + (o.fill_sz != null && Number(o.fill_sz) > 0 ? " · 已成 " + o.fill_sz : "") + + (remain != null && o.fill_sz != null && Number(o.fill_sz) > 0 ? " · 剩余 " + remain : "") + + "
" + ); + }).join(""); + host.querySelectorAll(".opt-pending-cancel").forEach(function (btn) { + btn.addEventListener("click", function () { + cancelPendingOrder(btn.getAttribute("data-inst"), btn.getAttribute("data-ord"), btn); + }); + }); + } + + async function refreshPendingOrders() { + const host = document.getElementById("opt-pending-list"); + if (!host) return; + try { + const d = await apiJson("/api/options/orders/pending"); + if (!d.ok) { + host.innerHTML = '
' + (d.msg || "获取委托失败") + "
"; + return; + } + paintPendingOrders(d.orders || []); + } catch (e) { + host.innerHTML = '
获取委托失败
'; + } + } + + function startPendingOrdersPoll() { + stopPendingOrdersPoll(); + pendingRefreshTimer = setInterval(function () { + const panel = orderPanel(); + if (!panel || panel.style.display === "none") { + stopPendingOrdersPoll(); + return; + } + refreshPendingOrders(); + }, PENDING_POLL_MS); + } + + function stopPendingOrdersPoll() { + if (pendingRefreshTimer) { + clearInterval(pendingRefreshTimer); + pendingRefreshTimer = null; + } + } + + async function cancelPendingOrder(inst, ordId, btn) { + if (!inst || !ordId) return; + if (!confirm("撤销该委托?\n合约: " + inst + "\n订单: " + ordId)) return; + if (btn) btn.disabled = true; + try { + const d = await apiJson("/api/options/orders/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inst_id: inst, ord_id: ordId }), + }); + if (!d.ok) { + alert(d.msg || "撤销失败"); + return; + } + await refreshPendingOrders(); + refreshAllPositions(); + if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot(); + } finally { + if (btn) btn.disabled = false; + } } function currentSizeMode() { @@ -852,9 +948,11 @@ body: JSON.stringify(body), }); const msgEl = document.getElementById("opt-order-msg"); - msgEl.textContent = d.ok ? "下单已提交,可在 OKX 委托中查看" : (d.msg || "失败"); + msgEl.textContent = d.ok ? "下单已提交,右侧可查看/撤销未成交委托" : (d.msg || "失败"); msgEl.classList.toggle("opt-error", !d.ok); if (d.ok) { + refreshPendingOrders(); + startPendingOrdersPoll(); refreshAllPositions(); if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot(); } else { @@ -1667,6 +1765,12 @@ document.getElementById("opt-load-chain").addEventListener("click", loadChain); document.getElementById("opt-refresh-positions").addEventListener("click", refreshAllPositions); document.getElementById("opt-open-btn").addEventListener("click", openPosition); + const pendingRefreshBtn = document.getElementById("opt-pending-refresh"); + if (pendingRefreshBtn) { + pendingRefreshBtn.addEventListener("click", function () { + refreshPendingOrders(); + }); + } bindOptionsPosTabs(); document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) { diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 11f356f..0a92c8a 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -766,6 +766,66 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]: return {"ok": False, "msg": str(e)} +def fetch_option_pending_orders(ex: ccxt.okx, inst_id: str | None = None) -> list[dict[str, Any]]: + """未成交期权委托(限价挂单).""" + params: dict[str, Any] = {"instType": "OPTION"} + inst = (inst_id or "").strip() + if inst: + params["instId"] = inst + try: + rows = ex.private_get_trade_orders_pending(params).get("data") or [] + except Exception: + return [] + out: list[dict[str, Any]] = [] + for o in rows: + if not isinstance(o, dict): + continue + oid = str(o.get("ordId") or "").strip() + iid = str(o.get("instId") or "").strip() + if not oid or not iid: + continue + side = str(o.get("side") or "").lower() + px = _safe_float(o.get("px")) + sz = _safe_float(o.get("sz")) + fill_sz = _safe_float(o.get("fillSz")) or 0.0 + acc_fill = _safe_float(o.get("accFillSz")) + if acc_fill is not None: + fill_sz = acc_fill + out.append( + { + "ord_id": oid, + "inst_id": iid, + "side": side, + "side_label": "买入" if side == "buy" else ("卖出" if side == "sell" else side or "—"), + "px": px, + "sz": int(sz) if sz is not None else None, + "fill_sz": int(fill_sz) if fill_sz is not None else 0, + "state": str(o.get("state") or ""), + "ord_type": str(o.get("ordType") or ""), + "c_time": o.get("cTime"), + "u_time": o.get("uTime"), + "reduce_only": str(o.get("reduceOnly") or "").lower() in ("true", "1", "yes"), + } + ) + out.sort(key=lambda x: int(float(x.get("c_time") or 0)), reverse=True) + return out + + +def cancel_option_order(ex: ccxt.okx, *, inst_id: str, ord_id: str) -> dict[str, Any]: + inst_id = (inst_id or "").strip() + ord_id = (ord_id or "").strip() + if not inst_id or not ord_id: + return {"ok": False, "msg": "缺少 inst_id 或 ord_id"} + try: + resp = ex.private_post_trade_cancel_order({"instId": inst_id, "ordId": ord_id}) + data = (resp or {}).get("data") or [] + if data and str(data[0].get("sCode")) == "0": + return {"ok": True, "data": data[0], "raw": resp} + return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp} + except Exception as e: + return {"ok": False, "msg": _okx_trade_error_message(e)} + + def place_option_limit_order( ex: ccxt.okx, *, diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 9f56b3c..b29ba95 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -87,6 +87,8 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: fetch_options_balances, format_position_row, options_api_ready, + cancel_option_order, + fetch_option_pending_orders, place_option_limit_order, place_option_market_order, quote_option_contract, @@ -119,6 +121,8 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: "fetch_option_book_depth": fetch_option_book_depth, "place_option_limit_order": place_option_limit_order, "place_option_market_order": place_option_market_order, + "fetch_option_pending_orders": fetch_option_pending_orders, + "cancel_option_order": cancel_option_order, "fetch_option_positions": fetch_option_positions, "fetch_options_balances": fetch_options_balances, "format_position_row": format_position_row, @@ -531,6 +535,61 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: } ) + @app.route("/api/options/orders/pending") + @lr + def api_options_orders_pending(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + inst_id = (request.args.get("inst_id") or "").strip() or None + try: + orders = cfg["fetch_option_pending_orders"](ex, inst_id) + except Exception as e: + return jsonify({"ok": False, "msg": f"获取委托失败: {e}"}) + return jsonify({"ok": True, "orders": orders, "count": len(orders)}) + + @app.route("/api/options/orders/cancel", methods=["POST"]) + @lr + def api_options_orders_cancel(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + ord_id = (data.get("ord_id") or "").strip() + if not inst_id or not ord_id: + return jsonify({"ok": False, "msg": "缺少 inst_id 或 ord_id"}) + out = cfg["cancel_option_order"](ex, inst_id=inst_id, ord_id=ord_id) + if out.get("ok"): + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + # 本地未成交开仓记录标记取消,避免假 open + try: + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + """ + UPDATE options_trades + SET status = 'cancelled', + signal_note = CASE + WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '委托撤销' + ELSE signal_note + END, + closed_at = CURRENT_TIMESTAMP + WHERE inst_id = ? AND exchange_ord_id = ? AND status = 'open' + """, + (inst_id, ord_id), + ) + conn.commit() + finally: + conn.close() + except Exception: + pass + _sync_options_trades(cfg, force=True) + return jsonify(out), (200 if out.get("ok") else 400) + @app.route("/api/options/positions") @lr def api_options_positions(): diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 494663c..336c7b6 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -66,39 +66,52 @@