Harden residual mid-close: IOC partial fills, exchange reconcile, atomic book.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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:
|
def try_close_one_residual(self, row: dict) -> dict | None:
|
||||||
"""LIVE-BN:权利金达标后按最新买一 IOC 限价卖出归档期权(不扫市价)。"""
|
"""LIVE-BN:权利金达标后按最新买一 IOC 限价卖出归档期权(不扫市价)。"""
|
||||||
err = self._guard_live()
|
err = self._guard_live()
|
||||||
if err:
|
if err:
|
||||||
logger.warning("residual premium close blocked: %s", err)
|
logger.warning("residual premium close blocked: %s", err)
|
||||||
return None
|
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)
|
skip, close_bid, _oq = self._evaluate_residual_premium_close(row)
|
||||||
if skip or close_bid is None:
|
if skip or close_bid is None:
|
||||||
if skip:
|
if skip:
|
||||||
@@ -1176,10 +1236,15 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
oq2 = self._quote_held_option(option_inst_id)
|
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 oq2 is None or oq2.bid is None:
|
||||||
if bid_px <= 0:
|
return None
|
||||||
|
bid_px = float(oq2.bid)
|
||||||
|
skip2 = self._residual_bid_gate(row, bid=bid_px, oq=oq2)
|
||||||
|
if skip2:
|
||||||
logger.debug(
|
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
|
return None
|
||||||
client = self._client()
|
client = self._client()
|
||||||
@@ -1200,26 +1265,125 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
return None
|
return None
|
||||||
of_px = float(opt_live.avg_px)
|
of_px = float(opt_live.avg_px)
|
||||||
of_fee = float(opt_live.fee)
|
of_fee = float(opt_live.fee)
|
||||||
filled_c = (
|
filled_c = float(opt_live.sz) if opt_live.sz and float(opt_live.sz) > 0 else 0.0
|
||||||
float(opt_live.sz)
|
if filled_c <= 1e-12:
|
||||||
if opt_live.sz and float(opt_live.sz) > 0
|
return None
|
||||||
else opt_contracts
|
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))
|
fill_eth = 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
|
|
||||||
now_ms = int(time.time() * 1000)
|
now_ms = int(time.time() * 1000)
|
||||||
return self._book_residual_market_close(
|
return self._book_residual_market_close(
|
||||||
row,
|
row,
|
||||||
fill_px=of_px,
|
fill_px=of_px,
|
||||||
fee=of_fee,
|
fee=of_fee,
|
||||||
notional=of_notional,
|
notional=of_px * fill_eth,
|
||||||
slip=0.0,
|
slip=0.0,
|
||||||
now_ms=now_ms,
|
now_ms=now_ms,
|
||||||
note=f"LIVE-BN residual mid-close at bid IOC px={bid_px}",
|
note=f"LIVE-BN residual mid-close at bid IOC px={bid_px}",
|
||||||
exec_mode="LIVE",
|
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(
|
def close_perp_abandon_option(
|
||||||
self, *, reason: str = "target_perp_only", require_deep_otm: bool = True
|
self, *, reason: str = "target_perp_only", require_deep_otm: bool = True
|
||||||
) -> CloseResult:
|
) -> CloseResult:
|
||||||
|
|||||||
@@ -228,9 +228,11 @@ class BinanceTradeClient:
|
|||||||
if reduce_only:
|
if reduce_only:
|
||||||
params["reduceOnly"] = "true"
|
params["reduceOnly"] = "true"
|
||||||
data = self._signed(self._eapi, "POST", "/eapi/v1/order", params)
|
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 "")
|
ord_id = str(data.get("orderId") or data.get("id") or "")
|
||||||
avg = safe_float(data.get("avgPrice")) or safe_float(data.get("price"))
|
avg = safe_float(data.get("avgPrice")) or safe_float(data.get("price"))
|
||||||
sz = safe_float(data.get("executedQty")) or safe_float(data.get("quantity"))
|
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":
|
if avg and avg > 0 and st == "FILLED":
|
||||||
break
|
break
|
||||||
if st in ("CANCELED", "REJECTED", "EXPIRED"):
|
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}")
|
raise RuntimeError(f"币安期权订单失败 status={st} {q}")
|
||||||
if st == "PARTIALLY_FILLED":
|
if st == "PARTIALLY_FILLED":
|
||||||
continue
|
continue
|
||||||
if not avg or avg <= 0:
|
if not avg or avg <= 0:
|
||||||
raise RuntimeError(f"币安期权无成交均价 orderId={ord_id} last={data}")
|
raise RuntimeError(f"币安期权无成交均价 orderId={ord_id} last={data}")
|
||||||
st_final = str(data.get("status") or "").upper()
|
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":
|
if st_final and st_final != "FILLED":
|
||||||
|
if not (
|
||||||
|
allow_partial
|
||||||
|
and executed > 1e-12
|
||||||
|
and st_final in ("CANCELED", "EXPIRED", "PARTIALLY_FILLED")
|
||||||
|
):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"币安期权未完全成交 status={st_final} orderId={ord_id} last={data}"
|
f"币安期权未完全成交 status={st_final} orderId={ord_id} last={data}"
|
||||||
)
|
)
|
||||||
|
sz = executed
|
||||||
from .money import abs_fee_usdt
|
from .money import abs_fee_usdt
|
||||||
|
|
||||||
fee = abs(safe_float(data.get("fee")) or 0.0)
|
fee = abs(safe_float(data.get("fee")) or 0.0)
|
||||||
|
|||||||
+184
-13
@@ -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:
|
def try_close_one_residual(self, row: dict) -> dict | None:
|
||||||
"""LIVE:权利金达标后按最新买一 IOC 限价卖出归档期权(不扫市价)。"""
|
"""LIVE:权利金达标后按最新买一 IOC 限价卖出归档期权(不扫市价)。"""
|
||||||
err = self._guard_live()
|
err = self._guard_live()
|
||||||
if err:
|
if err:
|
||||||
logger.warning("residual premium close blocked: %s", err)
|
logger.warning("residual premium close blocked: %s", err)
|
||||||
return None
|
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)
|
skip, close_bid, _oq = self._evaluate_residual_premium_close(row)
|
||||||
if skip or close_bid is None:
|
if skip or close_bid is None:
|
||||||
if skip:
|
if skip:
|
||||||
@@ -1218,12 +1280,16 @@ class OkxLiveExecutor(Matcher):
|
|||||||
"residual premium close skip %s: bad contracts", row.get("group_id")
|
"residual premium close skip %s: bad contracts", row.get("group_id")
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
# 下单前再刷一次买一,按最新盘口挂 IOC
|
|
||||||
oq2 = self._quote_held_option(option_inst_id)
|
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 oq2 is None or oq2.bid is None:
|
||||||
if bid_px <= 0:
|
return None
|
||||||
|
bid_px = float(oq2.bid)
|
||||||
|
skip2 = self._residual_bid_gate(row, bid=bid_px, oq=oq2)
|
||||||
|
if skip2:
|
||||||
logger.debug(
|
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
|
return None
|
||||||
client = self._client()
|
client = self._client()
|
||||||
@@ -1245,26 +1311,131 @@ class OkxLiveExecutor(Matcher):
|
|||||||
return None
|
return None
|
||||||
of_px = float(opt_live.avg_px)
|
of_px = float(opt_live.avg_px)
|
||||||
of_fee = float(opt_live.fee)
|
of_fee = float(opt_live.fee)
|
||||||
filled_c = (
|
filled_c = float(opt_live.sz) if opt_live.sz and float(opt_live.sz) > 0 else 0.0
|
||||||
float(opt_live.sz)
|
if filled_c <= 1e-12:
|
||||||
if opt_live.sz and float(opt_live.sz) > 0
|
return None
|
||||||
else opt_contracts
|
ex_left = exchange_option_abs_size(client, option_inst_id)
|
||||||
)
|
if ex_left is not None:
|
||||||
opt_qty = eth_from_contracts(filled_c, self._ct_mult(option_inst_id))
|
remaining = max(0.0, float(ex_left))
|
||||||
row = {**row, "option_qty_eth": opt_qty, "option_qty_contracts": filled_c}
|
else:
|
||||||
of_notional = of_px * opt_qty
|
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)
|
now_ms = int(time.time() * 1000)
|
||||||
return self._book_residual_market_close(
|
return self._book_residual_market_close(
|
||||||
row,
|
row,
|
||||||
fill_px=of_px,
|
fill_px=of_px,
|
||||||
fee=of_fee,
|
fee=of_fee,
|
||||||
notional=of_notional,
|
notional=of_px * fill_eth,
|
||||||
slip=0.0,
|
slip=0.0,
|
||||||
now_ms=now_ms,
|
now_ms=now_ms,
|
||||||
note=f"LIVE residual mid-close at bid IOC px={bid_px}",
|
note=f"LIVE residual mid-close at bid IOC px={bid_px}",
|
||||||
exec_mode="LIVE",
|
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(
|
def close_perp_abandon_option(
|
||||||
self, *, reason: str = "target_perp_only", require_deep_otm: bool = True
|
self, *, reason: str = "target_perp_only", require_deep_otm: bool = True
|
||||||
) -> CloseResult:
|
) -> CloseResult:
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ class OkxTradeClient:
|
|||||||
if not rows:
|
if not rows:
|
||||||
raise RuntimeError("OKX IOC 下单无返回")
|
raise RuntimeError("OKX IOC 下单无返回")
|
||||||
ord_id = str(rows[0].get("ordId") or "")
|
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:
|
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
|
avg = safe_float(row.get("avgPx")) or 0.0
|
||||||
@@ -206,7 +206,14 @@ class OkxTradeClient:
|
|||||||
raw=row,
|
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}"
|
path = f"/api/v5/trade/order?instId={inst_id}&ordId={ord_id}"
|
||||||
last: dict[str, Any] = {}
|
last: dict[str, Any] = {}
|
||||||
for _ in range(tries):
|
for _ in range(tries):
|
||||||
@@ -215,21 +222,38 @@ class OkxTradeClient:
|
|||||||
last = rows[0]
|
last = rows[0]
|
||||||
state = str(last.get("state") or "")
|
state = str(last.get("state") or "")
|
||||||
avg = safe_float(last.get("avgPx"))
|
avg = safe_float(last.get("avgPx"))
|
||||||
|
acc = safe_float(last.get("accFillSz")) or 0.0
|
||||||
# 仅完全成交;部分成交继续等,避免账本张数与交易所不一致
|
# 仅完全成交;部分成交继续等,避免账本张数与交易所不一致
|
||||||
if state == "filled" and avg and avg > 0:
|
if state == "filled" and avg and avg > 0:
|
||||||
return self._fill_from_order_row(inst_id, ord_id, last)
|
return self._fill_from_order_row(inst_id, ord_id, last)
|
||||||
if state in ("canceled", "failed"):
|
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}")
|
raise RuntimeError(f"OKX 订单失败 state={state} {last}")
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
# 超时兜底:仅接受完全成交;部分成交不得当全成记账(会错张数/对冲)
|
# 超时兜底:仅接受完全成交;部分成交不得当全成记账(会错张数/对冲)
|
||||||
state = str(last.get("state") or "")
|
state = str(last.get("state") or "")
|
||||||
avg = safe_float(last.get("avgPx"))
|
avg = safe_float(last.get("avgPx"))
|
||||||
|
acc = safe_float(last.get("accFillSz")) or 0.0
|
||||||
if state == "filled" and avg and avg > 0:
|
if state == "filled" and avg and avg > 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"OKX fill wait timeout but order filled ordId=%s",
|
"OKX fill wait timeout but order filled ordId=%s",
|
||||||
ord_id,
|
ord_id,
|
||||||
)
|
)
|
||||||
return self._fill_from_order_row(inst_id, ord_id, last)
|
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}")
|
raise RuntimeError(f"OKX 订单未完全成交 ordId={ord_id} last={last}")
|
||||||
|
|
||||||
def sum_fill_fees(self, inst_id: str, ord_id: str) -> tuple[float, str]:
|
def sum_fill_fees(self, inst_id: str, ord_id: str) -> tuple[float, str]:
|
||||||
|
|||||||
@@ -28,10 +28,12 @@ class Ledger:
|
|||||||
group_id: str | None = None,
|
group_id: str | None = None,
|
||||||
note: str = "",
|
note: str = "",
|
||||||
allow_negative: bool = False,
|
allow_negative: bool = False,
|
||||||
|
commit: bool = True,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""amount>0 入账;amount<0 出账。返回余额。
|
"""amount>0 入账;amount<0 出账。返回余额。
|
||||||
|
|
||||||
LIVE 实盘成交后本地账本仅作镜像,须 allow_negative=True,避免「交易所已成交、本地拒记」导致卡仓。
|
LIVE 实盘成交后本地账本仅作镜像,须 allow_negative=True,避免「交易所已成交、本地拒记」导致卡仓。
|
||||||
|
commit=False:由调用方持锁并统一提交(与持仓/残留状态同事务)。
|
||||||
"""
|
"""
|
||||||
now = int(time.time() * 1000)
|
now = int(time.time() * 1000)
|
||||||
with self.db._lock:
|
with self.db._lock:
|
||||||
@@ -49,7 +51,9 @@ class Ledger:
|
|||||||
"INSERT INTO ledger_entries(group_id, kind, amount, balance_after, note, ts_ms) VALUES (?,?,?,?,?,?)",
|
"INSERT INTO ledger_entries(group_id, kind, amount, balance_after, note, ts_ms) VALUES (?,?,?,?,?,?)",
|
||||||
(group_id, kind, float(amount), equity, note, now),
|
(group_id, kind, float(amount), equity, note, now),
|
||||||
)
|
)
|
||||||
|
if commit:
|
||||||
self.db._conn.commit()
|
self.db._conn.commit()
|
||||||
|
if commit:
|
||||||
try:
|
try:
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from .funds_wallets import SimFundsWallets
|
from .funds_wallets import SimFundsWallets
|
||||||
|
|||||||
+210
-150
@@ -12,7 +12,7 @@ from ..exchange import get_exchange
|
|||||||
from ..models.db import Database, get_db
|
from ..models.db import Database, get_db
|
||||||
from ..strategy.session import get_session
|
from ..strategy.session import get_session
|
||||||
from .ledger import Ledger
|
from .ledger import Ledger
|
||||||
from .liquidity import bid_covers_eth, bid_mark_ok, contracts_for_eth
|
from .liquidity import bid_covers_eth, bid_mark_ok, contracts_for_eth, eth_from_contracts
|
||||||
from .pricing import (
|
from .pricing import (
|
||||||
is_deep_otm,
|
is_deep_otm,
|
||||||
option_expiry_settle,
|
option_expiry_settle,
|
||||||
@@ -837,72 +837,64 @@ class Matcher:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _residual_bid_gate(
|
||||||
|
self, row: dict[str, Any], *, bid: float, oq: Any
|
||||||
|
) -> str | None:
|
||||||
|
"""权利金比例 + 深度 + 买一/标记偏差。通过返回 None。"""
|
||||||
|
s = get_settings()
|
||||||
|
initial_premium = float(row.get("initial_premium") or 0)
|
||||||
|
opt_qty = float(row.get("option_qty_eth") or 0)
|
||||||
|
if initial_premium <= 0 or opt_qty <= 0:
|
||||||
|
return "invalid_initial_premium_or_qty"
|
||||||
|
if bid <= 0:
|
||||||
|
return "option_bid_unavailable"
|
||||||
|
current_premium = float(bid) * opt_qty
|
||||||
|
min_pct = self._residual_min_premium_pct()
|
||||||
|
threshold = initial_premium * (min_pct / 100.0)
|
||||||
|
if current_premium + 1e-12 < threshold:
|
||||||
|
return (
|
||||||
|
f"premium_below_threshold curr={current_premium:.4f} "
|
||||||
|
f"need>={threshold:.4f} ({min_pct:g}%)"
|
||||||
|
)
|
||||||
|
option_inst_id = str(row.get("option_inst_id") or "")
|
||||||
|
ct_mult = self._ct_mult(option_inst_id)
|
||||||
|
if not bid_covers_eth(
|
||||||
|
bid_sz_contracts=getattr(oq, "bid_sz", None),
|
||||||
|
ct_mult=ct_mult,
|
||||||
|
need_eth=opt_qty,
|
||||||
|
):
|
||||||
|
return "option_bid_liquidity_insufficient"
|
||||||
|
max_dev = self.ledger.get_setting_float(
|
||||||
|
"close_bid_mark_max_pct", s.close_bid_mark_max_pct
|
||||||
|
)
|
||||||
|
ok_dev, why = bid_mark_ok(
|
||||||
|
bid=float(bid),
|
||||||
|
mark=getattr(oq, "mark_px", None),
|
||||||
|
max_dev_pct=max_dev,
|
||||||
|
)
|
||||||
|
if not ok_dev:
|
||||||
|
return why or "bid_mark_deviation"
|
||||||
|
return None
|
||||||
|
|
||||||
def _evaluate_residual_premium_close(
|
def _evaluate_residual_premium_close(
|
||||||
self, row: dict[str, Any]
|
self, row: dict[str, Any]
|
||||||
) -> tuple[str | None, float | None, Any]:
|
) -> tuple[str | None, float | None, Any]:
|
||||||
"""
|
"""
|
||||||
残留中途平前置:权利金比例 + 买一流动性。
|
残留中途平前置:权利金比例 + 买一流动性。
|
||||||
返回 (skip_reason, close_bid, option_quote);skip_reason 非空则本轮不卖。
|
返回 (skip_reason, close_bid, option_quote);skip_reason 非空则本轮不卖。
|
||||||
|
成交价口径:最新买一(不再抬到内在价值)。
|
||||||
"""
|
"""
|
||||||
s = get_settings()
|
|
||||||
initial_premium = float(row.get("initial_premium") or 0)
|
|
||||||
opt_qty = float(row.get("option_qty_eth") or 0)
|
|
||||||
if initial_premium <= 0 or opt_qty <= 0:
|
|
||||||
return ("invalid_initial_premium_or_qty", None, None)
|
|
||||||
|
|
||||||
option_inst_id = str(row.get("option_inst_id") or "")
|
option_inst_id = str(row.get("option_inst_id") or "")
|
||||||
if not option_inst_id:
|
if not option_inst_id:
|
||||||
return ("missing_option_inst", None, None)
|
return ("missing_option_inst", None, None)
|
||||||
|
|
||||||
oq = self._quote_held_option(option_inst_id)
|
oq = self._quote_held_option(option_inst_id)
|
||||||
if oq is None or oq.bid is None:
|
if oq is None or oq.bid is None:
|
||||||
return ("option_bid_unavailable", None, None)
|
return ("option_bid_unavailable", None, None)
|
||||||
|
|
||||||
close_bid = float(oq.bid)
|
close_bid = float(oq.bid)
|
||||||
current_premium = close_bid * opt_qty
|
skip = self._residual_bid_gate(row, bid=close_bid, oq=oq)
|
||||||
min_pct = self._residual_min_premium_pct()
|
if skip:
|
||||||
threshold = initial_premium * (min_pct / 100.0)
|
return (skip, None, None)
|
||||||
if current_premium + 1e-12 < threshold:
|
return (None, close_bid, oq)
|
||||||
return (
|
|
||||||
f"premium_below_threshold curr={current_premium:.4f} "
|
|
||||||
f"need>={threshold:.4f} ({min_pct:g}%)",
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
ct_mult = self._ct_mult(option_inst_id)
|
|
||||||
if not bid_covers_eth(
|
|
||||||
bid_sz_contracts=oq.bid_sz,
|
|
||||||
ct_mult=ct_mult,
|
|
||||||
need_eth=opt_qty,
|
|
||||||
):
|
|
||||||
return ("option_bid_liquidity_insufficient", None, None)
|
|
||||||
|
|
||||||
max_dev = self.ledger.get_setting_float(
|
|
||||||
"close_bid_mark_max_pct", s.close_bid_mark_max_pct
|
|
||||||
)
|
|
||||||
ok_dev, why = bid_mark_ok(bid=close_bid, mark=oq.mark_px, max_dev_pct=max_dev)
|
|
||||||
if not ok_dev:
|
|
||||||
return (why or "bid_mark_deviation", None, None)
|
|
||||||
|
|
||||||
strike = row.get("strike")
|
|
||||||
spot = self._close_spot_px(get_session().snapshot())
|
|
||||||
intrinsic: float | None = None
|
|
||||||
if strike is not None and spot is not None:
|
|
||||||
intrinsic = option_intrinsic(
|
|
||||||
option_side=str(row["option_side"]),
|
|
||||||
strike=float(strike),
|
|
||||||
spot=float(spot),
|
|
||||||
)
|
|
||||||
resolved = resolve_option_close_bid(
|
|
||||||
bid=close_bid,
|
|
||||||
mark=oq.mark_px,
|
|
||||||
intrinsic=intrinsic,
|
|
||||||
bypass_liquidity=False,
|
|
||||||
)
|
|
||||||
if resolved is None:
|
|
||||||
return ("option_close_px_unavailable", None, None)
|
|
||||||
return (None, float(resolved), oq)
|
|
||||||
|
|
||||||
def _book_residual_market_close(
|
def _book_residual_market_close(
|
||||||
self,
|
self,
|
||||||
@@ -915,21 +907,81 @@ class Matcher:
|
|||||||
now_ms: int,
|
now_ms: int,
|
||||||
note: str,
|
note: str,
|
||||||
exec_mode: str | None = None,
|
exec_mode: str | None = None,
|
||||||
) -> dict[str, Any]:
|
filled_contracts: float | None = None,
|
||||||
"""买一卖出残留后的入账与结清(SIM/LIVE 共用)。"""
|
remaining_contracts: float | None = None,
|
||||||
|
close_reason: str = "residual_premium_close",
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""买一卖出残留后的入账(与 pending 状态同事务)。支持部分成交扣减数量。"""
|
||||||
group_id = str(row["group_id"])
|
group_id = str(row["group_id"])
|
||||||
opt_qty = float(row["option_qty_eth"])
|
option_inst_id = str(row["option_inst_id"])
|
||||||
|
ct_mult = self._ct_mult(option_inst_id)
|
||||||
|
local_c = float(row.get("option_qty_contracts") or 0)
|
||||||
|
local_eth = float(row.get("option_qty_eth") or 0)
|
||||||
|
zero_fill_ok = (
|
||||||
|
filled_contracts is not None
|
||||||
|
and float(filled_contracts) <= 1e-12
|
||||||
|
and remaining_contracts is not None
|
||||||
|
and float(remaining_contracts) <= 1e-12
|
||||||
|
)
|
||||||
|
if filled_contracts is not None and float(filled_contracts) > 0:
|
||||||
|
fill_c = float(filled_contracts)
|
||||||
|
fill_eth = eth_from_contracts(fill_c, ct_mult)
|
||||||
|
elif zero_fill_ok:
|
||||||
|
fill_c = 0.0
|
||||||
|
fill_eth = 0.0
|
||||||
|
else:
|
||||||
|
fill_eth = local_eth
|
||||||
|
fill_c = local_c if local_c > 0 else contracts_for_eth(fill_eth, ct_mult)
|
||||||
|
if fill_eth <= 0 and not zero_fill_ok:
|
||||||
|
return None
|
||||||
|
fill_notional = (
|
||||||
|
float(notional)
|
||||||
|
if float(notional) > 0
|
||||||
|
else float(fill_px) * fill_eth
|
||||||
|
)
|
||||||
opt_entry = float(row["option_entry_px"])
|
opt_entry = float(row["option_entry_px"])
|
||||||
opt_pnl = (float(fill_px) - opt_entry) * opt_qty
|
opt_pnl = (float(fill_px) - opt_entry) * fill_eth if fill_eth > 0 else 0.0
|
||||||
opt_cash = float(notional) - float(fee)
|
opt_cash = fill_notional - float(fee)
|
||||||
|
allow_neg = not get_settings().is_sim
|
||||||
|
|
||||||
|
if remaining_contracts is not None:
|
||||||
|
rem_c = max(0.0, float(remaining_contracts))
|
||||||
|
else:
|
||||||
|
rem_c = max(0.0, local_c - fill_c) if local_c > 0 else 0.0
|
||||||
|
rem_eth = eth_from_contracts(rem_c, ct_mult) if rem_c > 0 else 0.0
|
||||||
|
fully_done = rem_c <= 1e-8
|
||||||
|
|
||||||
|
with self.db._lock:
|
||||||
|
pending = self.db._conn.execute(
|
||||||
|
"SELECT * FROM residual_options WHERE group_id=? AND status='pending'",
|
||||||
|
(group_id,),
|
||||||
|
).fetchone()
|
||||||
|
if pending is None:
|
||||||
|
logger.warning(
|
||||||
|
"residual book skip %s: not pending (already settled?)", group_id
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if abs(opt_cash) > 1e-12:
|
||||||
self.ledger.apply_cash(
|
self.ledger.apply_cash(
|
||||||
opt_cash,
|
opt_cash,
|
||||||
kind="close_option",
|
kind="close_option",
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
note=note,
|
note=note,
|
||||||
allow_negative=not get_settings().is_sim,
|
allow_negative=allow_neg,
|
||||||
|
commit=False,
|
||||||
)
|
)
|
||||||
|
if get_settings().is_sim:
|
||||||
|
try:
|
||||||
|
from .funds_wallets import SimFundsWallets
|
||||||
|
|
||||||
|
SimFundsWallets(self.db).mirror_cash(
|
||||||
|
float(opt_cash), kind="close_option"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if fill_eth > 1e-12 or float(fee) > 1e-12:
|
||||||
fill_cols = (
|
fill_cols = (
|
||||||
"group_id, leg, action, side, inst_id, qty_eth, qty_contracts, "
|
"group_id, leg, action, side, inst_id, qty_eth, qty_contracts, "
|
||||||
"base_px, fill_px, fee, slip, notional, ts_ms"
|
"base_px, fill_px, fee, slip, notional, ts_ms"
|
||||||
@@ -939,21 +991,19 @@ class Matcher:
|
|||||||
"option",
|
"option",
|
||||||
"close",
|
"close",
|
||||||
"flat",
|
"flat",
|
||||||
str(row["option_inst_id"]),
|
option_inst_id,
|
||||||
opt_qty,
|
fill_eth,
|
||||||
float(row["option_qty_contracts"] or 0),
|
fill_c,
|
||||||
float(fill_px),
|
float(fill_px),
|
||||||
float(fill_px),
|
float(fill_px),
|
||||||
float(fee),
|
float(fee),
|
||||||
float(slip),
|
float(slip),
|
||||||
float(notional),
|
fill_notional,
|
||||||
now_ms,
|
now_ms,
|
||||||
]
|
]
|
||||||
if exec_mode:
|
if exec_mode:
|
||||||
fill_cols += ", exec_mode"
|
fill_cols += ", exec_mode"
|
||||||
fill_vals.append(exec_mode)
|
fill_vals.append(exec_mode)
|
||||||
|
|
||||||
with self.db._lock:
|
|
||||||
self.db._conn.execute(
|
self.db._conn.execute(
|
||||||
f"""INSERT INTO fills({fill_cols})
|
f"""INSERT INTO fills({fill_cols})
|
||||||
VALUES ({",".join("?" for _ in fill_vals)})""",
|
VALUES ({",".join("?" for _ in fill_vals)})""",
|
||||||
@@ -973,9 +1023,11 @@ class Matcher:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
fees = float(g["fees"] or 0) + float(fee) if g else float(fee)
|
fees = float(g["fees"] or 0) + float(fee) if g else float(fee)
|
||||||
slip_total = float(g["slip_cost"] or 0) + float(slip) if g else float(slip)
|
slip_total = float(g["slip_cost"] or 0) + float(slip) if g else float(slip)
|
||||||
self.db._conn.execute(
|
|
||||||
|
if fully_done:
|
||||||
|
cur = self.db._conn.execute(
|
||||||
"""UPDATE residual_options SET status=?, settled_at_ms=?, settle_px=?, settle_pnl=?, note=?
|
"""UPDATE residual_options SET status=?, settled_at_ms=?, settle_px=?, settle_pnl=?, note=?
|
||||||
WHERE group_id=?""",
|
WHERE group_id=? AND status='pending'""",
|
||||||
(
|
(
|
||||||
"settled",
|
"settled",
|
||||||
now_ms,
|
now_ms,
|
||||||
@@ -985,20 +1037,48 @@ class Matcher:
|
|||||||
group_id,
|
group_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if cur.rowcount != 1:
|
||||||
|
self.db._conn.rollback()
|
||||||
|
logger.warning("residual settle race %s", group_id)
|
||||||
|
return None
|
||||||
self.db._conn.execute(
|
self.db._conn.execute(
|
||||||
"""UPDATE groups SET status=?, close_at_ms=COALESCE(close_at_ms, ?),
|
"""UPDATE groups SET status=?, close_at_ms=COALESCE(close_at_ms, ?),
|
||||||
close_reason=COALESCE(close_reason, ?), realized_pnl=?, fees=?, slip_cost=?
|
close_reason=?, realized_pnl=?, fees=?, slip_cost=?
|
||||||
WHERE group_id=?""",
|
WHERE group_id=?""",
|
||||||
(
|
(
|
||||||
"closed",
|
"closed",
|
||||||
now_ms,
|
now_ms,
|
||||||
"residual_premium_close",
|
close_reason,
|
||||||
float(net),
|
float(net),
|
||||||
fees,
|
fees,
|
||||||
slip_total,
|
slip_total,
|
||||||
group_id,
|
group_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# 按初始权利金比例缩减门槛基准,避免部分成交后永远达不到原 20%
|
||||||
|
init_prem = float(pending["initial_premium"] or 0)
|
||||||
|
if local_eth > 1e-12 and rem_eth > 0:
|
||||||
|
init_prem = init_prem * (rem_eth / local_eth)
|
||||||
|
cur = self.db._conn.execute(
|
||||||
|
"""UPDATE residual_options SET
|
||||||
|
option_qty_eth=?, option_qty_contracts=?, initial_premium=?, note=?
|
||||||
|
WHERE group_id=? AND status='pending'""",
|
||||||
|
(
|
||||||
|
rem_eth,
|
||||||
|
rem_c,
|
||||||
|
init_prem,
|
||||||
|
f"{note}; partial rem_c={rem_c}",
|
||||||
|
group_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cur.rowcount != 1:
|
||||||
|
self.db._conn.rollback()
|
||||||
|
return None
|
||||||
|
self.db._conn.execute(
|
||||||
|
"""UPDATE groups SET realized_pnl=?, fees=?, slip_cost=? WHERE group_id=?""",
|
||||||
|
(float(net), fees, slip_total, group_id),
|
||||||
|
)
|
||||||
self.db._conn.commit()
|
self.db._conn.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1006,9 +1086,12 @@ class Matcher:
|
|||||||
"option_pnl": opt_pnl,
|
"option_pnl": opt_pnl,
|
||||||
"settle_px": float(fill_px),
|
"settle_px": float(fill_px),
|
||||||
"net_pnl": float(net),
|
"net_pnl": float(net),
|
||||||
"reason": "residual_premium_close",
|
"reason": close_reason,
|
||||||
"current_premium": float(fill_px) * opt_qty,
|
"current_premium": float(fill_px) * fill_eth,
|
||||||
"initial_premium": float(row.get("initial_premium") or 0),
|
"initial_premium": float(row.get("initial_premium") or 0),
|
||||||
|
"filled_contracts": fill_c,
|
||||||
|
"remaining_contracts": rem_c,
|
||||||
|
"fully_done": fully_done,
|
||||||
}
|
}
|
||||||
|
|
||||||
def try_close_one_residual(self, row: dict[str, Any]) -> dict[str, Any] | None:
|
def try_close_one_residual(self, row: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
@@ -1022,10 +1105,22 @@ class Matcher:
|
|||||||
skip,
|
skip,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
# 下单前再刷买一并重跑门槛
|
||||||
|
option_inst_id = str(row.get("option_inst_id") or "")
|
||||||
|
oq2 = self._quote_held_option(option_inst_id) or oq
|
||||||
|
bid2 = float(oq2.bid) if oq2.bid is not None else float(close_bid)
|
||||||
|
skip2 = self._residual_bid_gate(row, bid=bid2, oq=oq2)
|
||||||
|
if skip2:
|
||||||
|
logger.debug(
|
||||||
|
"residual premium close recheck skip %s: %s",
|
||||||
|
row.get("group_id"),
|
||||||
|
skip2,
|
||||||
|
)
|
||||||
|
return None
|
||||||
of = option_fill(
|
of = option_fill(
|
||||||
action="close",
|
action="close",
|
||||||
bid=float(close_bid),
|
bid=float(bid2),
|
||||||
ask=float(oq.ask or close_bid),
|
ask=float(oq2.ask or bid2),
|
||||||
qty_eth=float(row["option_qty_eth"]),
|
qty_eth=float(row["option_qty_eth"]),
|
||||||
fee_rate=self._fee_rate(),
|
fee_rate=self._fee_rate(),
|
||||||
)
|
)
|
||||||
@@ -1037,7 +1132,9 @@ class Matcher:
|
|||||||
notional=of.notional,
|
notional=of.notional,
|
||||||
slip=of.slip,
|
slip=of.slip,
|
||||||
now_ms=now_ms,
|
now_ms=now_ms,
|
||||||
note=f"residual mid-close at bid px={close_bid}",
|
note=f"residual mid-close at bid px={bid2}",
|
||||||
|
filled_contracts=float(row.get("option_qty_contracts") or 0) or None,
|
||||||
|
remaining_contracts=0.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
def try_close_pending_residuals(self) -> list[dict[str, Any]]:
|
def try_close_pending_residuals(self) -> list[dict[str, Any]]:
|
||||||
@@ -1095,16 +1192,44 @@ class Matcher:
|
|||||||
out.append(r)
|
out.append(r)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def _try_exchange_flatten_residual(
|
||||||
|
self, row: dict[str, Any], *, force: bool = False
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""LIVE 覆盖:尽量在交易所卖掉残留。成功返回 fill 字段字典。"""
|
||||||
|
return None
|
||||||
|
|
||||||
def _settle_one_residual(
|
def _settle_one_residual(
|
||||||
self, row: dict[str, Any], *, now_ms: int, force: bool = False
|
self, row: dict[str, Any], *, now_ms: int, force: bool = False
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
group_id = str(row["group_id"])
|
group_id = str(row["group_id"])
|
||||||
|
# LIVE:优先交易所卖出再入账
|
||||||
|
ex_fill = self._try_exchange_flatten_residual(row, force=force)
|
||||||
|
if ex_fill is not None:
|
||||||
|
booked = self._book_residual_market_close(
|
||||||
|
row,
|
||||||
|
fill_px=float(ex_fill["fill_px"]),
|
||||||
|
fee=float(ex_fill.get("fee") or 0),
|
||||||
|
notional=float(ex_fill["notional"]),
|
||||||
|
slip=float(ex_fill.get("slip") or 0),
|
||||||
|
now_ms=now_ms,
|
||||||
|
note=str(ex_fill.get("note") or "residual exchange settle"),
|
||||||
|
exec_mode=ex_fill.get("exec_mode"),
|
||||||
|
filled_contracts=ex_fill.get("filled_contracts"),
|
||||||
|
remaining_contracts=float(ex_fill.get("remaining_contracts") or 0),
|
||||||
|
close_reason=str(
|
||||||
|
ex_fill.get("close_reason")
|
||||||
|
or ("emergency" if force else "expiry")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if booked is not None:
|
||||||
|
booked["forced"] = force
|
||||||
|
return booked
|
||||||
|
|
||||||
sess = get_session()
|
sess = get_session()
|
||||||
snap = sess.snapshot()
|
snap = sess.snapshot()
|
||||||
spot = self._close_spot_px(snap)
|
spot = self._close_spot_px(snap)
|
||||||
strike = row["strike"]
|
strike = row["strike"]
|
||||||
if strike is None or spot is None:
|
if strike is None or spot is None:
|
||||||
logger = __import__("logging").getLogger(__name__)
|
|
||||||
logger.warning("residual settle skip %s: no strike/spot", group_id)
|
logger.warning("residual settle skip %s: no strike/spot", group_id)
|
||||||
return None
|
return None
|
||||||
fee_rate = self._fee_rate()
|
fee_rate = self._fee_rate()
|
||||||
@@ -1118,84 +1243,19 @@ class Matcher:
|
|||||||
qty_eth=float(row["option_qty_eth"]),
|
qty_eth=float(row["option_qty_eth"]),
|
||||||
fee_rate=fee_rate,
|
fee_rate=fee_rate,
|
||||||
)
|
)
|
||||||
opt_entry = float(row["option_entry_px"])
|
return self._book_residual_market_close(
|
||||||
opt_qty = float(row["option_qty_eth"])
|
row,
|
||||||
opt_pnl = (of.fill_px - opt_entry) * opt_qty
|
fill_px=of.fill_px,
|
||||||
opt_cash = of.notional - of.fee
|
fee=of.fee,
|
||||||
from ..config import get_settings
|
notional=of.notional,
|
||||||
|
slip=of.slip,
|
||||||
self.ledger.apply_cash(
|
now_ms=now_ms,
|
||||||
opt_cash,
|
|
||||||
kind="close_option",
|
|
||||||
group_id=group_id,
|
|
||||||
note=f"residual option expiry settle{' force' if force else ''}",
|
note=f"residual option expiry settle{' force' if force else ''}",
|
||||||
# LIVE 本地账本仅镜像;拒记会导致 residual 永久 pending
|
filled_contracts=float(row.get("option_qty_contracts") or 0) or None,
|
||||||
allow_negative=not get_settings().is_sim,
|
remaining_contracts=0.0,
|
||||||
|
close_reason="expiry" if not force else "emergency",
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.db._lock:
|
|
||||||
self.db._conn.execute(
|
|
||||||
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
|
|
||||||
base_px, fill_px, fee, slip, notional, ts_ms)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
||||||
(
|
|
||||||
group_id,
|
|
||||||
"option",
|
|
||||||
"close",
|
|
||||||
"flat",
|
|
||||||
str(row["option_inst_id"]),
|
|
||||||
opt_qty,
|
|
||||||
float(row["option_qty_contracts"] or 0),
|
|
||||||
of.base_px,
|
|
||||||
of.fill_px,
|
|
||||||
of.fee,
|
|
||||||
of.slip,
|
|
||||||
of.notional,
|
|
||||||
now_ms,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
fills = self.db._conn.execute(
|
|
||||||
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
|
|
||||||
).fetchall()
|
|
||||||
from ..sim.pnl import summarize_fills_pnl
|
|
||||||
|
|
||||||
summary = summarize_fills_pnl(list(fills))
|
|
||||||
net = summary.get("net_pnl")
|
|
||||||
if net is None:
|
|
||||||
net = opt_pnl - of.fee
|
|
||||||
g = self.db._conn.execute(
|
|
||||||
"SELECT fees, slip_cost FROM groups WHERE group_id=?", (group_id,)
|
|
||||||
).fetchone()
|
|
||||||
fees = float(g["fees"] or 0) + of.fee
|
|
||||||
slip = float(g["slip_cost"] or 0) + of.slip
|
|
||||||
self.db._conn.execute(
|
|
||||||
"""UPDATE residual_options SET status=?, settled_at_ms=?, settle_px=?, settle_pnl=?, note=?
|
|
||||||
WHERE group_id=?""",
|
|
||||||
(
|
|
||||||
"settled",
|
|
||||||
now_ms,
|
|
||||||
of.fill_px,
|
|
||||||
opt_pnl,
|
|
||||||
"settled at intrinsic",
|
|
||||||
group_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self.db._conn.execute(
|
|
||||||
"""UPDATE groups SET status=?, close_at_ms=COALESCE(close_at_ms, ?),
|
|
||||||
realized_pnl=?, fees=?, slip_cost=?
|
|
||||||
WHERE group_id=?""",
|
|
||||||
("closed", now_ms, float(net), fees, slip, group_id),
|
|
||||||
)
|
|
||||||
self.db._conn.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"group_id": group_id,
|
|
||||||
"option_pnl": opt_pnl,
|
|
||||||
"settle_px": of.fill_px,
|
|
||||||
"net_pnl": net,
|
|
||||||
"forced": force,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _quote_held_option(self, option_inst_id: str):
|
def _quote_held_option(self, option_inst_id: str):
|
||||||
"""只取持仓合约盘口;缺失时 REST 补一次,绝不借用 ATM 对。"""
|
"""只取持仓合约盘口;缺失时 REST 补一次,绝不借用 ATM 对。"""
|
||||||
if not option_inst_id:
|
if not option_inst_id:
|
||||||
|
|||||||
@@ -570,6 +570,8 @@ class StrategyEngine:
|
|||||||
if not closed:
|
if not closed:
|
||||||
return
|
return
|
||||||
for item in closed:
|
for item in closed:
|
||||||
|
if isinstance(item, dict) and item.get("fully_done") is False:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
from ..notify import wecom
|
from ..notify import wecom
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,61 @@ def test_residual_liquidity_fail_skips(tmp_path, monkeypatch) -> None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_residual_recheck_bid_drop_skips(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("MODE", "SIM")
|
||||||
|
db = Database(tmp_path / "recheck.db")
|
||||||
|
db.set_setting("residual_min_premium_pct", "20")
|
||||||
|
_seed_residual(db, initial_premium=100.0, qty=2.0)
|
||||||
|
m = Matcher(db)
|
||||||
|
|
||||||
|
good = SimpleNamespace(bid=15.0, ask=15.5, bid_sz=10_000.0, mark_px=15.0)
|
||||||
|
bad = SimpleNamespace(bid=5.0, ask=5.5, bid_sz=10_000.0, mark_px=5.0)
|
||||||
|
quotes = iter([good, bad])
|
||||||
|
monkeypatch.setattr(m, "_quote_held_option", lambda _id: next(quotes))
|
||||||
|
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
|
||||||
|
monkeypatch.setattr(m, "_ct_mult", lambda _id: 0.01)
|
||||||
|
|
||||||
|
assert m.try_close_one_residual(m.list_residual_options()[0]) is None
|
||||||
|
row = db.fetchone(
|
||||||
|
"SELECT status FROM residual_options WHERE group_id=?", ("G-res",)
|
||||||
|
)
|
||||||
|
assert row is not None and row["status"] == "pending"
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_residual_book_pending_guard(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("MODE", "SIM")
|
||||||
|
db = Database(tmp_path / "guard.db")
|
||||||
|
_seed_residual(db, initial_premium=100.0, qty=2.0)
|
||||||
|
m = Matcher(db)
|
||||||
|
row = m.list_residual_options()[0]
|
||||||
|
first = m._book_residual_market_close(
|
||||||
|
row,
|
||||||
|
fill_px=15.0,
|
||||||
|
fee=0.01,
|
||||||
|
notional=30.0,
|
||||||
|
slip=0.0,
|
||||||
|
now_ms=1_700_000_100_000,
|
||||||
|
note="first",
|
||||||
|
filled_contracts=200.0,
|
||||||
|
remaining_contracts=0.0,
|
||||||
|
)
|
||||||
|
assert first is not None and first.get("fully_done") is True
|
||||||
|
second = m._book_residual_market_close(
|
||||||
|
row,
|
||||||
|
fill_px=15.0,
|
||||||
|
fee=0.01,
|
||||||
|
notional=30.0,
|
||||||
|
slip=0.0,
|
||||||
|
now_ms=1_700_000_200_000,
|
||||||
|
note="second",
|
||||||
|
filled_contracts=200.0,
|
||||||
|
remaining_contracts=0.0,
|
||||||
|
)
|
||||||
|
assert second is None
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def test_settings_exposes_residual_min_premium_pct(tmp_path, monkeypatch) -> None:
|
def test_settings_exposes_residual_min_premium_pct(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setenv("MODE", "SIM")
|
monkeypatch.setenv("MODE", "SIM")
|
||||||
from app.api import settings as settings_api
|
from app.api import settings as settings_api
|
||||||
|
|||||||
Reference in New Issue
Block a user