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:
@@ -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)))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user