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_POLL_SECONDS=15
|
||||||
OKX_OPTIONS_TD_MODE=isolated
|
OKX_OPTIONS_TD_MODE=isolated
|
||||||
OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
|
OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
|
||||||
|
# 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败
|
||||||
|
OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 对冲计划(仅 OKX;前端 env「对冲计划」;详见 docs/对冲计划开发方案.md)
|
# 对冲计划(仅 OKX;前端 env「对冲计划」;详见 docs/对冲计划开发方案.md)
|
||||||
|
|||||||
@@ -1331,7 +1331,7 @@
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
alert("补开成功 #" + (d.plan_id || planId) + " · 已进入进行中");
|
alert("补开成功 #" + (d.plan_id || planId) + " · 已完全成交并进入进行中");
|
||||||
void loadActivePlans();
|
void loadActivePlans();
|
||||||
void loadGates();
|
void loadGates();
|
||||||
} catch (e) {
|
} 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)}
|
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(
|
def place_option_limit_order(
|
||||||
ex: ccxt.okx,
|
ex: ccxt.okx,
|
||||||
*,
|
*,
|
||||||
@@ -973,12 +1086,16 @@ def place_option_limit_order(
|
|||||||
tick_sz: Any = None,
|
tick_sz: Any = None,
|
||||||
reduce_only: bool = False,
|
reduce_only: bool = False,
|
||||||
pos_side: str | None = None,
|
pos_side: str | None = None,
|
||||||
|
ord_type: str = "limit",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
side_l = (side or "").lower()
|
side_l = (side or "").lower()
|
||||||
if side_l not in ("buy", "sell"):
|
if side_l not in ("buy", "sell"):
|
||||||
return {"ok": False, "msg": "side 必须为 buy 或 sell"}
|
return {"ok": False, "msg": "side 必须为 buy 或 sell"}
|
||||||
if sheets < 1:
|
if sheets < 1:
|
||||||
return {"ok": False, "msg": "张数至少为 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)
|
px = round_option_px(float(price), tick_sz, side_l)
|
||||||
if px <= 0:
|
if px <= 0:
|
||||||
return {"ok": False, "msg": "价格无效"}
|
return {"ok": False, "msg": "价格无效"}
|
||||||
@@ -986,7 +1103,7 @@ def place_option_limit_order(
|
|||||||
"instId": inst_id,
|
"instId": inst_id,
|
||||||
"tdMode": td_mode,
|
"tdMode": td_mode,
|
||||||
"side": side_l,
|
"side": side_l,
|
||||||
"ordType": "limit",
|
"ordType": ot,
|
||||||
"px": format_option_px(px, tick_sz),
|
"px": format_option_px(px, tick_sz),
|
||||||
"sz": str(int(sheets)),
|
"sz": str(int(sheets)),
|
||||||
}
|
}
|
||||||
@@ -998,7 +1115,7 @@ def place_option_limit_order(
|
|||||||
resp = ex.private_post_trade_order(body)
|
resp = ex.private_post_trade_order(body)
|
||||||
data = (resp or {}).get("data") or []
|
data = (resp or {}).get("data") or []
|
||||||
if data and str(data[0].get("sCode")) == "0":
|
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}
|
return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp, "px": px}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "msg": _okx_trade_error_message(e), "px": px}
|
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(
|
def _buy_option(
|
||||||
cfg: dict[str, Any],
|
cfg: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
@@ -92,6 +99,7 @@ def _buy_option(
|
|||||||
from lib.exchange.okx_options_lib import (
|
from lib.exchange.okx_options_lib import (
|
||||||
cap_option_buy_sheets_to_ask_depth,
|
cap_option_buy_sheets_to_ask_depth,
|
||||||
option_buy_liquidity_ok,
|
option_buy_liquidity_ok,
|
||||||
|
wait_option_order_full_fill,
|
||||||
)
|
)
|
||||||
|
|
||||||
ex = cfg.get("exchange_options")
|
ex = cfg.get("exchange_options")
|
||||||
@@ -146,6 +154,7 @@ def _buy_option(
|
|||||||
td = "isolated"
|
td = "isolated"
|
||||||
if callable(td_buy):
|
if callable(td_buy):
|
||||||
td = td_buy(cfg.get("options_td_mode") or "isolated")
|
td = td_buy(cfg.get("options_td_mode") or "isolated")
|
||||||
|
# IOC:能成交多少成交多少,剩余立即撤销;再校验是否完全成交
|
||||||
order = place_fn(
|
order = place_fn(
|
||||||
ex,
|
ex,
|
||||||
inst_id=inst_id,
|
inst_id=inst_id,
|
||||||
@@ -154,14 +163,42 @@ def _buy_option(
|
|||||||
price=float(ask),
|
price=float(ask),
|
||||||
td_mode=td,
|
td_mode=td,
|
||||||
tick_sz=q.get("tick_sz"),
|
tick_sz=q.get("tick_sz"),
|
||||||
|
ord_type="ioc",
|
||||||
)
|
)
|
||||||
if not order.get("ok"):
|
if not order.get("ok"):
|
||||||
return order
|
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 {
|
return {
|
||||||
"ok": True,
|
"ok": False,
|
||||||
|
"msg": fill.get("msg") or "未完全成交,开仓失败",
|
||||||
"inst_id": inst_id,
|
"inst_id": inst_id,
|
||||||
"sheets": sheets_i,
|
"sheets": sheets_i,
|
||||||
"ask": float(ask),
|
"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": filled_n,
|
||||||
|
"ask": fill_px,
|
||||||
"ask_sz": float(ask_sz),
|
"ask_sz": float(ask_sz),
|
||||||
"premium": premium,
|
"premium": premium,
|
||||||
"ct_mult": ct_mult,
|
"ct_mult": ct_mult,
|
||||||
@@ -170,8 +207,9 @@ def _buy_option(
|
|||||||
"strike": q.get("strike"),
|
"strike": q.get("strike"),
|
||||||
"exp_time": q.get("exp_time"),
|
"exp_time": q.get("exp_time"),
|
||||||
"opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"),
|
"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,
|
"order": order,
|
||||||
|
"fill": fill,
|
||||||
"can_open": True,
|
"can_open": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -301,4 +301,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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