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