From cbb7f954f5864c54658677b4d7e9cccfda898fba Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 15 Jul 2026 22:06:24 +0800 Subject: [PATCH] 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 --- docs/对冲计划开发方案.md | 1 + docs/期权开平仓与监控说明.md | 16 +-- docs/期权方案.md | 2 + lib/common/static/instance_theme.css | 5 + lib/common/static/options_panel.js | 43 +++++++- lib/instance/templates/embed_shell.html | 2 +- lib/instance/templates/index.html | 2 +- lib/options/options_monitor_lib.py | 7 ++ lib/options/options_pending_lib.py | 124 +++++++++++++++++++++++ lib/options/options_register.py | 53 +++++++++- lib/options/templates/options_panel.html | 3 +- tests/test_options_pending_lib.py | 63 ++++++++++++ 12 files changed, 306 insertions(+), 15 deletions(-) create mode 100644 lib/options/options_pending_lib.py create mode 100644 tests/test_options_pending_lib.py diff --git a/docs/对冲计划开发方案.md b/docs/对冲计划开发方案.md index ac2b012..b8d2e39 100644 --- a/docs/对冲计划开发方案.md +++ b/docs/对冲计划开发方案.md @@ -163,6 +163,7 @@ | 分批 | 买一不够则剩余下一轮再平再锁新买一 | | 有效流动性 | 残档买一禁止按买盘平 | | 2× 门控 | 目标位/自动类路径首次需可回收≥2×权利金并持续 hold;手动买一平只验流动性 | +| 平仓挂单 TTL | 卖出限价超 `OKX_OPTIONS_PENDING_TTL_SECONDS`(默认 10 分钟)自动撤;UI「委托」可见 | 完整说明(可单独打开):**[期权开平仓与监控说明.md](./期权开平仓与监控说明.md)** · 线上 `/options/guide`. diff --git a/docs/期权开平仓与监控说明.md b/docs/期权开平仓与监控说明.md index c072f94..1252cd4 100644 --- a/docs/期权开平仓与监控说明.md +++ b/docs/期权开平仓与监控说明.md @@ -27,6 +27,7 @@ 2. **只锁买一**:本轮张数 = `min(持仓, 买一深度)`,限价 = 校验通过当刻的买一价. 3. **不吃买二及以下**;买一不够则只平本轮能吃掉的部分,**剩余仓位保留**,下次再平再锁新的买一. 4. 全程 `reduceOnly` 限价卖. +5. **平仓限价挂单超时自动撤**:卖出/平仓委托未成交超过默认 **10 分钟**(`OKX_OPTIONS_PENDING_TTL_SECONDS`,默认 600)由监控自动撤销,并可微信通知;UI「委托」面板实时展示挂单与剩余自动撤倒计时. 示例:持仓 300、买一深度 200 → 本轮只平 200;剩 100 等下次「买一平仓」或目标位再次触发. @@ -52,6 +53,8 @@ |------|------| | 持仓 / 买盘预览 | 轮询刷新;净盈亏按**本轮买一可回收 − 权利金** | | 残档买一 | 买一 ≪ 标记/内在价值(默认 < 30%) → 禁止按买盘平,UI 显示无效 | +| 未成交委托 | 期权下单区右侧「委托」列表展示开/平仓限价单,可手动撤销;页面轮询刷新 | +| 平仓挂单超时 | 卖出平仓限价超 TTL 未成交 → 自动撤单(默认 10 分钟) | | 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) | | 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次 | | 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) | @@ -71,12 +74,13 @@ ## 5. 风险点 -1. **流动性不足**:只平买一深度,大仓位可能多次才能平完;若买一突然撤单,限价可能挂而不成交. -2. **残档假买一**:若未拦住残档会严重贱卖 — 系统用标记/内在价值比例拦截,但不等于保证最优成交价. -3. **权利金沉没**:手动可在未达 2× 时平仓,可能主动止损或提前锁利不及预期. -4. **无市价强平**:盘口真空时系统**不会**市价砸盘,仓位可能留到到期. -5. **目标位只看指数**:触达后仍受买一/2×门控约束,可能「到价却平不掉」. -6. **对冲计划腿**:期权腿退出规则见对冲方案;独立期权页平仓勿与计划状态脱节. +1. **流动性不足**:只平买一深度,大仓位可能多次才能平完;若买一突然撤档,限价可能挂着 — 超 TTL 会自动撤,之后需再次点平或等目标触发. +2. **开仓挂单**:买入委托不在超时自动撤范围(仅平仓卖单);可在「委托」面板手动撤销. +3. **残档假买一**:若未拦住残档会严重贱卖 — 系统用标记/内在价值比例拦截,但不等于保证最优成交价. +4. **权利金沉没**:手动可在未达 2× 时平仓,可能主动止损或提前锁利不及预期. +5. **无市价强平**:盘口真空时系统**不会**市价砸盘,仓位可能留到到期. +6. **目标位只看指数**:触达后仍受买一/2×门控约束,可能「到价却平不掉」. +7. **对冲计划腿**:期权腿退出规则见对冲方案;独立期权页平仓勿与计划状态脱节. --- diff --git a/docs/期权方案.md b/docs/期权方案.md index f56c11f..c2bb51f 100644 --- a/docs/期权方案.md +++ b/docs/期权方案.md @@ -96,6 +96,8 @@ OKX_OPTIONS_TD_MODE=cross # OKX_OPTIONS_ALLOW_MARKET_CLOSE=false OKX_OPTIONS_CLOSE_RECYCLE_MULT=2 OKX_OPTIONS_CLOSE_HOLD_SECONDS=120 +# 平仓限价挂单超时自动撤(秒),默认 600=10 分钟;联调可临时改 60 +OKX_OPTIONS_PENDING_TTL_SECONDS=600 ``` 平仓执行:**只锁买一限价**,说明见 [期权开平仓与监控说明.md](./期权开平仓与监控说明.md);线上 `/options/guide`. diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 566b64b..d296291 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -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; diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 0180529..16a6124 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -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 ( '
' + '
' + - '' + (o.side_label || side || "—") + "" + + '' + kind + " · " + (o.side_label || side || "—") + "" + '' + "
" + @@ -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 + "
" ); }).join(""); @@ -167,7 +199,7 @@ host.innerHTML = '
' + (d.msg || "获取委托失败") + "
"; return; } - paintPendingOrders(d.orders || []); + paintPendingOrders(d.orders || [], d.pending_ttl_seconds); } catch (e) { host.innerHTML = '
获取委托失败
'; } @@ -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 && diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index 514fe0d..9d355b9 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -7,7 +7,7 @@ - + {{ exchange_display }} · 加密货币 | 交易监控复盘系统 diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index db5f71b..695e7ff 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -17,7 +17,7 @@ {{ exchange_display }} · 加密货币 | 交易监控复盘系统 - + 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))) diff --git a/lib/options/options_pending_lib.py b/lib/options/options_pending_lib.py new file mode 100644 index 0000000..392df02 --- /dev/null +++ b/lib/options/options_pending_lib.py @@ -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, + } diff --git a/lib/options/options_register.py b/lib/options/options_register.py index c94a40a..b248c79 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -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", diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 864f9ff..5375c2f 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -107,6 +107,7 @@

委托

+

平仓限价超 10 分未成交将自动撤销

暂无未成交委托
@@ -272,4 +273,4 @@ - + diff --git a/tests/test_options_pending_lib.py b/tests/test_options_pending_lib.py new file mode 100644 index 0000000..d393ddd --- /dev/null +++ b/tests/test_options_pending_lib.py @@ -0,0 +1,63 @@ +"""期权挂单超时撤单单测.""" +from unittest import TestCase + +from lib.options.options_pending_lib import ( + cancel_stale_close_pending_orders, + enrich_pending_orders, + is_close_pending_order, + order_age_seconds, +) + + +class OptionsPendingLibTests(TestCase): + def test_order_age_and_close_detect(self): + now = 1_700_000_600_000 + age = order_age_seconds({"c_time": now - 90_000}, now_ms=now) + self.assertAlmostEqual(age, 90.0, places=3) + self.assertTrue(is_close_pending_order({"side": "sell"})) + self.assertTrue(is_close_pending_order({"side": "buy", "reduce_only": True})) + self.assertFalse(is_close_pending_order({"side": "buy"})) + + def test_enrich_expire(self): + now = 1_700_000_600_000 + rows = enrich_pending_orders( + [ + {"ord_id": "1", "inst_id": "A", "side": "sell", "c_time": now - 700_000}, + {"ord_id": "2", "inst_id": "B", "side": "buy", "c_time": now - 700_000}, + {"ord_id": "3", "inst_id": "C", "side": "sell", "c_time": now - 30_000}, + ], + ttl_seconds=600, + now_ms=now, + ) + by_id = {r["ord_id"]: r for r in rows} + self.assertTrue(by_id["1"]["stale"]) + self.assertTrue(by_id["1"]["auto_cancel_enabled"]) + self.assertFalse(by_id["2"]["auto_cancel_enabled"]) + self.assertFalse(by_id["3"]["stale"]) + self.assertAlmostEqual(by_id["3"]["expire_in_sec"], 570.0, places=0) + + def test_cancel_stale_only_close(self): + now = 1_700_000_600_000 + pending = [ + {"ord_id": "s1", "inst_id": "A", "side": "sell", "c_time": now - 700_000}, + {"ord_id": "b1", "inst_id": "B", "side": "buy", "c_time": now - 700_000}, + {"ord_id": "s2", "inst_id": "C", "side": "sell", "c_time": now - 10_000}, + ] + cancelled = [] + + def fetch(_ex=None): + return pending + + def cancel(_ex=None, inst_id=None, ord_id=None): + cancelled.append((inst_id, ord_id)) + return {"ok": True} + + out = cancel_stale_close_pending_orders( + fetch_pending=fetch, + cancel_order=cancel, + ttl_seconds=60, + now_ms=now, + ex=object(), + ) + self.assertEqual(out["cancelled"], 1) + self.assertEqual(cancelled, [("A", "s1")])