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:
@@ -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,))
|
||||
|
||||
@@ -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
@@ -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 段。
|
||||
|
||||
@@ -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)
|
||||
|
||||
# 期期 opening:perp_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
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""P0 实盘 SoT:closing 状态机、紧急期期、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/后续。
|
||||
@@ -5,6 +5,21 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-08 — 实盘 SoT 续修(closing / 期期 / 资金门)
|
||||
|
||||
### 变更
|
||||
|
||||
1. `closing` 进开仓拦截;引擎 manage/紧急处理 closing;OKX 期期盈利腿收尾顺序对齐 BN。
|
||||
2. 紧急/全平检测期期 → `close_oo_full`;盈利腿按交易所张数+真实均价,验空再落库。
|
||||
3. 到期 fill 禁止 intrinsic;recover 识别 `oo_put:` 防孤儿 Put;repair/abandon 交易所 size。
|
||||
4. 币安余额接入资金门;半自动出场前要求期权买一。
|
||||
|
||||
### 审计
|
||||
|
||||
见 `docs/审计修改方案-2026-08-08-交易所SoT续.md`;交割账单 API 未接,ITM 零价镜像 PnL 可能偏低(仓位 SoT 优先)。
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-08 — 实盘以交易所为 SoT(到期只平永续)
|
||||
|
||||
### 变更
|
||||
|
||||
Reference in New Issue
Block a user