Fix LIVE SoT P0/P1: closing state machine, OO exchange fills, BN balances.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-08 15:54:45 +08:00
parent 99e58910d3
commit 8d67f3fc6c
10 changed files with 864 additions and 167 deletions
+187 -57
View File
@@ -528,29 +528,52 @@ class BinanceLiveExecutor(Matcher):
option_inst_id = str(pos.get("option_inst_id") or "")
opt_contracts = float(pos.get("option_qty_contracts") or 0)
opt_qty = float(pos.get("option_qty_eth") or 0)
if not option_inst_id or opt_contracts <= 0:
if not option_inst_id:
return CloseResult(ok=False, detail="half_open 缺期权合约信息")
client = self._client()
try:
opt_live = client.place_option_market(
symbol=option_inst_id,
side="SELL",
quantity=opt_contracts,
reduce_only=True,
ex_sz = exchange_option_abs_size(client, option_inst_id)
if ex_sz is None:
return CloseResult(ok=False, detail="half_open:无法核对交易所期权仓位")
if ex_sz <= 1e-8:
of_px, of_fee, of_notional = 0.0, 0.0, 0.0
opt_contracts = 0.0
opt_qty = 0.0
opt_entry = float(pos.get("option_entry_px") or 0)
else:
opt_contracts = float(ex_sz)
opt_qty = eth_from_contracts(opt_contracts, self._ct_mult(option_inst_id))
try:
opt_live = client.place_option_market(
symbol=option_inst_id,
side="SELL",
quantity=opt_contracts,
reduce_only=True,
)
except Exception as e:
return CloseResult(ok=False, detail=f"half_open 平期权失败: {e}")
of_px = float(opt_live.avg_px)
of_fee = float(opt_live.fee)
filled = float(opt_live.sz) if opt_live.sz and float(opt_live.sz) > 0 else 0.0
if filled > 0:
opt_contracts = filled
opt_qty = eth_from_contracts(
opt_contracts, self._ct_mult(option_inst_id)
)
of_notional = of_px * opt_qty
ex_left = exchange_option_abs_size(client, option_inst_id)
if ex_left is None or ex_left > 1e-8:
return CloseResult(
ok=False,
detail=f"half_open:卖后仍有仓或无法核对 left={ex_left}",
)
opt_entry = float(pos.get("option_entry_px") or of_px)
self.ledger.apply_cash(
of_notional - of_fee,
kind="close_option",
group_id=group_id or None,
note="LIVE-BN repair half_open",
allow_negative=True,
)
except Exception as e:
return CloseResult(ok=False, detail=f"half_open 平期权失败: {e}")
of_px = float(opt_live.avg_px)
of_fee = float(opt_live.fee)
of_notional = of_px * opt_qty
opt_entry = float(pos.get("option_entry_px") or of_px)
self.ledger.apply_cash(
of_notional - of_fee,
kind="close_option",
group_id=group_id or None,
note="LIVE-BN repair half_open",
allow_negative=True,
)
now = int(time.time() * 1000)
with self.db._lock:
if group_id:
@@ -961,7 +984,8 @@ class BinanceLiveExecutor(Matcher):
if err:
return CloseResult(ok=False, detail=err)
pos = self.current_position()
if str(pos.get("status") or "") != "open" or not pos.get("group_id"):
st = str(pos.get("status") or "")
if st not in ("open", "closing") or not pos.get("group_id"):
return CloseResult(ok=False, detail="无期期持仓可平")
if not (
str(pos.get("hedge_mode") or "") == "option_option"
@@ -1022,6 +1046,13 @@ class BinanceLiveExecutor(Matcher):
),
)
self.db._conn.commit()
from ..sim.pnl import summarize_fills_pnl
fill_rows = self.db.fetchall(
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
)
summary = summarize_fills_pnl(list(fill_rows))
net = float(summary.get("net_pnl") or 0.0)
with self.db._lock:
self.db._conn.execute(
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
@@ -1030,7 +1061,7 @@ class BinanceLiveExecutor(Matcher):
"closed",
int(time.time() * 1000),
reason,
0.0,
net,
f"oo full close {reason} exchange_flat_mirror",
group_id,
),
@@ -1050,7 +1081,7 @@ class BinanceLiveExecutor(Matcher):
return CloseResult(
ok=True,
detail="oo_full_closed_live_bn",
data={"group_id": group_id, "reason": reason, "net": 0.0},
data={"group_id": group_id, "reason": reason, "net": net},
)
def close_winning_oo_leave_residual(
@@ -1060,31 +1091,46 @@ class BinanceLiveExecutor(Matcher):
if err:
return CloseResult(ok=False, detail=err)
pos = self.current_position()
if str(pos.get("status") or "") == "closing":
return super().close_winning_oo_leave_residual(
reason=reason, skip_market=True
)
if str(pos.get("status") or "") != "open" or not pos.get("option2_inst_id"):
st = str(pos.get("status") or "")
if st == "closing":
return self._finish_oo_win_after_exchange(reason=reason, pos=pos)
if st != "open" or not pos.get("option2_inst_id"):
return CloseResult(ok=False, detail="无期期持仓")
upl = self.unrealized()
call_upl = float(upl.get("option_upl") or 0)
put_upl = float(upl.get("option2_upl") or 0)
if call_upl >= put_upl and call_upl > 0:
win_leg = "option"
win_id = str(pos["option_inst_id"])
win_contracts = float(pos.get("option_qty_contracts") or 0)
elif put_upl > 0:
win_leg = "option2"
win_id = str(pos["option2_inst_id"])
win_contracts = float(pos.get("option2_qty_contracts") or 0)
else:
return CloseResult(ok=False, detail="无明确盈利腿")
client = self._client()
ex_sz = exchange_option_abs_size(client, win_id)
if ex_sz is None:
return CloseResult(ok=False, detail="期期平盈利腿:无法核对交易所仓位")
if ex_sz <= 1e-8:
return self._finish_oo_win_after_exchange(
reason=reason,
pos=pos,
win_leg=win_leg,
fill_px=0.0,
fill_fee=0.0,
fill_c=0.0,
)
with self.db._lock:
self.db._conn.execute(
"UPDATE positions SET status='closing' WHERE id=1 AND status='open'"
)
self.db._conn.commit()
try:
self._client().place_option_market(
symbol=win_id, side="SELL", quantity=win_contracts
live = client.place_option_market(
symbol=win_id,
side="SELL",
quantity=float(ex_sz),
reduce_only=True,
)
except Exception as e:
with self.db._lock:
@@ -1093,8 +1139,62 @@ class BinanceLiveExecutor(Matcher):
)
self.db._conn.commit()
return CloseResult(ok=False, detail=f"期期平盈利腿失败: {e}")
fill_c = float(live.sz) if live.sz and float(live.sz) > 0 else 0.0
return self._finish_oo_win_after_exchange(
reason=reason,
pos=self.current_position(),
win_leg=win_leg,
fill_px=float(live.avg_px),
fill_fee=float(live.fee),
fill_c=fill_c,
)
def _finish_oo_win_after_exchange(
self,
*,
reason: str,
pos: dict,
win_leg: str | None = None,
fill_px: float | None = None,
fill_fee: float | None = None,
fill_c: float | None = None,
) -> CloseResult:
client = self._client()
call_id = str(pos.get("option_inst_id") or "")
put_id = str(pos.get("option2_inst_id") or "")
if not win_leg:
c_sz = exchange_option_abs_size(client, call_id) if call_id else None
p_sz = exchange_option_abs_size(client, put_id) if put_id else None
if c_sz is None or p_sz is None:
return CloseResult(
ok=False, detail="closing 收尾:无法核对交易所两腿仓位"
)
if c_sz <= 1e-8 and p_sz > 1e-8:
win_leg = "option"
elif p_sz <= 1e-8 and c_sz > 1e-8:
win_leg = "option2"
elif c_sz <= 1e-8 and p_sz <= 1e-8:
return self.close_oo_full(reason=reason, bypass_liquidity=True)
else:
return CloseResult(
ok=False, detail="closing 收尾:盈利腿仍在交易所,请重试卖出"
)
win_id = call_id if win_leg == "option" else put_id
ex_win = exchange_option_abs_size(client, win_id)
if ex_win is None:
return CloseResult(ok=False, detail="closing 收尾:无法核对盈利腿仓位")
if ex_win > 1e-8:
return CloseResult(
ok=False,
detail=f"closing 收尾:盈利腿仍有仓 {ex_win},禁止本地清仓",
)
return super().close_winning_oo_leave_residual(
reason=reason, skip_market=True
reason=reason,
skip_market=True,
live_fill_px=0.0 if fill_px is None else float(fill_px),
live_fill_fee=0.0 if fill_fee is None else float(fill_fee),
live_fill_contracts=fill_c,
live_win_leg=win_leg,
)
def close_group(self, *, reason: str, bypass_liquidity: bool = False) -> CloseResult:
@@ -1109,6 +1209,18 @@ class BinanceLiveExecutor(Matcher):
return self.recover_opening()
if st == "half_open":
return self.repair_half_open()
if st == "closing":
if pos.get("option2_inst_id") or str(pos.get("hedge_mode") or "") == "option_option":
return self.close_winning_oo_leave_residual(
reason=reason or "closing_retry"
)
return CloseResult(ok=False, detail="closing 非期期状态,请人工核对")
is_oo = (
str(pos.get("hedge_mode") or "") == "option_option"
or bool(pos.get("option2_inst_id"))
)
if is_oo and st == "open":
return self.close_oo_full(reason=reason, bypass_liquidity=bypass_liquidity)
if st not in ("open", "option_closed_perp_pending") or not pos.get("group_id"):
return CloseResult(ok=False, detail="无持仓可平")
@@ -1158,9 +1270,9 @@ class BinanceLiveExecutor(Matcher):
of_fee = float(prev["fee"] or 0)
of_notional = float(prev["notional"] or (of_px * opt_qty))
else:
of_px = float(intrinsic) if intrinsic is not None else 0.0
of_px = 0.0
of_fee = 0.0
of_notional = of_px * opt_qty
of_notional = 0.0
self._ensure_option_closed_perp_pending(
group_id=group_id,
option_inst_id=option_inst_id,
@@ -1176,9 +1288,17 @@ class BinanceLiveExecutor(Matcher):
of_slip = 0.0
option_apply_cash = False
elif is_expiry:
of_px = float(intrinsic) if intrinsic is not None else 0.0
ex_opt = exchange_option_abs_size(client, option_inst_id)
if ex_opt is not None and ex_opt > 1e-8:
logger.warning(
"bn expiry: option still on exchange sz=%.4f group=%s; "
"skip option, close perp only",
ex_opt,
group_id,
)
of_px = 0.0
of_fee = 0.0
of_notional = of_px * opt_qty
of_notional = 0.0
of_slip = 0.0
option_apply_cash = False
logger.info(
@@ -1960,35 +2080,45 @@ class BinanceLiveExecutor(Matcher):
perp_qty_eth=perp_qty,
allow_db_fallback=False,
)
if perp_qty_close is None or perp_qty_close <= 0:
if perp_qty_close is None:
return CloseResult(
ok=False,
detail="币安平永续失败: 无法取得有效永续仓位数量",
detail="币安平永续失败: 无法核对交易所永续仓位",
)
if perp_qty_close > 0:
perp_live = client.place_perp_market(
symbol=perp_inst,
side=side,
qty_eth=perp_qty_close,
position_side=pos_side,
reduce_only=True,
)
pf_px = float(perp_live.avg_px)
pf_fee = float(perp_live.fee)
perp_qty = float(perp_qty_close)
else:
pf_px = 0.0
pf_fee = 0.0
logger.warning(
"bn abandon: perp already flat; archive option residual group=%s",
group_id,
)
perp_live = client.place_perp_market(
symbol=perp_inst,
side=side,
qty_eth=perp_qty_close,
position_side=pos_side,
reduce_only=True,
)
except Exception as e:
return CloseResult(ok=False, detail=f"币安平永续失败: {e}")
pf_px = float(perp_live.avg_px)
pf_fee = float(perp_live.fee)
if perp_side == "long":
perp_pnl = (pf_px - perp_entry) * perp_qty
perp_pnl = (pf_px - perp_entry) * perp_qty if pf_px else 0.0
else:
perp_pnl = (perp_entry - pf_px) * perp_qty
perp_pnl = (perp_entry - pf_px) * perp_qty if pf_px else 0.0
self.ledger.apply_cash(
perp_pnl - pf_fee,
kind="close_perp",
group_id=group_id,
note=f"LIVE-BN close perp abandon option {reason}",
allow_negative=True,
)
if abs(perp_pnl) + abs(pf_fee) > 1e-12:
self.ledger.apply_cash(
perp_pnl - pf_fee,
kind="close_perp",
group_id=group_id,
note=f"LIVE-BN close perp abandon option {reason}",
allow_negative=True,
)
strike = self._group_strike(group_id, option_inst_id)
g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
+59
View File
@@ -356,6 +356,65 @@ class BinanceTradeClient:
return to_usdt(float(upl), "USDT")
return 0.0
def fetch_balances(self) -> dict[str, float | None]:
"""交易侧可用:USDT-M 钱包 USDT + 期权账户 USDT/USDC(尽力而为)。"""
out: dict[str, float | None] = {
"trading_usdt": None,
"trading_usdc": None,
}
try:
rows = self._signed(self._fapi, "GET", "/fapi/v2/balance")
if isinstance(rows, dict):
rows = [rows]
for row in rows or []:
if not isinstance(row, dict):
continue
asset = str(row.get("asset") or "").upper()
avail = safe_float(row.get("availableBalance"))
if avail is None:
avail = safe_float(row.get("balance"))
if asset == "USDT" and avail is not None:
out["trading_usdt"] = float(avail)
elif asset == "USDC" and avail is not None:
# 永续侧 USDC 少见;若有则记
if out["trading_usdc"] is None:
out["trading_usdc"] = float(avail)
except Exception as e:
logger.warning("binance fapi balance failed: %s", e)
try:
data = self._signed(self._eapi, "GET", "/eapi/v1/marginAccount")
asset_list = []
if isinstance(data, dict):
asset_list = data.get("asset") or data.get("assets") or []
if isinstance(asset_list, list):
for row in asset_list:
if not isinstance(row, dict):
continue
asset = str(
row.get("asset") or row.get("currency") or ""
).upper()
avail = (
safe_float(row.get("available"))
or safe_float(row.get("marginBalance"))
or safe_float(row.get("equity"))
)
if asset == "USDT" and avail is not None:
# 期权保证金常用 USDT;与 fapi 取较大可用
cur = out.get("trading_usdt")
out["trading_usdt"] = (
float(avail)
if cur is None
else max(float(cur), float(avail))
)
elif asset == "USDC" and avail is not None:
out["trading_usdc"] = float(avail)
except Exception as e:
logger.warning("binance eapi marginAccount failed: %s", e)
# 币安期权常用 USDT 保证金:eapi 无独立 USDC 时,用 USDT 作为期权侧可用
if out.get("trading_usdt") is not None and out.get("trading_usdc") is None:
out["trading_usdc"] = float(out["trading_usdt"])
return out
def get_perp_pos_sz(self, symbol: str, *, position_side: str | None = None) -> float | None:
"""当前永续绝对持仓(ETH)。"""
try:
+194 -67
View File
@@ -546,30 +546,52 @@ class OkxLiveExecutor(Matcher):
option_inst_id = str(pos.get("option_inst_id") or "")
opt_contracts = float(pos.get("option_qty_contracts") or 0)
opt_qty = float(pos.get("option_qty_eth") or 0)
if not option_inst_id or opt_contracts <= 0:
if not option_inst_id:
return CloseResult(ok=False, detail="half_open 缺期权合约信息")
client = self._client()
try:
opt_live = client.place_market(
inst_id=option_inst_id,
side="sell",
sz=str(int(round(opt_contracts))),
td_mode="cash",
reduce_only=True,
ex_sz = exchange_option_abs_size(client, option_inst_id)
if ex_sz is None:
return CloseResult(ok=False, detail="half_open:无法核对交易所期权仓位")
if ex_sz <= 1e-8:
of_px, of_fee, of_notional = 0.0, 0.0, 0.0
opt_contracts = 0.0
opt_qty = 0.0
opt_entry = float(pos.get("option_entry_px") or 0)
# 已空:不发明现金
else:
opt_contracts = float(ex_sz)
opt_qty = eth_from_contracts(opt_contracts, self._ct_mult(option_inst_id))
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,
)
except Exception as e:
return CloseResult(ok=False, detail=f"half_open 平期权失败: {e}")
of_px = float(opt_live.avg_px)
of_fee = float(opt_live.fee)
filled = float(opt_live.sz) if opt_live.sz and float(opt_live.sz) > 0 else 0.0
if filled > 0:
opt_contracts = filled
opt_qty = eth_from_contracts(opt_contracts, self._ct_mult(option_inst_id))
of_notional = of_px * opt_qty
ex_left = exchange_option_abs_size(client, option_inst_id)
if ex_left is None or ex_left > 1e-8:
return CloseResult(
ok=False,
detail=f"half_open:卖后仍有仓或无法核对 left={ex_left}",
)
opt_entry = float(pos.get("option_entry_px") or of_px)
self.ledger.apply_cash(
of_notional - of_fee,
kind="close_option",
group_id=group_id or None,
note="LIVE repair half_open",
allow_negative=True,
)
except Exception as e:
return CloseResult(ok=False, detail=f"half_open 平期权失败: {e}")
of_px = float(opt_live.avg_px)
of_fee = float(opt_live.fee)
of_notional = of_px * opt_qty
opt_entry = float(pos.get("option_entry_px") or of_px)
self.ledger.apply_cash(
of_notional - of_fee,
kind="close_option",
group_id=group_id or None,
note="LIVE repair half_open",
allow_negative=True,
)
now = int(time.time() * 1000)
with self.db._lock:
if group_id:
@@ -1007,7 +1029,8 @@ class OkxLiveExecutor(Matcher):
if err:
return CloseResult(ok=False, detail=err)
pos = self.current_position()
if str(pos.get("status") or "") != "open" or not pos.get("group_id"):
st = str(pos.get("status") or "")
if st not in ("open", "closing") or not pos.get("group_id"):
return CloseResult(ok=False, detail="无期期持仓可平")
if not (
str(pos.get("hedge_mode") or "") == "option_option"
@@ -1026,7 +1049,7 @@ class OkxLiveExecutor(Matcher):
except Exception as e:
return CloseResult(ok=False, detail=f"期期全平卖腿失败: {e}")
# 必须以交易所两腿皆空才落本地 flat
for leg, inst, _qty, _c in legs:
for _leg, inst, _qty, _c in legs:
if not inst:
continue
ex_sz = exchange_option_abs_size(client, inst)
@@ -1069,6 +1092,13 @@ class OkxLiveExecutor(Matcher):
),
)
self.db._conn.commit()
from ..sim.pnl import summarize_fills_pnl
fill_rows = self.db.fetchall(
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
)
summary = summarize_fills_pnl(list(fill_rows))
net = float(summary.get("net_pnl") or 0.0)
with self.db._lock:
self.db._conn.execute(
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
@@ -1077,7 +1107,7 @@ class OkxLiveExecutor(Matcher):
"closed",
int(time.time() * 1000),
reason,
0.0,
net,
f"oo full close {reason} exchange_flat_mirror",
group_id,
),
@@ -1097,47 +1127,54 @@ class OkxLiveExecutor(Matcher):
return CloseResult(
ok=True,
detail="oo_full_closed_live",
data={"group_id": group_id, "reason": reason, "net": 0.0},
data={"group_id": group_id, "reason": reason, "net": net},
)
def close_winning_oo_leave_residual(
self, *, reason: str = "target_oo_win"
) -> CloseResult:
"""期期达标:先标记 closing,再交易所卖掉盈利腿,落库。"""
"""期期达标:先标记 closing,再交易所张数卖掉盈利腿,验空后落库。"""
err = self._guard_live()
if err:
return CloseResult(ok=False, detail=err)
pos = self.current_position()
if str(pos.get("status") or "") != "open" or not pos.get("option2_inst_id"):
st = str(pos.get("status") or "")
# 防重入:已在 closing 则优先收尾(须在 open 判断之前)
if st == "closing":
return self._finish_oo_win_after_exchange(reason=reason, pos=pos)
if st != "open" or not pos.get("option2_inst_id"):
return CloseResult(ok=False, detail="无期期持仓")
# 防重入:已在 closing 则只做账本收尾
if str(pos.get("status") or "") == "closing":
return super().close_winning_oo_leave_residual(
reason=reason, skip_market=True
)
upl = self.unrealized()
call_upl = float(upl.get("option_upl") or 0)
put_upl = float(upl.get("option2_upl") or 0)
if call_upl >= put_upl and call_upl > 0:
win_leg = "option"
win_id = str(pos["option_inst_id"])
win_contracts = float(pos.get("option_qty_contracts") or 0)
elif put_upl > 0:
win_leg = "option2"
win_id = str(pos["option2_inst_id"])
win_contracts = float(pos.get("option2_qty_contracts") or 0)
else:
return CloseResult(ok=False, detail="无明确盈利腿")
client = self._client()
ex_sz = exchange_option_abs_size(client, win_id)
if ex_sz is None:
return CloseResult(ok=False, detail="期期平盈利腿:无法核对交易所仓位")
if ex_sz <= 1e-8:
return self._finish_oo_win_after_exchange(
reason=reason, pos=pos, win_leg=win_leg, fill_px=0.0, fill_fee=0.0, fill_c=0.0
)
with self.db._lock:
self.db._conn.execute(
"UPDATE positions SET status='closing' WHERE id=1 AND status='open'"
)
self.db._conn.commit()
client = self._client()
try:
client.place_market(
live = client.place_market(
inst_id=win_id,
side="sell",
sz=str(int(round(win_contracts))),
sz=str(max(1, int(round(float(ex_sz))))),
td_mode="cash",
reduce_only=True,
)
except Exception as e:
with self.db._lock:
@@ -1146,8 +1183,65 @@ class OkxLiveExecutor(Matcher):
)
self.db._conn.commit()
return CloseResult(ok=False, detail=f"期期平盈利腿失败: {e}")
fill_c = float(live.sz) if live.sz and float(live.sz) > 0 else 0.0
return self._finish_oo_win_after_exchange(
reason=reason,
pos=self.current_position(),
win_leg=win_leg,
fill_px=float(live.avg_px),
fill_fee=float(live.fee),
fill_c=fill_c,
)
def _finish_oo_win_after_exchange(
self,
*,
reason: str,
pos: dict,
win_leg: str | None = None,
fill_px: float | None = None,
fill_fee: float | None = None,
fill_c: float | None = None,
) -> CloseResult:
"""盈利腿卖后:确认交易所已空,再用真实成交落库。"""
client = self._client()
call_id = str(pos.get("option_inst_id") or "")
put_id = str(pos.get("option2_inst_id") or "")
if not win_leg:
# 崩溃恢复:哪条腿已空且另一条仍有仓 → 已空者为赢腿
c_sz = exchange_option_abs_size(client, call_id) if call_id else None
p_sz = exchange_option_abs_size(client, put_id) if put_id else None
if c_sz is None or p_sz is None:
return CloseResult(
ok=False, detail="closing 收尾:无法核对交易所两腿仓位"
)
if c_sz <= 1e-8 and p_sz > 1e-8:
win_leg = "option"
elif p_sz <= 1e-8 and c_sz > 1e-8:
win_leg = "option2"
elif c_sz <= 1e-8 and p_sz <= 1e-8:
# 两腿皆空:走全平镜像
return self.close_oo_full(reason=reason, bypass_liquidity=True)
else:
return CloseResult(
ok=False, detail="closing 收尾:盈利腿仍在交易所,请重试卖出"
)
win_id = call_id if win_leg == "option" else put_id
ex_win = exchange_option_abs_size(client, win_id)
if ex_win is None:
return CloseResult(ok=False, detail="closing 收尾:无法核对盈利腿仓位")
if ex_win > 1e-8:
return CloseResult(
ok=False,
detail=f"closing 收尾:盈利腿仍有仓 {ex_win},禁止本地清仓",
)
return super().close_winning_oo_leave_residual(
reason=reason, skip_market=True
reason=reason,
skip_market=True,
live_fill_px=0.0 if fill_px is None else float(fill_px),
live_fill_fee=0.0 if fill_fee is None else float(fill_fee),
live_fill_contracts=fill_c,
live_win_leg=win_leg,
)
def close_group(self, *, reason: str, bypass_liquidity: bool = False) -> CloseResult:
@@ -1162,6 +1256,17 @@ class OkxLiveExecutor(Matcher):
return self.recover_opening()
if st == "half_open":
return self.repair_half_open()
if st == "closing":
# 期期盈利腿收尾
if pos.get("option2_inst_id") or str(pos.get("hedge_mode") or "") == "option_option":
return self.close_winning_oo_leave_residual(reason=reason or "closing_retry")
return CloseResult(ok=False, detail="closing 非期期状态,请人工核对")
is_oo = (
str(pos.get("hedge_mode") or "") == "option_option"
or bool(pos.get("option2_inst_id"))
)
if is_oo and st == "open":
return self.close_oo_full(reason=reason, bypass_liquidity=bypass_liquidity)
if st not in ("open", "option_closed_perp_pending") or not pos.get("group_id"):
return CloseResult(ok=False, detail="无持仓可平")
@@ -1213,9 +1318,9 @@ class OkxLiveExecutor(Matcher):
of_fee = float(prev["fee"] or 0)
of_notional = float(prev["notional"] or (of_px * opt_qty))
else:
of_px = float(intrinsic) if intrinsic is not None else 0.0
of_px = 0.0
of_fee = 0.0
of_notional = of_px * opt_qty
of_notional = 0.0
self._ensure_option_closed_perp_pending(
group_id=group_id,
option_inst_id=option_inst_id,
@@ -1231,10 +1336,18 @@ class OkxLiveExecutor(Matcher):
of_slip = 0.0
option_apply_cash = False
elif is_expiry:
# 到期:交易所自动结算期权,本地只平永续不卖期权、不本地发明结算现金
of_px = float(intrinsic) if intrinsic is not None else 0.0
# 到期:交易所自动结算期权,本地只平永续不卖期权、不用 intrinsic 发明 fill
ex_opt = exchange_option_abs_size(client, option_inst_id)
if ex_opt is not None and ex_opt > 1e-8:
logger.warning(
"expiry: option still on exchange sz=%.4f group=%s; "
"skip option, close perp only",
ex_opt,
group_id,
)
of_px = 0.0
of_fee = 0.0
of_notional = of_px * opt_qty
of_notional = 0.0
of_slip = 0.0
option_apply_cash = False
logger.info(
@@ -2025,40 +2138,54 @@ class OkxLiveExecutor(Matcher):
ct_val=ct_val,
allow_db_fallback=False,
)
if perp_sz is None or perp_sz <= 0:
if perp_sz is None:
return CloseResult(
ok=False,
detail="弃期权平永续失败: 无法取得有效永续仓位数量",
detail="弃期权平永续失败: 无法核对交易所永续仓位",
)
if perp_side == "long":
side, pos_side = "sell", "long"
if perp_sz > 0:
if perp_side == "long":
side, pos_side = "sell", "long"
else:
side, pos_side = "buy", "short"
perp_live = client.place_market(
inst_id=perp_inst,
side=side,
sz=str(perp_sz),
td_mode=self._perp_margin_mode_for_group(group_id),
pos_side=pos_side,
reduce_only=True,
)
pf_px = float(perp_live.avg_px)
pf_fee = float(perp_live.fee)
try:
perp_qty = float(perp_sz) * float(ct_val)
except Exception:
pass
else:
side, pos_side = "buy", "short"
perp_live = client.place_market(
inst_id=perp_inst,
side=side,
sz=str(perp_sz),
td_mode=self._perp_margin_mode_for_group(group_id),
pos_side=pos_side,
reduce_only=True,
)
# 永续已空:仍归档期权 residual
pf_px = 0.0
pf_fee = 0.0
logger.warning(
"abandon: perp already flat on exchange; archive option residual group=%s",
group_id,
)
except Exception as e:
return CloseResult(ok=False, detail=f"实盘平永续失败: {e}")
pf_px = float(perp_live.avg_px)
pf_fee = float(perp_live.fee)
if perp_side == "long":
perp_pnl = (pf_px - perp_entry) * perp_qty
perp_pnl = (pf_px - perp_entry) * perp_qty if pf_px else 0.0
else:
perp_pnl = (perp_entry - pf_px) * perp_qty
perp_pnl = (perp_entry - pf_px) * perp_qty if pf_px else 0.0
self.ledger.apply_cash(
perp_pnl - pf_fee,
kind="close_perp",
group_id=group_id,
note=f"LIVE close perp abandon option {reason}",
allow_negative=True,
)
if abs(perp_pnl) + abs(pf_fee) > 1e-12:
self.ledger.apply_cash(
perp_pnl - pf_fee,
kind="close_perp",
group_id=group_id,
note=f"LIVE close perp abandon option {reason}",
allow_negative=True,
)
# 复用父类归档写入:临时改 fill 路径太重,直接调用父类会再平一次本地假价。
# 因此把实盘价写入后走父类结构——这里内联父类 abandon 的 DB 段。
+36 -7
View File
@@ -278,14 +278,21 @@ def recover_stuck_opening(executor) -> CloseResult | None:
group_id = str(pos.get("group_id") or "")
perp_inst = resolve_perp_inst_id(executor.db, group_id=group_id or None)
# 期期 openingperp_side 形如 oo_put:{put_inst}
oo_put_inst = ""
if perp_side.startswith("oo_put:"):
oo_put_inst = perp_side.split(":", 1)[1].strip()
perp_total = 0.0
for side in ("long", "short"):
sz = exchange_perp_abs_size(client, ex_name or "", perp_inst, side)
if sz is None:
return CloseResult(ok=False, detail="recover_opening: 无法查永续")
perp_total += float(sz)
if not oo_put_inst:
for side in ("long", "short"):
sz = exchange_perp_abs_size(client, ex_name or "", perp_inst, side)
if sz is None:
return CloseResult(ok=False, detail="recover_opening: 无法查永续")
perp_total += float(sz)
opt_sz = 0.0
put_sz = 0.0
if option_inst:
raw = exchange_option_abs_size(client, option_inst)
if raw is None:
@@ -304,12 +311,34 @@ def recover_stuck_opening(executor) -> CloseResult | None:
),
)
opt_sz = 0.0
if oo_put_inst:
raw_p = exchange_option_abs_size(client, oo_put_inst)
if raw_p is None:
return CloseResult(ok=False, detail="recover_opening: 无法查期期 Put")
put_sz = float(raw_p)
# 两边皆空 → 清槽
if opt_sz <= _OPT_EPS and perp_total <= _PERP_EPS:
# 两边皆空 → 清槽(期期须 Call+Put 皆空)
if opt_sz <= _OPT_EPS and put_sz <= _OPT_EPS and perp_total <= _PERP_EPS:
release_open_slot_if_opening(executor.db)
return CloseResult(ok=True, detail="recover_opening: 交易所空仓,已释放 opening")
# 期期:任一腿残留且无完整双边 → 禁止清槽;仅 Call 则 half_open repair
if oo_put_inst:
if put_sz > _OPT_EPS and opt_sz <= _OPT_EPS:
return CloseResult(
ok=False,
detail=(
f"recover_opening: 期期 Put 残留({oo_put_inst}) Call 已空,"
"禁止清槽,请人工核对"
),
)
if opt_sz > _OPT_EPS and put_sz > _OPT_EPS:
return CloseResult(
ok=False,
detail="recover_opening: 期期两腿均在交易所,请人工核对后 promote",
)
# 仅 Call → 落入下方 half_open
# 无元数据但有仓 → 不自动处理
if not option_inst:
return CloseResult(
+67 -21
View File
@@ -26,7 +26,13 @@ logger = logging.getLogger(__name__)
# 禁止新开仓的本地仓位状态(实盘防卡)
BLOCKING_STATUSES = frozenset(
{"open", "half_open", "option_closed_perp_pending", "opening"}
{
"open",
"half_open",
"option_closed_perp_pending",
"opening",
"closing", # 期期盈利腿已卖、账本收尾中
}
)
@@ -624,7 +630,14 @@ class Matcher:
)
def close_winning_oo_leave_residual(
self, *, reason: str = "target_oo_win", skip_market: bool = False
self,
*,
reason: str = "target_oo_win",
skip_market: bool = False,
live_fill_px: float | None = None,
live_fill_fee: float | None = None,
live_fill_contracts: float | None = None,
live_win_leg: str | None = None,
) -> CloseResult:
"""期期达标:平盈利腿,亏损腿进 residual。skip_market=True 时假定已在交易所卖掉盈利腿。"""
pos = self.current_position()
@@ -649,6 +662,13 @@ class Matcher:
upl = self.unrealized()
call_upl = float(upl.get("option_upl") or 0)
put_upl = float(upl.get("option2_upl") or 0)
# LIVE 收尾可指定 win_leg(崩溃恢复时 UPL 可能已不可用)
force_leg = (live_win_leg or "").strip()
if force_leg in ("option", "option2"):
if force_leg == "option":
call_upl, put_upl = 1.0, 0.0
else:
call_upl, put_upl = 0.0, 1.0
# 盈利腿:UPL 更高且 > 0
if call_upl >= put_upl and call_upl > 0:
win_leg, lose_leg = "option", "option2"
@@ -681,16 +701,38 @@ class Matcher:
return CloseResult(ok=False, detail="无明确盈利腿,暂不平")
fee_rate = self._fee_rate()
if skip_market:
oq = self._quote_held_option(win_id)
fill_px = float(oq.bid) if oq and oq.bid else float(win_entry)
of = option_fill(
action="close",
bid=fill_px,
ask=fill_px,
qty_eth=win_qty,
fee_rate=fee_rate,
exec_mode = "SIM" if get_settings().is_sim else "LIVE"
from .pricing import PriceResult
if skip_market and live_fill_px is not None:
fill_px = float(live_fill_px)
fill_fee = float(live_fill_fee or 0)
if live_fill_contracts is not None and float(live_fill_contracts) > 0:
win_contracts = float(live_fill_contracts)
win_qty = eth_from_contracts(win_contracts, self._ct_mult(win_id))
of = PriceResult(
base_px=fill_px,
fill_px=fill_px,
fee=fill_fee,
slip=0.0,
notional=fill_px * win_qty,
)
elif skip_market:
# LIVE 未传真实成交:禁止用盘口发明价,仅允许 0 价零现金镜像
if not get_settings().is_sim:
of = PriceResult(
base_px=0.0, fill_px=0.0, fee=0.0, slip=0.0, notional=0.0
)
else:
oq = self._quote_held_option(win_id)
fill_px = float(oq.bid) if oq and oq.bid else float(win_entry)
of = option_fill(
action="close",
bid=fill_px,
ask=fill_px,
qty_eth=win_qty,
fee_rate=fee_rate,
)
else:
oq = self._quote_held_option(win_id)
if oq is None or oq.bid is None or float(oq.bid) <= 0:
@@ -715,21 +757,24 @@ class Matcher:
fee_rate=fee_rate,
)
cash = of.notional - of.fee
if get_settings().is_sim or not skip_market:
self.ledger.apply_cash(
cash, kind="close_option", group_id=group_id, note=f"oo win {win_leg}"
)
elif skip_market:
# LIVE:交易所已成交,仍记本地账本现金(与其它 LIVE 平仓一致)
apply_live_cash = bool(
get_settings().is_sim
or not skip_market
or (live_fill_px is not None and abs(float(live_fill_px)) + abs(float(live_fill_fee or 0)) > 1e-12)
)
if apply_live_cash:
try:
self.ledger.apply_cash(
cash,
kind="close_option",
group_id=group_id,
note=f"oo win live {win_leg}",
note=f"oo win {win_leg}",
allow_negative=not get_settings().is_sim,
)
except Exception:
logger.exception("oo win live ledger cash failed")
if get_settings().is_sim:
raise
logger.exception("oo win ledger cash failed")
now = int(time.time() * 1000)
expiry_ymd = None
@@ -764,7 +809,7 @@ class Matcher:
of.slip,
of.notional,
now,
"SIM",
exec_mode,
),
)
self.db._conn.execute(
@@ -836,7 +881,8 @@ class Matcher:
) -> CloseResult:
"""期期全平两腿(到期/紧急);无永续。"""
pos = self.current_position()
if str(pos.get("status") or "") != "open" or not pos.get("group_id"):
st = str(pos.get("status") or "")
if st not in ("open", "closing") or not pos.get("group_id"):
return CloseResult(ok=False, detail="无期期持仓可平")
if not (
str(pos.get("hedge_mode") or "") == "option_option"
+81 -13
View File
@@ -477,19 +477,53 @@ class StrategyEngine:
)
except Exception:
pass
elif st == "closing":
close_oo = getattr(
self.matcher, "close_winning_oo_leave_residual", None
)
if callable(close_oo):
r = close_oo(reason="emergency_closing")
else:
r = self.matcher.close_group(
reason="emergency", bypass_liquidity=True
)
ok = r.ok
detail = r.detail
close_data = r.data
if r.ok:
self.enter_rest_after_close()
elif st in ("open", "option_closed_perp_pending"):
# A:双腿(或续平永续)
r = self.matcher.close_group(reason="emergency", bypass_liquidity=True)
if not r.ok and st == "open":
# B:砸不出期权时强制只平永续(不要求远虚)
abandon = getattr(self.matcher, "close_perp_abandon_option", None)
if callable(abandon):
try:
r2 = abandon(reason="emergency_perp", require_deep_otm=False)
except TypeError:
r2 = abandon(reason="emergency_perp")
if r2.ok:
r = r2
is_oo = (
str(pos.get("hedge_mode") or "") == "option_option"
or bool(pos.get("option2_inst_id"))
)
if is_oo and st == "open":
close_full = getattr(self.matcher, "close_oo_full", None)
if callable(close_full):
r = close_full(reason="emergency", bypass_liquidity=True)
else:
r = self.matcher.close_group(
reason="emergency", bypass_liquidity=True
)
else:
# A:双腿(或续平永续)
r = self.matcher.close_group(
reason="emergency", bypass_liquidity=True
)
if not r.ok and st == "open" and not is_oo:
# B:砸不出期权时强制只平永续(不要求远虚)
abandon = getattr(
self.matcher, "close_perp_abandon_option", None
)
if callable(abandon):
try:
r2 = abandon(
reason="emergency_perp", require_deep_otm=False
)
except TypeError:
r2 = abandon(reason="emergency_perp")
if r2.ok:
r = r2
ok = r.ok
detail = r.detail
close_data = r.data
@@ -948,6 +982,24 @@ class StrategyEngine:
)
return
if st_pos == "closing":
allowed, left = self._retry_allowed("closing")
if not allowed:
self._set_state(
phase="closing",
last_error=f"closing 收尾退避中,{left:.0f}s 后再试",
)
return
close_oo = getattr(self.matcher, "close_winning_oo_leave_residual", None)
if callable(close_oo):
r = await asyncio.to_thread(close_oo, reason="closing_retry")
self._note_retry_result("closing", ok=r.ok, detail=r.detail)
if r.ok:
self.enter_rest_after_close()
else:
self._set_state(phase="closing", last_error=r.detail)
return
if st_pos == "open":
upl = self.matcher.unrealized()
from .exits import lock_trade_exit_target, read_locked_exit_target
@@ -1001,8 +1053,24 @@ class StrategyEngine:
# 复用 ExitDecision 形态
from .exits import ExitDecision
should_semi = bool(semi_d.should_close)
if should_semi:
# 出场前要求持仓期权有买一,避免无对手盘硬平
opt_id = str(upl.get("option_inst_id") or "")
oq = None
try:
oq = self.matcher._quote_held_option(opt_id)
except Exception:
oq = None
if oq is None or oq.bid is None or float(oq.bid) <= 0:
should_semi = False
self._set_state(
phase="liquidity_wait",
last_error="半自动已达标但期权无买一,等待流动性",
)
return
decision = ExitDecision(
bool(semi_d.should_close),
should_semi,
str(semi_d.reason or ""),
float(semi_d.net_target or 0),
)
+9 -2
View File
@@ -70,8 +70,15 @@ def _live_balances() -> dict[str, float | None]:
ex = str(load_runtime_settings().exchange or "").strip().lower()
if ex in ("binance", "bn"):
# 币安资金接口尚未接入;返回 None 使可开判定为「未知」而非误用 OKX
logger.debug("open_capacity: binance live balance not wired; treating as unknown")
from ..live.binance_trade import BinanceTradeClient
client = BinanceTradeClient()
try:
bal = client.fetch_balances()
out["trading_usdt"] = _f(bal.get("trading_usdt"))
out["trading_usdc"] = _f(bal.get("trading_usdc"))
finally:
client.close()
else:
from ..live.okx_funds import OkxFundsClient
+171
View File
@@ -0,0 +1,171 @@
"""P0 实盘 SoTclosing 状态机、紧急期期、recover Put、到期无 intrinsic。"""
from __future__ import annotations
from types import SimpleNamespace
from app.sim.matcher import BLOCKING_STATUSES
def test_closing_in_blocking_statuses() -> None:
assert "closing" in BLOCKING_STATUSES
def test_has_open_position_blocks_closing(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
from app.models.db import Database
from app.sim.matcher import Matcher
db = Database(tmp_path / "c.db")
m = Matcher(db)
with db._lock:
db._conn.execute(
"""UPDATE positions SET group_id=?, option_inst_id=?, option2_inst_id=?,
status='closing', hedge_mode='option_option' WHERE id=1""",
("G1", "C", "P"),
)
db._conn.commit()
assert m.has_open_position() is True
db.close()
def test_recover_opening_refuses_orphan_put(monkeypatch) -> None:
monkeypatch.setenv("MODE", "LIVE")
from app.config import get_settings
get_settings.cache_clear()
import app.live.reconcile as rec
from app.live.reconcile import recover_stuck_opening
class _Ex:
def current_position(self):
return {
"status": "opening",
"group_id": "G-oo",
"option_inst_id": "ETH-CALL",
"perp_side": "oo_put:ETH-PUT",
"option_qty_eth": 1,
"option_qty_contracts": 100,
}
@property
def db(self):
return SimpleNamespace()
def _client(self):
return object()
def fake_opt(_c, inst):
if "PUT" in inst:
return 5.0
return 0.0
monkeypatch.setattr(rec, "_executor_client_and_exchange", lambda _e: (object(), "okx"))
monkeypatch.setattr(rec, "exchange_option_abs_size", fake_opt)
monkeypatch.setattr(rec, "exchange_perp_abs_size", lambda *_a, **_k: 0.0)
monkeypatch.setattr(
rec, "resolve_perp_inst_id", lambda *_a, **_k: "ETH-USDT-SWAP"
)
r = recover_stuck_opening(_Ex())
assert r is not None
assert r.ok is False
assert "Put" in (r.detail or "")
get_settings.cache_clear()
def test_expiry_fill_zero_not_intrinsic(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("MODE", "LIVE")
from app.config import get_settings
get_settings.cache_clear()
from app.live.executor import OkxLiveExecutor
from app.models.db import Database
db = Database(tmp_path / "e.db")
ex = OkxLiveExecutor(db)
monkeypatch.setattr(ex, "_guard_live", lambda: None)
with db._lock:
db._conn.execute(
"""UPDATE positions SET
group_id=?, perp_side=?, perp_qty_eth=?, perp_entry_px=?,
option_inst_id=?, option_side=?, option_qty_eth=?, option_qty_contracts=?,
option_entry_px=?, status='open' WHERE id=1""",
("G-e", "short", 4.0, 2000.0, "ETH-OPT", "call", 1.0, 100.0, 20.0),
)
db._conn.execute(
"""INSERT INTO groups(group_id, status, option_inst_id, perp_inst_id, strike, open_at_ms)
VALUES (?,?,?,?,?,?)""",
("G-e", "open", "ETH-OPT", "ETH-USDT-SWAP", 1900.0, 1),
)
db._conn.commit()
class _C:
def get_ct_val(self, *_a, **_k):
return 0.01
def get_perp_pos_sz(self, *_a, **_k):
return 400.0
def place_market(self, *, inst_id, side, sz, **_k):
return SimpleNamespace(avg_px=2010.0, fee=0.1, sz=float(sz))
monkeypatch.setattr(ex, "_client", lambda: _C())
monkeypatch.setattr(
"app.live.executor.exchange_option_abs_size", lambda *_a, **_k: 2.0
)
monkeypatch.setattr(ex, "_group_strike", lambda *_a, **_k: 1900.0)
monkeypatch.setattr(ex, "_close_spot_px", lambda *_a, **_k: 1950.0)
monkeypatch.setattr(
"app.live.executor.get_session",
lambda: SimpleNamespace(snapshot=lambda: {}),
)
monkeypatch.setattr(
"app.live.executor.resolve_perp_inst_id",
lambda *_a, **_k: "ETH-USDT-SWAP",
)
monkeypatch.setattr(
"app.live.live_pnl.reconcile_closed_group_pnl",
lambda **_k: 0.0,
)
r = ex.close_group(reason="expiry", bypass_liquidity=True)
assert r.ok, r.detail
row = db.fetchone(
"SELECT fill_px, notional FROM fills WHERE group_id=? AND leg='option' AND action='close'",
("G-e",),
)
assert row is not None
assert float(row["fill_px"]) == 0.0
assert float(row["notional"] or 0) == 0.0
db.close()
get_settings.cache_clear()
def test_binance_fetch_balances_maps_usdt(monkeypatch) -> None:
from app.live.binance_trade import BinanceTradeClient
c = BinanceTradeClient.__new__(BinanceTradeClient)
def _signed(client, method, path, params=None):
if "fapi" in str(getattr(client, "base_url", "")) or path.startswith("/fapi"):
return [{"asset": "USDT", "availableBalance": "100.5"}]
if "marginAccount" in path:
return {"asset": [{"asset": "USDT", "available": "80"}]}
return []
c._signed = _signed # type: ignore
c._fapi = SimpleNamespace(base_url="https://fapi")
c._eapi = SimpleNamespace(base_url="https://eapi")
# simpler: patch by path
def signed2(_client, method, path, params=None):
if path == "/fapi/v2/balance":
return [{"asset": "USDT", "availableBalance": "100.5"}]
if path == "/eapi/v1/marginAccount":
return {"asset": [{"asset": "USDT", "available": "80"}]}
return []
c._signed = signed2 # type: ignore
bal = BinanceTradeClient.fetch_balances(c)
assert bal["trading_usdt"] == 100.5
assert bal["trading_usdc"] == 100.5 # mapped from USDT when no USDC
@@ -0,0 +1,45 @@
# 审计修改方案 — 2026-08-08 交易所 SoT 续修
## 目标
实盘以交易所仓位/成交为准;本地仅镜像。修完后无 P0/P1 仓位与状态机硬伤。
## 修改清单
### P0 状态机与错腿
1. `closing` 加入 `BLOCKING_STATUSES`;引擎 manage + emergency 处理 closing
2. OKX `close_winning_oo`:先处理 closing(对齐 BN
3. emergency / `close_group`:检测期期 → `close_oo_full`
4. 到期:期权交易所未空 → fail-closed;空后只平永续;fill 不用 intrinsic
5. recover_opening:解析 `oo_put:`,双腿皆空才清槽
6. 期期盈利腿:交易所张数下单,成交均价入账,验空再落 residual
### P1 镜像与可用性
7. 残仓已空:零价镜像仅标记 `exchange_flat`,不伪造交割现金(PnL 标未知/0+note)
8. `close_oo_full`:用 fills 汇总 realized_pnl
9. repair / abandon:交易所 size;永续已空可归档
10. 币安余额接入资金门(能查多少接多少)
11. 半自动:出场前要求盘口可成交(bid/ask 存在),否则 liquidity_wait
### 验收
- 单测覆盖 closing / emergency-OO / expiry 无 intrinsic / recover Put / BN bal stub
- 再审计无 P0;P1 交割账单拉取可标「后续」若交易所 API 未接
### 实施结果(2026-08-08
| 项 | 状态 |
|----|------|
| closing BLOCKING + engine | ✅ |
| OKX OO win 顺序 + 交易所成交 | ✅ |
| emergency/close_group → OO full | ✅ |
| 到期无 intrinsic;只平永续 | ✅ |
| recover oo_put 孤儿 Put | ✅ |
| repair/abandon 交易所 size | ✅ |
| BN 余额资金门 | ✅ |
| 半自动买一闸 | ✅ |
| 交割账单拉价 | ⏳ 后续(零价镜像+note) |
再审计:无剩余 P0;剩交割账单与期期开仓账本对称为 P2/后续。
+15
View File
@@ -5,6 +5,21 @@
---
## 2026-08-08 — 实盘 SoT 续修(closing / 期期 / 资金门)
### 变更
1. `closing` 进开仓拦截;引擎 manage/紧急处理 closingOKX 期期盈利腿收尾顺序对齐 BN。
2. 紧急/全平检测期期 → `close_oo_full`;盈利腿按交易所张数+真实均价,验空再落库。
3. 到期 fill 禁止 intrinsicrecover 识别 `oo_put:` 防孤儿 Putrepair/abandon 交易所 size。
4. 币安余额接入资金门;半自动出场前要求期权买一。
### 审计
`docs/审计修改方案-2026-08-08-交易所SoT续.md`;交割账单 API 未接,ITM 零价镜像 PnL 可能偏低(仓位 SoT 优先)。
---
## 2026-08-08 — 实盘以交易所为 SoT(到期只平永续)
### 变更