From 640ecc95301ec9ad6041ac75a2c55eaec41f0afa Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 2 Aug 2026 14:25:03 +0800 Subject: [PATCH] Sell residual options at latest bid via IOC limit, not market. Co-authored-by: Cursor --- backend/app/live/binance_executor.py | 16 +++++++++++---- backend/app/live/binance_trade.py | 29 +++++++++++++++++++++++++++ backend/app/live/executor.py | 17 ++++++++++++---- backend/app/live/okx_trade.py | 30 ++++++++++++++++++++++++++++ backend/app/sim/matcher.py | 2 +- docs/策略说明.md | 4 ++-- frontend/src/pages/Settings.tsx | 4 ++-- 7 files changed, 89 insertions(+), 13 deletions(-) diff --git a/backend/app/live/binance_executor.py b/backend/app/live/binance_executor.py index 0590d99..02fdfc7 100644 --- a/backend/app/live/binance_executor.py +++ b/backend/app/live/binance_executor.py @@ -1150,7 +1150,7 @@ class BinanceLiveExecutor(Matcher): ) def try_close_one_residual(self, row: dict) -> dict | None: - """LIVE-BN:权利金达标后交易所市价卖出归档期权。""" + """LIVE-BN:权利金达标后按最新买一 IOC 限价卖出归档期权(不扫市价)。""" err = self._guard_live() if err: logger.warning("residual premium close blocked: %s", err) @@ -1175,17 +1175,25 @@ class BinanceLiveExecutor(Matcher): "residual premium close skip %s: bad contracts", row.get("group_id") ) return None + oq2 = self._quote_held_option(option_inst_id) + bid_px = float(oq2.bid) if oq2 is not None and oq2.bid is not None else float(close_bid) + if bid_px <= 0: + logger.debug( + "residual premium close skip %s: bid vanished", row.get("group_id") + ) + return None client = self._client() try: - opt_live = client.place_option_market( + opt_live = client.place_option_ioc( symbol=option_inst_id, side="SELL", quantity=max(1.0, opt_contracts), + price=bid_px, reduce_only=True, ) except Exception as e: logger.warning( - "residual premium close exchange sell failed %s: %s", + "residual premium close bid-ioc sell failed %s: %s", row.get("group_id"), e, ) @@ -1208,7 +1216,7 @@ class BinanceLiveExecutor(Matcher): notional=of_notional, slip=0.0, now_ms=now_ms, - note="LIVE-BN residual mid-close by premium recovery", + note=f"LIVE-BN residual mid-close at bid IOC px={bid_px}", exec_mode="LIVE", ) diff --git a/backend/app/live/binance_trade.py b/backend/app/live/binance_trade.py index 901173d..065292d 100644 --- a/backend/app/live/binance_trade.py +++ b/backend/app/live/binance_trade.py @@ -201,6 +201,35 @@ class BinanceTradeClient: data = self._signed(self._eapi, "POST", "/eapi/v1/order", params) return self._fill_from_eapi(symbol, data) + def place_option_ioc( + self, + *, + symbol: str, + side: str, # BUY|SELL + quantity: float, + price: float, + reduce_only: bool = False, + ) -> LiveFill: + """期权限价 IOC:按买一/卖一价吃单,未成交部分取消。""" + qty = str(int(round(quantity))) + if qty == "0": + qty = "1" + px = f"{float(price):.8f}".rstrip("0").rstrip(".") + if not px or px == "0": + raise RuntimeError("币安期权 IOC 价格无效") + params: dict[str, Any] = { + "symbol": symbol, + "side": side.upper(), + "type": "LIMIT", + "timeInForce": "IOC", + "quantity": qty, + "price": px, + } + if reduce_only: + params["reduceOnly"] = "true" + data = self._signed(self._eapi, "POST", "/eapi/v1/order", params) + return self._fill_from_eapi(symbol, data) + def _fill_from_eapi(self, symbol: str, data: dict[str, Any]) -> LiveFill: ord_id = str(data.get("orderId") or data.get("id") or "") avg = safe_float(data.get("avgPrice")) or safe_float(data.get("price")) diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index ab2ab6f..1c0ee51 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -1193,7 +1193,7 @@ class OkxLiveExecutor(Matcher): ) def try_close_one_residual(self, row: dict) -> dict | None: - """LIVE:权利金达标后交易所市价卖出归档期权。""" + """LIVE:权利金达标后按最新买一 IOC 限价卖出归档期权(不扫市价)。""" err = self._guard_live() if err: logger.warning("residual premium close blocked: %s", err) @@ -1218,18 +1218,27 @@ class OkxLiveExecutor(Matcher): "residual premium close skip %s: bad contracts", row.get("group_id") ) return None + # 下单前再刷一次买一,按最新盘口挂 IOC + oq2 = self._quote_held_option(option_inst_id) + bid_px = float(oq2.bid) if oq2 is not None and oq2.bid is not None else float(close_bid) + if bid_px <= 0: + logger.debug( + "residual premium close skip %s: bid vanished", row.get("group_id") + ) + return None client = self._client() try: - opt_live = client.place_market( + opt_live = client.place_ioc( inst_id=option_inst_id, side="sell", sz=str(max(1, int(round(opt_contracts)))), + px=bid_px, td_mode="cash", reduce_only=True, ) except Exception as e: logger.warning( - "residual premium close exchange sell failed %s: %s", + "residual premium close bid-ioc sell failed %s: %s", row.get("group_id"), e, ) @@ -1252,7 +1261,7 @@ class OkxLiveExecutor(Matcher): notional=of_notional, slip=0.0, now_ms=now_ms, - note="LIVE residual mid-close by premium recovery", + note=f"LIVE residual mid-close at bid IOC px={bid_px}", exec_mode="LIVE", ) diff --git a/backend/app/live/okx_trade.py b/backend/app/live/okx_trade.py index f2e87ee..57664fe 100644 --- a/backend/app/live/okx_trade.py +++ b/backend/app/live/okx_trade.py @@ -157,6 +157,36 @@ class OkxTradeClient: fill = self._wait_fill(inst_id, ord_id) return fill + def place_ioc( + self, + *, + inst_id: str, + side: str, # buy|sell + sz: str, + px: float | str, + td_mode: str, + pos_side: str | None = None, + reduce_only: bool = False, + ) -> LiveFill: + """限价 IOC:残留回收等场景按指定买一/卖一吃单,不成交部分立即取消。""" + body: dict[str, Any] = { + "instId": inst_id, + "tdMode": td_mode, + "side": side, + "ordType": "ioc", + "sz": str(sz), + "px": str(px), + } + if pos_side: + body["posSide"] = pos_side + if reduce_only: + body["reduceOnly"] = True + rows = self._request("POST", "/api/v5/trade/order", body) + if not rows: + raise RuntimeError("OKX IOC 下单无返回") + ord_id = str(rows[0].get("ordId") or "") + return self._wait_fill(inst_id, ord_id) + def _fill_from_order_row(self, inst_id: str, ord_id: str, row: dict[str, Any]) -> LiveFill: avg = safe_float(row.get("avgPx")) or 0.0 sz = safe_float(row.get("accFillSz")) or safe_float(row.get("sz")) or 0.0 diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index adf3eb4..7f0b59b 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -1037,7 +1037,7 @@ class Matcher: notional=of.notional, slip=of.slip, now_ms=now_ms, - note="residual mid-close by premium recovery", + note=f"residual mid-close at bid px={close_bid}", ) def try_close_pending_residuals(self) -> list[dict[str, Any]]: diff --git a/docs/策略说明.md b/docs/策略说明.md index ddcc4ab..6b1d93b 100644 --- a/docs/策略说明.md +++ b/docs/策略说明.md @@ -181,7 +181,7 @@ k = floor(budget / (2A + I×fee_rate×3) × 10) / 10 - 动作: 1. **只市价平掉永续**,兑现净利里永续那一截; 2. 本张期权归档为「残留」:不占用活跃持仓、**不挡住下一组开仓**;下一组只扫当前活跃组期权; - 3. **中途回收(可配置)**:默认每 **5 分钟**巡检 pending 残留;当 **买一权利金 ≥ 初始权利金 × 比例**(默认 **20%**,系统设置「残留期权回收」可改)且通过买一流动性闸门时,**市价卖掉**该残留并结清; + 3. **中途回收(可配置)**:默认每 **5 分钟**巡检 pending 残留;当 **买一权利金 ≥ 初始权利金 × 比例**(默认 **20%**,系统设置「残留期权回收」可改)且通过买一流动性闸门时,按**最新买一 IOC 限价**卖掉该残留并结清(不扫市价簿); 4. 未达比例或闸门不过 → 继续等到下次巡检,或到期按 **内在价值** 结算(多半接近 0); 5. 页面:**活跃持仓区变空**;归档腿出现在「残留期权(待到期)」;下方期权盘口 **切回新 ATM**(见 4.6)。 @@ -237,7 +237,7 @@ k = floor(budget / (2A + I×fee_rate×3) × 10) / 10 └─ 否 → 持有直到到期 → 内在价值结算(+ 若有永续则平永续) 残留期权(已归档) - ├─ 周期性:买一权利金 / 初始 ≥ 设置% 且流动性过 → 市价卖出结清 + ├─ 周期性:买一权利金 / 初始 ≥ 设置% 且流动性过 → 最新买一 IOC 卖出结清 └─ 否则到期内在价值结算;不挡下一组开仓 ``` diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index eb8af7e..a47c322 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1323,8 +1323,8 @@ export default function SettingsPage() { 流动性闸门;LIVE 以交易所能否成交为准。
  • - 残留期权回收比例:只平永续后,当买一权利金回升到初始权利金的该比例及以上时,才尝试中途卖掉归档期权;默认 - 20%。未达标则等到期按内在价值结算。 + 残留期权回收比例:只平永续后,当买一权利金回升到初始权利金的该比例及以上时,按最新买一 + IOC 限价卖掉归档期权(不扫市价);默认 20%。未达标或未完全成交则等到下次巡检或到期结算。
  • ) : null}