Hedge options open: require full fill (IOC + wait) before success.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-20 09:14:03 +08:00
parent 88d460d484
commit 8597e47596
6 changed files with 254 additions and 7 deletions
+1 -1
View File
@@ -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) {
+119 -2
View File
@@ -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}
+41 -3
View File
@@ -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,
}
@@ -301,4 +301,4 @@
</div>
</div>
</div>
<script src="/static/hedge_plan.js?v=26"></script>
<script src="/static/hedge_plan.js?v=27"></script>