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 @@
-
下单
-
-
-
卖一/张—
-
买一/张—
-
张数—
-
ETH 数量—
-
预估权利金—
-
合约杠杆—
-
到期平衡—
-
距平衡—
+
+
+
下单
+
+
+
卖一/张—
+
买一/张—
+
张数—
+
ETH 数量—
+
预估权利金—
+
合约杠杆—
+
到期平衡—
+
距平衡—
+
+
+
+
+ 预计价值
+ —
+ 盈利
+ —
+ 目标杠杆
+ —
+ 目标价=监控指数;到位后按买一限价平仓;无止损,到期即止损
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
- 预计价值
- —
- 盈利
- —
- 目标杠杆
- —
- 目标价=监控指数;到位后按买一限价平仓;无止损,到期即止损
-
-
-
-
-
-
-
-
-
-
-