diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example
index 8d3ebba..52ef306 100644
--- a/crypto_monitor_okx/.env.example
+++ b/crypto_monitor_okx/.env.example
@@ -122,6 +122,8 @@ OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
OKX_OPTIONS_POLL_SECONDS=15
OKX_OPTIONS_TD_MODE=isolated
OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
+# 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败
+OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12
# =============================================================================
# 对冲计划(仅 OKX;前端 env「对冲计划」;详见 docs/对冲计划开发方案.md)
diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js
index 9e4f9d8..1864b86 100644
--- a/lib/common/static/hedge_plan.js
+++ b/lib/common/static/hedge_plan.js
@@ -1331,7 +1331,7 @@
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
- alert("补开成功 #" + (d.plan_id || planId) + " · 已进入进行中");
+ alert("补开成功 #" + (d.plan_id || planId) + " · 已完全成交并进入进行中");
void loadActivePlans();
void loadGates();
} catch (e) {
diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py
index 7f58ec8..58575a0 100644
--- a/lib/exchange/okx_options_lib.py
+++ b/lib/exchange/okx_options_lib.py
@@ -962,6 +962,119 @@ def cancel_option_order(ex: ccxt.okx, *, inst_id: str, ord_id: str) -> dict[str,
return {"ok": False, "msg": _okx_trade_error_message(e)}
+def fetch_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_get_trade_order({"instId": inst_id, "ordId": ord_id})
+ data = (resp or {}).get("data") or []
+ if not data or not isinstance(data[0], dict):
+ return {"ok": False, "msg": "订单不存在或暂不可查", "raw": resp}
+ o = data[0]
+ sz = _safe_float(o.get("sz"))
+ acc = _safe_float(o.get("accFillSz"))
+ if acc is None:
+ acc = _safe_float(o.get("fillSz")) or 0.0
+ avg = _safe_float(o.get("avgPx"))
+ fill_px = _safe_float(o.get("fillPx"))
+ if avg is None or avg <= 0:
+ avg = fill_px
+ state = str(o.get("state") or "").strip().lower()
+ return {
+ "ok": True,
+ "ord_id": str(o.get("ordId") or ord_id),
+ "inst_id": str(o.get("instId") or inst_id),
+ "state": state,
+ "sz": int(sz) if sz is not None else None,
+ "acc_fill_sz": float(acc or 0),
+ "avg_px": avg,
+ "side": str(o.get("side") or "").lower(),
+ "ord_type": str(o.get("ordType") or ""),
+ "raw": o,
+ }
+ except Exception as e:
+ return {"ok": False, "msg": _okx_trade_error_message(e)}
+
+
+def wait_option_order_full_fill(
+ ex: ccxt.okx,
+ *,
+ inst_id: str,
+ ord_id: str,
+ need_sheets: int,
+ timeout_sec: float = 12.0,
+ poll_sec: float = 0.35,
+ cancel_on_timeout: bool = True,
+) -> dict[str, Any]:
+ """轮询至完全成交;超时则撤单.未完全成交返回 ok=False."""
+ need = max(1, int(need_sheets))
+ deadline = time.time() + max(0.5, float(timeout_sec))
+ last: dict[str, Any] = {}
+ while time.time() < deadline:
+ last = fetch_option_order(ex, inst_id=inst_id, ord_id=ord_id)
+ if not last.get("ok"):
+ time.sleep(max(0.15, float(poll_sec)))
+ continue
+ acc = float(last.get("acc_fill_sz") or 0)
+ state = str(last.get("state") or "")
+ if acc + 1e-9 >= need or state == "filled":
+ if acc + 1e-9 < need:
+ return {
+ "ok": False,
+ "msg": f"订单已结束但成交不足 {need} 张(已成 {acc:g})",
+ "filled_sheets": acc,
+ "order": last,
+ }
+ return {
+ "ok": True,
+ "filled_sheets": int(round(acc)),
+ "avg_px": last.get("avg_px"),
+ "state": state,
+ "order": last,
+ }
+ if state in ("canceled", "cancelled", "mmp_canceled"):
+ if acc + 1e-9 >= need:
+ return {
+ "ok": True,
+ "filled_sheets": int(round(acc)),
+ "avg_px": last.get("avg_px"),
+ "state": state,
+ "order": last,
+ }
+ return {
+ "ok": False,
+ "msg": f"订单已撤销且未完全成交(已成 {acc:g}/{need})",
+ "filled_sheets": acc,
+ "order": last,
+ }
+ time.sleep(max(0.15, float(poll_sec)))
+
+ if cancel_on_timeout:
+ cancel_option_order(ex, inst_id=inst_id, ord_id=ord_id)
+ time.sleep(0.25)
+ last = fetch_option_order(ex, inst_id=inst_id, ord_id=ord_id)
+ acc = float((last or {}).get("acc_fill_sz") or 0) if (last or {}).get("ok") else 0.0
+ if acc + 1e-9 >= need:
+ return {
+ "ok": True,
+ "filled_sheets": int(round(acc)),
+ "avg_px": (last or {}).get("avg_px"),
+ "state": (last or {}).get("state"),
+ "order": last,
+ "timed_out": True,
+ }
+ return {
+ "ok": False,
+ "msg": f"等待成交超时({float(timeout_sec):g}s),已撤未成交部分;已成 {acc:g}/{need}",
+ "filled_sheets": acc,
+ "order": last,
+ "timed_out": True,
+ }
+
+
def place_option_limit_order(
ex: ccxt.okx,
*,
@@ -973,12 +1086,16 @@ def place_option_limit_order(
tick_sz: Any = None,
reduce_only: bool = False,
pos_side: str | None = None,
+ ord_type: str = "limit",
) -> dict[str, Any]:
side_l = (side or "").lower()
if side_l not in ("buy", "sell"):
return {"ok": False, "msg": "side 必须为 buy 或 sell"}
if sheets < 1:
return {"ok": False, "msg": "张数至少为 1"}
+ ot = (ord_type or "limit").strip().lower()
+ if ot not in ("limit", "ioc", "fok", "post_only"):
+ return {"ok": False, "msg": f"不支持的 ordType: {ord_type}"}
px = round_option_px(float(price), tick_sz, side_l)
if px <= 0:
return {"ok": False, "msg": "价格无效"}
@@ -986,7 +1103,7 @@ def place_option_limit_order(
"instId": inst_id,
"tdMode": td_mode,
"side": side_l,
- "ordType": "limit",
+ "ordType": ot,
"px": format_option_px(px, tick_sz),
"sz": str(int(sheets)),
}
@@ -998,7 +1115,7 @@ def place_option_limit_order(
resp = ex.private_post_trade_order(body)
data = (resp or {}).get("data") or []
if data and str(data[0].get("sCode")) == "0":
- return {"ok": True, "data": data[0], "raw": resp, "px": px}
+ return {"ok": True, "data": data[0], "raw": resp, "px": px, "ord_type": ot}
return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp, "px": px}
except Exception as e:
return {"ok": False, "msg": _okx_trade_error_message(e), "px": px}
diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py
index 81fc2fa..0eefb92 100644
--- a/lib/hedge_plan/hedge_plan_orders_lib.py
+++ b/lib/hedge_plan/hedge_plan_orders_lib.py
@@ -82,6 +82,13 @@ def build_oo_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]:
]
+def _option_open_fill_timeout_sec() -> float:
+ try:
+ return max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12"))
+ except (TypeError, ValueError):
+ return 12.0
+
+
def _buy_option(
cfg: dict[str, Any],
*,
@@ -92,6 +99,7 @@ def _buy_option(
from lib.exchange.okx_options_lib import (
cap_option_buy_sheets_to_ask_depth,
option_buy_liquidity_ok,
+ wait_option_order_full_fill,
)
ex = cfg.get("exchange_options")
@@ -146,6 +154,7 @@ def _buy_option(
td = "isolated"
if callable(td_buy):
td = td_buy(cfg.get("options_td_mode") or "isolated")
+ # IOC:能成交多少成交多少,剩余立即撤销;再校验是否完全成交
order = place_fn(
ex,
inst_id=inst_id,
@@ -154,14 +163,42 @@ def _buy_option(
price=float(ask),
td_mode=td,
tick_sz=q.get("tick_sz"),
+ ord_type="ioc",
)
if not order.get("ok"):
return order
+ ord_id = str((order.get("data") or {}).get("ordId") or "").strip()
+ if not ord_id:
+ return {"ok": False, "msg": "下单成功但未返回订单号", "order": order}
+ fill = wait_option_order_full_fill(
+ ex,
+ inst_id=inst_id,
+ ord_id=ord_id,
+ need_sheets=sheets_i,
+ timeout_sec=_option_open_fill_timeout_sec(),
+ cancel_on_timeout=True,
+ )
+ if not fill.get("ok"):
+ return {
+ "ok": False,
+ "msg": fill.get("msg") or "未完全成交,开仓失败",
+ "inst_id": inst_id,
+ "sheets": sheets_i,
+ "ask": float(ask),
+ "exchange_ord_id": ord_id,
+ "filled_sheets": fill.get("filled_sheets"),
+ "order": order,
+ "fill": fill,
+ "can_open": False,
+ }
+ fill_px = float(fill.get("avg_px") or ask)
+ filled_n = int(fill.get("filled_sheets") or sheets_i)
+ premium = fill_px * filled_n * ct_mult
return {
"ok": True,
"inst_id": inst_id,
- "sheets": sheets_i,
- "ask": float(ask),
+ "sheets": filled_n,
+ "ask": fill_px,
"ask_sz": float(ask_sz),
"premium": premium,
"ct_mult": ct_mult,
@@ -170,8 +207,9 @@ def _buy_option(
"strike": q.get("strike"),
"exp_time": q.get("exp_time"),
"opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"),
- "exchange_ord_id": (order.get("data") or {}).get("ordId"),
+ "exchange_ord_id": ord_id,
"order": order,
+ "fill": fill,
"can_open": True,
}
diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html
index 091ea4f..469ec32 100644
--- a/lib/hedge_plan/templates/hedge_plan_panel.html
+++ b/lib/hedge_plan/templates/hedge_plan_panel.html
@@ -301,4 +301,4 @@
-
+
diff --git a/tests/test_option_order_fill_wait.py b/tests/test_option_order_fill_wait.py
new file mode 100644
index 0000000..5ab8954
--- /dev/null
+++ b/tests/test_option_order_fill_wait.py
@@ -0,0 +1,90 @@
+"""期权开仓:等待完全成交门禁."""
+from __future__ import annotations
+
+from lib.exchange.okx_options_lib import wait_option_order_full_fill
+
+
+class _FakeEx:
+ def __init__(self, sequence: list[dict]):
+ self._seq = list(sequence)
+ self.cancelled = False
+
+ def private_get_trade_order(self, params):
+ if not self._seq:
+ return {"data": []}
+ row = self._seq.pop(0)
+ return {"data": [row]}
+
+ def private_post_trade_cancel_order(self, params):
+ self.cancelled = True
+ return {"data": [{"sCode": "0"}]}
+
+
+def test_wait_fill_success_when_filled():
+ ex = _FakeEx(
+ [
+ {
+ "ordId": "1",
+ "instId": "ETH-USD_UM-260720-1870-C",
+ "state": "live",
+ "sz": "50",
+ "accFillSz": "0",
+ },
+ {
+ "ordId": "1",
+ "instId": "ETH-USD_UM-260720-1870-C",
+ "state": "filled",
+ "sz": "50",
+ "accFillSz": "50",
+ "avgPx": "12.5",
+ },
+ ]
+ )
+ out = wait_option_order_full_fill(
+ ex, # type: ignore[arg-type]
+ inst_id="ETH-USD_UM-260720-1870-C",
+ ord_id="1",
+ need_sheets=50,
+ timeout_sec=2,
+ poll_sec=0.01,
+ )
+ assert out["ok"] is True
+ assert out["filled_sheets"] == 50
+ assert float(out["avg_px"]) == 12.5
+ assert ex.cancelled is False
+
+
+def test_wait_fill_timeout_cancels_and_fails():
+ ex = _FakeEx(
+ [
+ {
+ "ordId": "2",
+ "instId": "ETH-USD_UM-260720-1870-C",
+ "state": "live",
+ "sz": "50",
+ "accFillSz": "0",
+ }
+ for _ in range(40)
+ ]
+ + [
+ {
+ "ordId": "2",
+ "instId": "ETH-USD_UM-260720-1870-C",
+ "state": "canceled",
+ "sz": "50",
+ "accFillSz": "0",
+ }
+ ]
+ )
+ out = wait_option_order_full_fill(
+ ex, # type: ignore[arg-type]
+ inst_id="ETH-USD_UM-260720-1870-C",
+ ord_id="2",
+ need_sheets=50,
+ timeout_sec=0.6,
+ poll_sec=0.05,
+ cancel_on_timeout=True,
+ )
+ assert out["ok"] is False
+ assert "超时" in (out.get("msg") or "")
+ assert ex.cancelled is True