Auto-cancel stale option close limits after pending TTL.

Default 10m via OKX_OPTIONS_PENDING_TTL_SECONDS; show age/countdown in pending panel; monitor loop cancels sell closes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-15 22:06:24 +08:00
parent ee7be3e7f3
commit cbb7f954f5
12 changed files with 306 additions and 15 deletions
+5
View File
@@ -3669,6 +3669,11 @@ html[data-theme="light"] .options-strike-table--t .opt-strike-row-atm td {
color: #9ec0ff;
font-weight: 600;
}
.opt-pending-ttl-hint {
margin: 0 0 8px;
font-size: 12px;
line-height: 1.4;
}
.opt-order-pending-head .btn-secondary {
font-size: 0.68rem;
padding: 2px 8px;
+38 -5
View File
@@ -28,6 +28,7 @@
let positionsRefreshSeq = 0;
let refreshAllTimer = null;
let pendingRefreshTimer = null;
let pendingTtlSeconds = 600;
const POSITIONS_STALE_MS = 45000;
const PENDING_POLL_MS = 8000;
@@ -123,8 +124,30 @@
startPendingOrdersPoll();
}
function paintPendingOrders(orders) {
function fmtPendingAge(sec) {
if (sec == null || Number.isNaN(Number(sec))) return "—";
let s = Math.max(0, Math.round(Number(sec)));
if (s < 60) return s + "秒";
const m = Math.floor(s / 60);
const rs = s % 60;
if (m < 60) return rs ? m + "分" + rs + "秒" : m + "分";
const h = Math.floor(m / 60);
const rm = m % 60;
return rm ? h + "时" + rm + "分" : h + "时";
}
function paintPendingOrders(orders, ttlSec) {
const host = document.getElementById("opt-pending-list");
const hint = document.getElementById("opt-pending-ttl-hint");
if (ttlSec != null && !Number.isNaN(Number(ttlSec))) {
pendingTtlSeconds = Number(ttlSec);
}
if (hint) {
const ttl = pendingTtlSeconds;
hint.textContent = ttl > 0
? ("平仓限价超 " + fmtPendingAge(ttl) + " 未成交将自动撤销")
: "平仓超时自动撤单已关闭";
}
if (!host) return;
const rows = Array.isArray(orders) ? orders : [];
if (!rows.length) {
@@ -136,10 +159,17 @@
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) : "—";
const kind = o.is_close_order ? "平仓" : "开仓";
let ttlTxt = "";
if (o.auto_cancel_enabled) {
if (o.stale) ttlTxt = " · 超时待撤";
else if (o.expire_in_sec != null) ttlTxt = " · 剩 " + fmtPendingAge(o.expire_in_sec) + " 自动撤";
}
const ageTxt = o.age_sec != null ? ("已挂 " + fmtPendingAge(o.age_sec)) : "";
return (
'<div class="opt-pending-item" data-ord="' + (o.ord_id || "") + '" data-inst="' + (o.inst_id || "") + '">' +
'<div class="opt-pending-item-top">' +
'<span class="opt-pending-side ' + sideCls + '">' + (o.side_label || side || "—") + "</span>" +
'<span class="opt-pending-side ' + sideCls + '">' + kind + " · " + (o.side_label || side || "—") + "</span>" +
'<button type="button" class="btn-secondary opt-pending-cancel" data-ord="' + (o.ord_id || "") +
'" data-inst="' + (o.inst_id || "") + '">撤销</button>' +
"</div>" +
@@ -148,6 +178,8 @@
" · 张数 " + (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 : "") +
(ageTxt ? " · " + ageTxt : "") +
ttlTxt +
"</div></div>"
);
}).join("");
@@ -167,7 +199,7 @@
host.innerHTML = '<div class="muted opt-pending-empty">' + (d.msg || "获取委托失败") + "</div>";
return;
}
paintPendingOrders(d.orders || []);
paintPendingOrders(d.orders || [], d.pending_ttl_seconds);
} catch (e) {
host.innerHTML = '<div class="muted opt-pending-empty">获取委托失败</div>';
}
@@ -176,8 +208,7 @@
function startPendingOrdersPoll() {
stopPendingOrdersPoll();
pendingRefreshTimer = setInterval(function () {
const panel = orderPanel();
if (!panel || panel.style.display === "none") {
if (!document.getElementById("options-root")) {
stopPendingOrdersPoll();
return;
}
@@ -1709,6 +1740,8 @@
syncMoneyFilterButtons();
syncChainViewUI();
updateUnderlyingLabel();
refreshPendingOrders();
startPendingOrdersPoll();
const hasCache =
panelCache.chain &&
panelCache.underlying === state.underlying &&
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<link rel="stylesheet" href="/static/instance_page.css?v=6">
<link rel="stylesheet" href="/static/instance_theme.css?v=91">
<link rel="stylesheet" href="/static/instance_theme.css?v=92">
<script src="/static/account_risk_badge.js?v=4"></script>
<meta name="theme-color" content="#0b0d14">
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
+1 -1
View File
@@ -17,7 +17,7 @@
<link rel="manifest" href="/static/icons/manifest.webmanifest">
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
<link rel="stylesheet" href="/static/instance_page.css?v=3">
<link rel="stylesheet" href="/static/instance_theme.css?v=91">
<link rel="stylesheet" href="/static/instance_theme.css?v=92">
</head>
<body
+7
View File
@@ -272,6 +272,7 @@ def options_monitor_loop(
profit_ratio: float,
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
target_close_fn: Callable[[str], dict[str, Any]] | None = None,
stale_pending_fn: Callable[[], dict[str, Any]] | None = None,
stop_event: Any = None,
) -> None:
if not enabled:
@@ -306,6 +307,12 @@ def options_monitor_loop(
conn.commit()
finally:
conn.close()
# 平仓限价挂单超时撤单(独立于 DB 事务)
if stale_pending_fn is not None:
try:
stale_pending_fn()
except Exception:
pass
except Exception:
pass
time.sleep(max(5.0, float(poll_seconds)))
+124
View File
@@ -0,0 +1,124 @@
"""期权限价挂单:展示 enrichment + 超时自动撤单."""
from __future__ import annotations
import time
from typing import Any
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def order_age_seconds(order: dict[str, Any], *, now_ms: float | None = None) -> float | None:
"""根据交易所 cTime(ms) 估算挂单时长(秒)."""
ct = _safe_float(order.get("c_time") or order.get("cTime"))
if ct is None or ct <= 0:
return None
# OKX 一般为毫秒时间戳
if ct < 1e12:
ct *= 1000.0
now = float(now_ms if now_ms is not None else time.time() * 1000.0)
age = (now - ct) / 1000.0
return age if age >= 0 else 0.0
def is_close_pending_order(order: dict[str, Any]) -> bool:
"""平仓向限价挂单:卖出 / reduceOnly."""
side = str(order.get("side") or "").lower()
if side == "sell":
return True
return bool(order.get("reduce_only"))
def enrich_pending_orders(
orders: list[dict[str, Any]] | None,
*,
ttl_seconds: float = 600.0,
now_ms: float | None = None,
) -> list[dict[str, Any]]:
"""为 UI 附加挂单时长与自动撤倒计时."""
ttl = max(0.0, float(ttl_seconds or 0))
now = float(now_ms if now_ms is not None else time.time() * 1000.0)
out: list[dict[str, Any]] = []
for raw in orders or []:
o = dict(raw)
age = order_age_seconds(o, now_ms=now)
is_close = is_close_pending_order(o)
o["age_sec"] = round(age, 1) if age is not None else None
o["is_close_order"] = is_close
o["auto_cancel_enabled"] = bool(is_close and ttl > 0)
if age is not None and is_close and ttl > 0:
remain = max(0.0, ttl - age)
o["ttl_seconds"] = ttl
o["expire_in_sec"] = round(remain, 1)
o["stale"] = remain <= 0
else:
o["ttl_seconds"] = ttl if is_close else None
o["expire_in_sec"] = None
o["stale"] = False
out.append(o)
return out
def cancel_stale_close_pending_orders(
*,
fetch_pending: Any,
cancel_order: Any,
ttl_seconds: float = 600.0,
now_ms: float | None = None,
ex: Any = None,
) -> dict[str, Any]:
"""
平仓限价挂单超过 ttl 自动撤销.
fetch_pending(ex) -> list; cancel_order(ex, inst_id=..., ord_id=...).
"""
ttl = float(ttl_seconds or 0)
if ttl <= 0:
return {"ok": True, "cancelled": 0, "checked": 0, "skipped": "ttl_disabled"}
try:
orders = fetch_pending(ex) if ex is not None else fetch_pending()
except TypeError:
orders = fetch_pending(ex)
except Exception as e:
return {"ok": False, "msg": str(e), "cancelled": 0, "checked": 0}
enriched = enrich_pending_orders(orders or [], ttl_seconds=ttl, now_ms=now_ms)
cancelled: list[dict[str, Any]] = []
errors: list[str] = []
checked = 0
for o in enriched:
if not o.get("is_close_order"):
continue
checked += 1
if not o.get("stale"):
continue
inst = str(o.get("inst_id") or "").strip()
oid = str(o.get("ord_id") or "").strip()
if not inst or not oid:
continue
try:
if ex is not None:
res = cancel_order(ex, inst_id=inst, ord_id=oid)
else:
res = cancel_order(inst_id=inst, ord_id=oid)
except TypeError:
res = cancel_order(ex, inst_id=inst, ord_id=oid)
except Exception as e:
errors.append(f"{oid}:{e}")
continue
if res.get("ok"):
cancelled.append({"inst_id": inst, "ord_id": oid, "age_sec": o.get("age_sec")})
else:
errors.append(f"{oid}:{res.get('msg') or 'cancel_failed'}")
return {
"ok": True,
"cancelled": len(cancelled),
"checked": checked,
"orders": cancelled,
"errors": errors,
"ttl_seconds": ttl,
}
+52 -1
View File
@@ -107,6 +107,8 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(),
# 市价平仓已硬关闭(忽略 env),仅买一限价
"allow_market_close": False,
# 平仓限价挂单超时自动撤单(秒);默认 600=10 分钟,联调可设 60
"pending_ttl_seconds": _env_float("OKX_OPTIONS_PENDING_TTL_SECONDS", 600.0),
"profit_ratio": _env_float("OKX_OPTIONS_PROFIT_ALERT_RATIO", 1.0),
"poll_seconds": _env_float("OKX_OPTIONS_POLL_SECONDS", 15.0),
"account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "OKX期权").strip(),
@@ -586,7 +588,18 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
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)})
from lib.options.options_pending_lib import enrich_pending_orders
ttl = float(cfg.get("pending_ttl_seconds") or 600.0)
enriched = enrich_pending_orders(orders, ttl_seconds=ttl)
return jsonify(
{
"ok": True,
"orders": enriched,
"count": len(enriched),
"pending_ttl_seconds": ttl,
}
)
@app.route("/api/options/orders/cancel", methods=["POST"])
@lr
@@ -1079,6 +1092,43 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
pass
return result
def _stale_pending() -> dict[str, Any]:
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
from lib.options.options_pending_lib import cancel_stale_close_pending_orders
ex = cfg.get("exchange_options")
if ex is None:
return {"ok": False, "msg": "期权 exchange 未就绪"}
ttl = float(cfg.get("pending_ttl_seconds") or 600.0)
out = cancel_stale_close_pending_orders(
fetch_pending=lambda _ex: cfg["fetch_option_pending_orders"](_ex),
cancel_order=lambda _ex, inst_id, ord_id: cfg["cancel_option_order"](
_ex, inst_id=inst_id, ord_id=ord_id
),
ttl_seconds=ttl,
ex=ex,
)
if out.get("cancelled"):
try:
invalidate_option_positions_cache()
except Exception:
pass
try:
send = cfg.get("send_wechat")
if callable(send):
parts = [
"【OKX期权·挂单超时撤销】",
f"账户:{cfg.get('account_label') or 'OKX期权'}",
f"超时:{ttl:g}s",
f"撤销:{out.get('cancelled')}",
]
for o in out.get("orders") or []:
parts.append(f"- {o.get('inst_id')} #{o.get('ord_id')}")
send("\n".join(parts))
except Exception:
pass
return out
t = threading.Thread(
target=options_monitor_loop,
kwargs={
@@ -1092,6 +1142,7 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
"profit_ratio": cfg["profit_ratio"],
"sync_trades_fn": _sync,
"target_close_fn": _target_close,
"stale_pending_fn": _stale_pending,
},
daemon=True,
name="options-monitor",
+2 -1
View File
@@ -107,6 +107,7 @@
<h4 class="opt-order-pending-title">委托</h4>
<button type="button" class="btn-secondary" id="opt-pending-refresh">刷新</button>
</div>
<p class="muted opt-pending-ttl-hint" id="opt-pending-ttl-hint">平仓限价超 10 分未成交将自动撤销</p>
<div id="opt-pending-list" class="opt-pending-list">
<div class="muted opt-pending-empty">暂无未成交委托</div>
</div>
@@ -272,4 +273,4 @@
</div>
</div>
<script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=30"></script>
<script src="/static/options_panel.js?v=31"></script>