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:
@@ -163,6 +163,7 @@
|
|||||||
| 分批 | 买一不够则剩余下一轮再平再锁新买一 |
|
| 分批 | 买一不够则剩余下一轮再平再锁新买一 |
|
||||||
| 有效流动性 | 残档买一禁止按买盘平 |
|
| 有效流动性 | 残档买一禁止按买盘平 |
|
||||||
| 2× 门控 | 目标位/自动类路径首次需可回收≥2×权利金并持续 hold;手动买一平只验流动性 |
|
| 2× 门控 | 目标位/自动类路径首次需可回收≥2×权利金并持续 hold;手动买一平只验流动性 |
|
||||||
|
| 平仓挂单 TTL | 卖出限价超 `OKX_OPTIONS_PENDING_TTL_SECONDS`(默认 10 分钟)自动撤;UI「委托」可见 |
|
||||||
|
|
||||||
完整说明(可单独打开):**[期权开平仓与监控说明.md](./期权开平仓与监控说明.md)** · 线上 `/options/guide`.
|
完整说明(可单独打开):**[期权开平仓与监控说明.md](./期权开平仓与监控说明.md)** · 线上 `/options/guide`.
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -27,6 +27,7 @@
|
|||||||
2. **只锁买一**:本轮张数 = `min(持仓, 买一深度)`,限价 = 校验通过当刻的买一价.
|
2. **只锁买一**:本轮张数 = `min(持仓, 买一深度)`,限价 = 校验通过当刻的买一价.
|
||||||
3. **不吃买二及以下**;买一不够则只平本轮能吃掉的部分,**剩余仓位保留**,下次再平再锁新的买一.
|
3. **不吃买二及以下**;买一不够则只平本轮能吃掉的部分,**剩余仓位保留**,下次再平再锁新的买一.
|
||||||
4. 全程 `reduceOnly` 限价卖.
|
4. 全程 `reduceOnly` 限价卖.
|
||||||
|
5. **平仓限价挂单超时自动撤**:卖出/平仓委托未成交超过默认 **10 分钟**(`OKX_OPTIONS_PENDING_TTL_SECONDS`,默认 600)由监控自动撤销,并可微信通知;UI「委托」面板实时展示挂单与剩余自动撤倒计时.
|
||||||
|
|
||||||
示例:持仓 300、买一深度 200 → 本轮只平 200;剩 100 等下次「买一平仓」或目标位再次触发.
|
示例:持仓 300、买一深度 200 → 本轮只平 200;剩 100 等下次「买一平仓」或目标位再次触发.
|
||||||
|
|
||||||
@@ -52,6 +53,8 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 持仓 / 买盘预览 | 轮询刷新;净盈亏按**本轮买一可回收 − 权利金** |
|
| 持仓 / 买盘预览 | 轮询刷新;净盈亏按**本轮买一可回收 − 权利金** |
|
||||||
| 残档买一 | 买一 ≪ 标记/内在价值(默认 < 30%) → 禁止按买盘平,UI 显示无效 |
|
| 残档买一 | 买一 ≪ 标记/内在价值(默认 < 30%) → 禁止按买盘平,UI 显示无效 |
|
||||||
|
| 未成交委托 | 期权下单区右侧「委托」列表展示开/平仓限价单,可手动撤销;页面轮询刷新 |
|
||||||
|
| 平仓挂单超时 | 卖出平仓限价超 TTL 未成交 → 自动撤单(默认 10 分钟) |
|
||||||
| 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) |
|
| 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) |
|
||||||
| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次 |
|
| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次 |
|
||||||
| 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) |
|
| 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) |
|
||||||
@@ -71,12 +74,13 @@
|
|||||||
|
|
||||||
## 5. 风险点
|
## 5. 风险点
|
||||||
|
|
||||||
1. **流动性不足**:只平买一深度,大仓位可能多次才能平完;若买一突然撤单,限价可能挂而不成交.
|
1. **流动性不足**:只平买一深度,大仓位可能多次才能平完;若买一突然撤档,限价可能挂着 — 超 TTL 会自动撤,之后需再次点平或等目标触发.
|
||||||
2. **残档假买一**:若未拦住残档会严重贱卖 — 系统用标记/内在价值比例拦截,但不等于保证最优成交价.
|
2. **开仓挂单**:买入委托不在超时自动撤范围(仅平仓卖单);可在「委托」面板手动撤销.
|
||||||
3. **权利金沉没**:手动可在未达 2× 时平仓,可能主动止损或提前锁利不及预期.
|
3. **残档假买一**:若未拦住残档会严重贱卖 — 系统用标记/内在价值比例拦截,但不等于保证最优成交价.
|
||||||
4. **无市价强平**:盘口真空时系统**不会**市价砸盘,仓位可能留到到期.
|
4. **权利金沉没**:手动可在未达 2× 时平仓,可能主动止损或提前锁利不及预期.
|
||||||
5. **目标位只看指数**:触达后仍受买一/2×门控约束,可能「到价却平不掉」.
|
5. **无市价强平**:盘口真空时系统**不会**市价砸盘,仓位可能留到到期.
|
||||||
6. **对冲计划腿**:期权腿退出规则见对冲方案;独立期权页平仓勿与计划状态脱节.
|
6. **目标位只看指数**:触达后仍受买一/2×门控约束,可能「到价却平不掉」.
|
||||||
|
7. **对冲计划腿**:期权腿退出规则见对冲方案;独立期权页平仓勿与计划状态脱节.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ OKX_OPTIONS_TD_MODE=cross
|
|||||||
# OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
|
# OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
|
||||||
OKX_OPTIONS_CLOSE_RECYCLE_MULT=2
|
OKX_OPTIONS_CLOSE_RECYCLE_MULT=2
|
||||||
OKX_OPTIONS_CLOSE_HOLD_SECONDS=120
|
OKX_OPTIONS_CLOSE_HOLD_SECONDS=120
|
||||||
|
# 平仓限价挂单超时自动撤(秒),默认 600=10 分钟;联调可临时改 60
|
||||||
|
OKX_OPTIONS_PENDING_TTL_SECONDS=600
|
||||||
```
|
```
|
||||||
|
|
||||||
平仓执行:**只锁买一限价**,说明见 [期权开平仓与监控说明.md](./期权开平仓与监控说明.md);线上 `/options/guide`.
|
平仓执行:**只锁买一限价**,说明见 [期权开平仓与监控说明.md](./期权开平仓与监控说明.md);线上 `/options/guide`.
|
||||||
|
|||||||
@@ -3669,6 +3669,11 @@ html[data-theme="light"] .options-strike-table--t .opt-strike-row-atm td {
|
|||||||
color: #9ec0ff;
|
color: #9ec0ff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
.opt-pending-ttl-hint {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
.opt-order-pending-head .btn-secondary {
|
.opt-order-pending-head .btn-secondary {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
let positionsRefreshSeq = 0;
|
let positionsRefreshSeq = 0;
|
||||||
let refreshAllTimer = null;
|
let refreshAllTimer = null;
|
||||||
let pendingRefreshTimer = null;
|
let pendingRefreshTimer = null;
|
||||||
|
let pendingTtlSeconds = 600;
|
||||||
const POSITIONS_STALE_MS = 45000;
|
const POSITIONS_STALE_MS = 45000;
|
||||||
const PENDING_POLL_MS = 8000;
|
const PENDING_POLL_MS = 8000;
|
||||||
|
|
||||||
@@ -123,8 +124,30 @@
|
|||||||
startPendingOrdersPoll();
|
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 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;
|
if (!host) return;
|
||||||
const rows = Array.isArray(orders) ? orders : [];
|
const rows = Array.isArray(orders) ? orders : [];
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
@@ -136,10 +159,17 @@
|
|||||||
const sideCls = side === "buy" ? "is-buy" : side === "sell" ? "is-sell" : "";
|
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 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 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 (
|
return (
|
||||||
'<div class="opt-pending-item" data-ord="' + (o.ord_id || "") + '" data-inst="' + (o.inst_id || "") + '">' +
|
'<div class="opt-pending-item" data-ord="' + (o.ord_id || "") + '" data-inst="' + (o.inst_id || "") + '">' +
|
||||||
'<div class="opt-pending-item-top">' +
|
'<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 || "") +
|
'<button type="button" class="btn-secondary opt-pending-cancel" data-ord="' + (o.ord_id || "") +
|
||||||
'" data-inst="' + (o.inst_id || "") + '">撤销</button>' +
|
'" data-inst="' + (o.inst_id || "") + '">撤销</button>' +
|
||||||
"</div>" +
|
"</div>" +
|
||||||
@@ -148,6 +178,8 @@
|
|||||||
" · 张数 " + (o.sz != null ? o.sz : "—") +
|
" · 张数 " + (o.sz != null ? o.sz : "—") +
|
||||||
(o.fill_sz != null && Number(o.fill_sz) > 0 ? " · 已成 " + o.fill_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 : "") +
|
(remain != null && o.fill_sz != null && Number(o.fill_sz) > 0 ? " · 剩余 " + remain : "") +
|
||||||
|
(ageTxt ? " · " + ageTxt : "") +
|
||||||
|
ttlTxt +
|
||||||
"</div></div>"
|
"</div></div>"
|
||||||
);
|
);
|
||||||
}).join("");
|
}).join("");
|
||||||
@@ -167,7 +199,7 @@
|
|||||||
host.innerHTML = '<div class="muted opt-pending-empty">' + (d.msg || "获取委托失败") + "</div>";
|
host.innerHTML = '<div class="muted opt-pending-empty">' + (d.msg || "获取委托失败") + "</div>";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
paintPendingOrders(d.orders || []);
|
paintPendingOrders(d.orders || [], d.pending_ttl_seconds);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
host.innerHTML = '<div class="muted opt-pending-empty">获取委托失败</div>';
|
host.innerHTML = '<div class="muted opt-pending-empty">获取委托失败</div>';
|
||||||
}
|
}
|
||||||
@@ -176,8 +208,7 @@
|
|||||||
function startPendingOrdersPoll() {
|
function startPendingOrdersPoll() {
|
||||||
stopPendingOrdersPoll();
|
stopPendingOrdersPoll();
|
||||||
pendingRefreshTimer = setInterval(function () {
|
pendingRefreshTimer = setInterval(function () {
|
||||||
const panel = orderPanel();
|
if (!document.getElementById("options-root")) {
|
||||||
if (!panel || panel.style.display === "none") {
|
|
||||||
stopPendingOrdersPoll();
|
stopPendingOrdersPoll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1709,6 +1740,8 @@
|
|||||||
syncMoneyFilterButtons();
|
syncMoneyFilterButtons();
|
||||||
syncChainViewUI();
|
syncChainViewUI();
|
||||||
updateUnderlyingLabel();
|
updateUnderlyingLabel();
|
||||||
|
refreshPendingOrders();
|
||||||
|
startPendingOrdersPoll();
|
||||||
const hasCache =
|
const hasCache =
|
||||||
panelCache.chain &&
|
panelCache.chain &&
|
||||||
panelCache.underlying === state.underlying &&
|
panelCache.underlying === state.underlying &&
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
<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/account_risk_badge.css?v=4">
|
||||||
<link rel="stylesheet" href="/static/instance_page.css?v=6">
|
<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>
|
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||||
<meta name="theme-color" content="#0b0d14">
|
<meta name="theme-color" content="#0b0d14">
|
||||||
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
|
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<link rel="manifest" href="/static/icons/manifest.webmanifest">
|
<link rel="manifest" href="/static/icons/manifest.webmanifest">
|
||||||
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
|
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
|
||||||
<link rel="stylesheet" href="/static/instance_page.css?v=3">
|
<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>
|
</head>
|
||||||
<body
|
<body
|
||||||
|
|||||||
@@ -272,6 +272,7 @@ def options_monitor_loop(
|
|||||||
profit_ratio: float,
|
profit_ratio: float,
|
||||||
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
|
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
|
||||||
target_close_fn: Callable[[str], dict[str, Any]] | 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,
|
stop_event: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not enabled:
|
if not enabled:
|
||||||
@@ -306,6 +307,12 @@ def options_monitor_loop(
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
# 平仓限价挂单超时撤单(独立于 DB 事务)
|
||||||
|
if stale_pending_fn is not None:
|
||||||
|
try:
|
||||||
|
stale_pending_fn()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
time.sleep(max(5.0, float(poll_seconds)))
|
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(),
|
"td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(),
|
||||||
# 市价平仓已硬关闭(忽略 env),仅买一限价
|
# 市价平仓已硬关闭(忽略 env),仅买一限价
|
||||||
"allow_market_close": False,
|
"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),
|
"profit_ratio": _env_float("OKX_OPTIONS_PROFIT_ALERT_RATIO", 1.0),
|
||||||
"poll_seconds": _env_float("OKX_OPTIONS_POLL_SECONDS", 15.0),
|
"poll_seconds": _env_float("OKX_OPTIONS_POLL_SECONDS", 15.0),
|
||||||
"account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "OKX期权").strip(),
|
"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)
|
orders = cfg["fetch_option_pending_orders"](ex, inst_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"ok": False, "msg": f"获取委托失败: {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"])
|
@app.route("/api/options/orders/cancel", methods=["POST"])
|
||||||
@lr
|
@lr
|
||||||
@@ -1079,6 +1092,43 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
pass
|
pass
|
||||||
return result
|
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(
|
t = threading.Thread(
|
||||||
target=options_monitor_loop,
|
target=options_monitor_loop,
|
||||||
kwargs={
|
kwargs={
|
||||||
@@ -1092,6 +1142,7 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
"profit_ratio": cfg["profit_ratio"],
|
"profit_ratio": cfg["profit_ratio"],
|
||||||
"sync_trades_fn": _sync,
|
"sync_trades_fn": _sync,
|
||||||
"target_close_fn": _target_close,
|
"target_close_fn": _target_close,
|
||||||
|
"stale_pending_fn": _stale_pending,
|
||||||
},
|
},
|
||||||
daemon=True,
|
daemon=True,
|
||||||
name="options-monitor",
|
name="options-monitor",
|
||||||
|
|||||||
@@ -107,6 +107,7 @@
|
|||||||
<h4 class="opt-order-pending-title">委托</h4>
|
<h4 class="opt-order-pending-title">委托</h4>
|
||||||
<button type="button" class="btn-secondary" id="opt-pending-refresh">刷新</button>
|
<button type="button" class="btn-secondary" id="opt-pending-refresh">刷新</button>
|
||||||
</div>
|
</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 id="opt-pending-list" class="opt-pending-list">
|
||||||
<div class="muted opt-pending-empty">暂无未成交委托</div>
|
<div class="muted opt-pending-empty">暂无未成交委托</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,4 +273,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
<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>
|
||||||
|
|||||||
@@ -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")])
|
||||||
Reference in New Issue
Block a user