Hedge options open: require full fill (IOC + wait) before success.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user