Harden residual mid-close: IOC partial fills, exchange reconcile, atomic book.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-02 14:41:00 +08:00
parent 640ecc9530
commit 469e7a258a
8 changed files with 729 additions and 238 deletions
+175 -11
View File
@@ -1149,12 +1149,72 @@ class BinanceLiveExecutor(Matcher):
},
)
def _sync_residual_contracts_with_exchange(self, row: dict) -> dict | None:
option_inst_id = str(row.get("option_inst_id") or "")
group_id = str(row.get("group_id") or "")
client = self._client()
ex_sz = exchange_option_abs_size(client, option_inst_id)
if ex_sz is None:
return row
ct = self._ct_mult(option_inst_id)
local_c = float(row.get("option_qty_contracts") or 0)
if local_c <= 0:
local_c = float(
contracts_for_eth(float(row.get("option_qty_eth") or 0), ct) or 0
)
if ex_sz <= 1e-8:
now_ms = int(time.time() * 1000)
booked = self._book_residual_market_close(
row,
fill_px=0.0,
fee=0.0,
notional=0.0,
slip=0.0,
now_ms=now_ms,
note="LIVE-BN residual already flat on exchange",
exec_mode="LIVE",
filled_contracts=0.0,
remaining_contracts=0.0,
close_reason="residual_premium_close",
)
logger.warning(
"residual %s already flat on exchange; local settled=%s",
group_id,
booked is not None,
)
return None
if local_c > ex_sz + 1e-8:
rem_eth = eth_from_contracts(float(ex_sz), ct)
init = float(row.get("initial_premium") or 0)
local_eth = float(row.get("option_qty_eth") or 0)
if local_eth > 1e-12:
init = init * (rem_eth / local_eth)
with self.db._lock:
self.db._conn.execute(
"""UPDATE residual_options SET
option_qty_eth=?, option_qty_contracts=?, initial_premium=?
WHERE group_id=? AND status='pending'""",
(rem_eth, float(ex_sz), init, group_id),
)
self.db._conn.commit()
row = {
**row,
"option_qty_eth": rem_eth,
"option_qty_contracts": float(ex_sz),
"initial_premium": init,
}
return row
def try_close_one_residual(self, row: dict) -> dict | None:
"""LIVE-BN:权利金达标后按最新买一 IOC 限价卖出归档期权(不扫市价)。"""
err = self._guard_live()
if err:
logger.warning("residual premium close blocked: %s", err)
return None
synced = self._sync_residual_contracts_with_exchange(row)
if synced is None:
return None
row = synced
skip, close_bid, _oq = self._evaluate_residual_premium_close(row)
if skip or close_bid is None:
if skip:
@@ -1176,10 +1236,15 @@ class BinanceLiveExecutor(Matcher):
)
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:
if oq2 is None or oq2.bid is None:
return None
bid_px = float(oq2.bid)
skip2 = self._residual_bid_gate(row, bid=bid_px, oq=oq2)
if skip2:
logger.debug(
"residual premium close skip %s: bid vanished", row.get("group_id")
"residual premium close recheck skip %s: %s",
row.get("group_id"),
skip2,
)
return None
client = self._client()
@@ -1200,26 +1265,125 @@ class BinanceLiveExecutor(Matcher):
return None
of_px = float(opt_live.avg_px)
of_fee = float(opt_live.fee)
filled_c = (
float(opt_live.sz)
if opt_live.sz and float(opt_live.sz) > 0
else opt_contracts
filled_c = float(opt_live.sz) if opt_live.sz and float(opt_live.sz) > 0 else 0.0
if filled_c <= 1e-12:
return None
ex_left = exchange_option_abs_size(client, option_inst_id)
remaining = (
max(0.0, float(ex_left))
if ex_left is not None
else max(0.0, opt_contracts - filled_c)
)
opt_qty = eth_from_contracts(filled_c, self._ct_mult(option_inst_id))
row = {**row, "option_qty_eth": opt_qty, "option_qty_contracts": filled_c}
of_notional = of_px * opt_qty
fill_eth = eth_from_contracts(filled_c, self._ct_mult(option_inst_id))
now_ms = int(time.time() * 1000)
return self._book_residual_market_close(
row,
fill_px=of_px,
fee=of_fee,
notional=of_notional,
notional=of_px * fill_eth,
slip=0.0,
now_ms=now_ms,
note=f"LIVE-BN residual mid-close at bid IOC px={bid_px}",
exec_mode="LIVE",
filled_contracts=filled_c,
remaining_contracts=remaining,
close_reason="residual_premium_close",
)
def _try_exchange_flatten_residual(
self, row: dict, *, force: bool = False
) -> dict | None:
err = self._guard_live()
if err:
return None
option_inst_id = str(row.get("option_inst_id") or "")
client = self._client()
ex_sz = exchange_option_abs_size(client, option_inst_id)
if ex_sz is not None and ex_sz <= 1e-8:
return {
"fill_px": 0.0,
"fee": 0.0,
"notional": 0.0,
"slip": 0.0,
"filled_contracts": 0.0,
"remaining_contracts": 0.0,
"note": "LIVE-BN residual flat on exchange before settle",
"exec_mode": "LIVE",
"close_reason": "emergency" if force else "expiry",
}
opt_contracts = float(row.get("option_qty_contracts") or 0)
if ex_sz is not None and ex_sz > 0:
opt_contracts = float(ex_sz)
if opt_contracts <= 0:
opt_contracts = float(
contracts_for_eth(
float(row.get("option_qty_eth") or 0),
self._ct_mult(option_inst_id),
)
or 0
)
if opt_contracts <= 0:
return None
oq = self._quote_held_option(option_inst_id)
bid_px = float(oq.bid) if oq is not None and oq.bid is not None else 0.0
try:
if bid_px > 0 and not force:
opt_live = client.place_option_ioc(
symbol=option_inst_id,
side="SELL",
quantity=max(1.0, opt_contracts),
price=bid_px,
reduce_only=True,
)
else:
opt_live = client.place_option_market(
symbol=option_inst_id,
side="SELL",
quantity=max(1.0, opt_contracts),
reduce_only=True,
)
except Exception as e:
logger.warning(
"residual exchange flatten failed %s force=%s: %s",
row.get("group_id"),
force,
e,
)
return None
filled_c = float(opt_live.sz) if opt_live.sz and float(opt_live.sz) > 0 else 0.0
if filled_c <= 1e-12 and force:
try:
opt_live = client.place_option_market(
symbol=option_inst_id,
side="SELL",
quantity=max(1.0, opt_contracts),
reduce_only=True,
)
filled_c = (
float(opt_live.sz) if opt_live.sz and float(opt_live.sz) > 0 else 0.0
)
except Exception as e:
logger.warning("residual emergency market sell failed: %s", e)
return None
if filled_c <= 1e-12:
return None
of_px = float(opt_live.avg_px)
of_fee = float(opt_live.fee)
fill_eth = eth_from_contracts(filled_c, self._ct_mult(option_inst_id))
ex_left = exchange_option_abs_size(client, option_inst_id)
remaining = max(0.0, float(ex_left)) if ex_left is not None else 0.0
return {
"fill_px": of_px,
"fee": of_fee,
"notional": of_px * fill_eth,
"slip": 0.0,
"filled_contracts": filled_c,
"remaining_contracts": remaining,
"note": f"LIVE-BN residual exchange flatten force={force}",
"exec_mode": "LIVE",
"close_reason": "emergency" if force else "expiry",
}
def close_perp_abandon_option(
self, *, reason: str = "target_perp_only", require_deep_otm: bool = True
) -> CloseResult:
+16 -5
View File
@@ -228,9 +228,11 @@ class BinanceTradeClient:
if reduce_only:
params["reduceOnly"] = "true"
data = self._signed(self._eapi, "POST", "/eapi/v1/order", params)
return self._fill_from_eapi(symbol, data)
return self._fill_from_eapi(symbol, data, allow_partial=True)
def _fill_from_eapi(self, symbol: str, data: dict[str, Any]) -> LiveFill:
def _fill_from_eapi(
self, symbol: str, data: dict[str, Any], *, allow_partial: bool = False
) -> LiveFill:
ord_id = str(data.get("orderId") or data.get("id") or "")
avg = safe_float(data.get("avgPrice")) or safe_float(data.get("price"))
sz = safe_float(data.get("executedQty")) or safe_float(data.get("quantity"))
@@ -251,16 +253,25 @@ class BinanceTradeClient:
if avg and avg > 0 and st == "FILLED":
break
if st in ("CANCELED", "REJECTED", "EXPIRED"):
if allow_partial and sz and sz > 1e-12 and avg and avg > 0:
break
raise RuntimeError(f"币安期权订单失败 status={st} {q}")
if st == "PARTIALLY_FILLED":
continue
if not avg or avg <= 0:
raise RuntimeError(f"币安期权无成交均价 orderId={ord_id} last={data}")
st_final = str(data.get("status") or "").upper()
executed = safe_float(data.get("executedQty")) or float(sz or 0)
if st_final and st_final != "FILLED":
raise RuntimeError(
f"币安期权未完全成交 status={st_final} orderId={ord_id} last={data}"
)
if not (
allow_partial
and executed > 1e-12
and st_final in ("CANCELED", "EXPIRED", "PARTIALLY_FILLED")
):
raise RuntimeError(
f"币安期权未完全成交 status={st_final} orderId={ord_id} last={data}"
)
sz = executed
from .money import abs_fee_usdt
fee = abs(safe_float(data.get("fee")) or 0.0)
+184 -13
View File
@@ -1192,12 +1192,74 @@ class OkxLiveExecutor(Matcher):
},
)
def _sync_residual_contracts_with_exchange(self, row: dict) -> dict | None:
"""按交易所持仓修正本地残留数量;已空仓则直接结清。返回待卖 row 或 None(已处理/跳过)。"""
option_inst_id = str(row.get("option_inst_id") or "")
group_id = str(row.get("group_id") or "")
client = self._client()
ex_sz = exchange_option_abs_size(client, option_inst_id)
if ex_sz is None:
return row
ct = self._ct_mult(option_inst_id)
local_c = float(row.get("option_qty_contracts") or 0)
if local_c <= 0:
local_c = float(
contracts_for_eth(float(row.get("option_qty_eth") or 0), ct) or 0
)
if ex_sz <= 1e-8:
now_ms = int(time.time() * 1000)
booked = self._book_residual_market_close(
row,
fill_px=0.0,
fee=0.0,
notional=0.0,
slip=0.0,
now_ms=now_ms,
note="LIVE residual already flat on exchange",
exec_mode="LIVE",
filled_contracts=0.0,
remaining_contracts=0.0,
close_reason="residual_premium_close",
)
logger.warning(
"residual %s already flat on exchange; local settled=%s",
group_id,
booked is not None,
)
return None
# 交易所更少:缩到交易所数量,避免超卖
if local_c > ex_sz + 1e-8:
rem_eth = eth_from_contracts(float(ex_sz), ct)
init = float(row.get("initial_premium") or 0)
local_eth = float(row.get("option_qty_eth") or 0)
if local_eth > 1e-12:
init = init * (rem_eth / local_eth)
with self.db._lock:
self.db._conn.execute(
"""UPDATE residual_options SET
option_qty_eth=?, option_qty_contracts=?, initial_premium=?
WHERE group_id=? AND status='pending'""",
(rem_eth, float(ex_sz), init, group_id),
)
self.db._conn.commit()
row = {
**row,
"option_qty_eth": rem_eth,
"option_qty_contracts": float(ex_sz),
"initial_premium": init,
}
return row
def try_close_one_residual(self, row: dict) -> dict | None:
"""LIVE:权利金达标后按最新买一 IOC 限价卖出归档期权(不扫市价)。"""
err = self._guard_live()
if err:
logger.warning("residual premium close blocked: %s", err)
return None
synced = self._sync_residual_contracts_with_exchange(row)
if synced is None:
return None
row = synced
skip, close_bid, _oq = self._evaluate_residual_premium_close(row)
if skip or close_bid is None:
if skip:
@@ -1218,12 +1280,16 @@ 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:
if oq2 is None or oq2.bid is None:
return None
bid_px = float(oq2.bid)
skip2 = self._residual_bid_gate(row, bid=bid_px, oq=oq2)
if skip2:
logger.debug(
"residual premium close skip %s: bid vanished", row.get("group_id")
"residual premium close recheck skip %s: %s",
row.get("group_id"),
skip2,
)
return None
client = self._client()
@@ -1245,26 +1311,131 @@ class OkxLiveExecutor(Matcher):
return None
of_px = float(opt_live.avg_px)
of_fee = float(opt_live.fee)
filled_c = (
float(opt_live.sz)
if opt_live.sz and float(opt_live.sz) > 0
else opt_contracts
)
opt_qty = eth_from_contracts(filled_c, self._ct_mult(option_inst_id))
row = {**row, "option_qty_eth": opt_qty, "option_qty_contracts": filled_c}
of_notional = of_px * opt_qty
filled_c = float(opt_live.sz) if opt_live.sz and float(opt_live.sz) > 0 else 0.0
if filled_c <= 1e-12:
return None
ex_left = exchange_option_abs_size(client, option_inst_id)
if ex_left is not None:
remaining = max(0.0, float(ex_left))
else:
remaining = max(0.0, opt_contracts - filled_c)
fill_eth = eth_from_contracts(filled_c, self._ct_mult(option_inst_id))
now_ms = int(time.time() * 1000)
return self._book_residual_market_close(
row,
fill_px=of_px,
fee=of_fee,
notional=of_notional,
notional=of_px * fill_eth,
slip=0.0,
now_ms=now_ms,
note=f"LIVE residual mid-close at bid IOC px={bid_px}",
exec_mode="LIVE",
filled_contracts=filled_c,
remaining_contracts=remaining,
close_reason="residual_premium_close",
)
def _try_exchange_flatten_residual(
self, row: dict, *, force: bool = False
) -> dict | None:
"""到期/紧急:优先交易所卖掉残留;失败返回 None 走内在价值。"""
err = self._guard_live()
if err:
return None
option_inst_id = str(row.get("option_inst_id") or "")
client = self._client()
ex_sz = exchange_option_abs_size(client, option_inst_id)
if ex_sz is not None and ex_sz <= 1e-8:
return {
"fill_px": 0.0,
"fee": 0.0,
"notional": 0.0,
"slip": 0.0,
"filled_contracts": 0.0,
"remaining_contracts": 0.0,
"note": "LIVE residual flat on exchange before settle",
"exec_mode": "LIVE",
"close_reason": "emergency" if force else "expiry",
}
opt_contracts = float(row.get("option_qty_contracts") or 0)
if ex_sz is not None and ex_sz > 0:
opt_contracts = float(ex_sz)
if opt_contracts <= 0:
opt_contracts = float(
contracts_for_eth(
float(row.get("option_qty_eth") or 0),
self._ct_mult(option_inst_id),
)
or 0
)
if opt_contracts <= 0:
return None
oq = self._quote_held_option(option_inst_id)
bid_px = float(oq.bid) if oq is not None and oq.bid is not None else 0.0
try:
if bid_px > 0 and not force:
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,
)
else:
opt_live = client.place_market(
inst_id=option_inst_id,
side="sell",
sz=str(max(1, int(round(opt_contracts)))),
td_mode="cash",
reduce_only=True,
)
except Exception as e:
logger.warning(
"residual exchange flatten failed %s force=%s: %s",
row.get("group_id"),
force,
e,
)
return None
filled_c = float(opt_live.sz) if opt_live.sz and float(opt_live.sz) > 0 else 0.0
if filled_c <= 1e-12 and force:
# 紧急:再试市价
try:
opt_live = client.place_market(
inst_id=option_inst_id,
side="sell",
sz=str(max(1, int(round(opt_contracts)))),
td_mode="cash",
reduce_only=True,
)
filled_c = (
float(opt_live.sz) if opt_live.sz and float(opt_live.sz) > 0 else 0.0
)
except Exception as e:
logger.warning("residual emergency market sell failed: %s", e)
return None
if filled_c <= 1e-12:
return None
of_px = float(opt_live.avg_px)
of_fee = float(opt_live.fee)
fill_eth = eth_from_contracts(filled_c, self._ct_mult(option_inst_id))
ex_left = exchange_option_abs_size(client, option_inst_id)
remaining = max(0.0, float(ex_left)) if ex_left is not None else 0.0
# 到期/紧急要求尽量结清:若仍有剩余且 force,不在此硬结(返回 None 让内在价值兜底会重复)
# 有成交则先入账已成交部分;剩余留 pending 由下次处理,除非交易所已空
return {
"fill_px": of_px,
"fee": of_fee,
"notional": of_px * fill_eth,
"slip": 0.0,
"filled_contracts": filled_c,
"remaining_contracts": remaining,
"note": f"LIVE residual exchange flatten force={force}",
"exec_mode": "LIVE",
"close_reason": "emergency" if force else "expiry",
}
def close_perp_abandon_option(
self, *, reason: str = "target_perp_only", require_deep_otm: bool = True
) -> CloseResult:
+26 -2
View File
@@ -185,7 +185,7 @@ class OkxTradeClient:
if not rows:
raise RuntimeError("OKX IOC 下单无返回")
ord_id = str(rows[0].get("ordId") or "")
return self._wait_fill(inst_id, ord_id)
return self._wait_fill(inst_id, ord_id, allow_partial=True)
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
@@ -206,7 +206,14 @@ class OkxTradeClient:
raw=row,
)
def _wait_fill(self, inst_id: str, ord_id: str, *, tries: int = 40) -> LiveFill:
def _wait_fill(
self,
inst_id: str,
ord_id: str,
*,
tries: int = 40,
allow_partial: bool = False,
) -> LiveFill:
path = f"/api/v5/trade/order?instId={inst_id}&ordId={ord_id}"
last: dict[str, Any] = {}
for _ in range(tries):
@@ -215,21 +222,38 @@ class OkxTradeClient:
last = rows[0]
state = str(last.get("state") or "")
avg = safe_float(last.get("avgPx"))
acc = safe_float(last.get("accFillSz")) or 0.0
# 仅完全成交;部分成交继续等,避免账本张数与交易所不一致
if state == "filled" and avg and avg > 0:
return self._fill_from_order_row(inst_id, ord_id, last)
if state in ("canceled", "failed"):
# IOC:未成交部分取消;若已有成交量则按部分成交入账
if (
allow_partial
and acc > 1e-12
and avg
and avg > 0
):
return self._fill_from_order_row(inst_id, ord_id, last)
raise RuntimeError(f"OKX 订单失败 state={state} {last}")
time.sleep(0.3)
# 超时兜底:仅接受完全成交;部分成交不得当全成记账(会错张数/对冲)
state = str(last.get("state") or "")
avg = safe_float(last.get("avgPx"))
acc = safe_float(last.get("accFillSz")) or 0.0
if state == "filled" and avg and avg > 0:
logger.warning(
"OKX fill wait timeout but order filled ordId=%s",
ord_id,
)
return self._fill_from_order_row(inst_id, ord_id, last)
if allow_partial and acc > 1e-12 and avg and avg > 0:
logger.warning(
"OKX IOC partial fill on timeout ordId=%s acc=%s",
ord_id,
acc,
)
return self._fill_from_order_row(inst_id, ord_id, last)
raise RuntimeError(f"OKX 订单未完全成交 ordId={ord_id} last={last}")
def sum_fill_fees(self, inst_id: str, ord_id: str) -> tuple[float, str]: