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 "")
|
option_inst_id = str(pos.get("option_inst_id") or "")
|
||||||
opt_contracts = float(pos.get("option_qty_contracts") or 0)
|
opt_contracts = float(pos.get("option_qty_contracts") or 0)
|
||||||
opt_qty = float(pos.get("option_qty_eth") 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 缺期权合约信息")
|
return CloseResult(ok=False, detail="half_open 缺期权合约信息")
|
||||||
client = self._client()
|
client = self._client()
|
||||||
try:
|
ex_sz = exchange_option_abs_size(client, option_inst_id)
|
||||||
opt_live = client.place_option_market(
|
if ex_sz is None:
|
||||||
symbol=option_inst_id,
|
return CloseResult(ok=False, detail="half_open:无法核对交易所期权仓位")
|
||||||
side="SELL",
|
if ex_sz <= 1e-8:
|
||||||
quantity=opt_contracts,
|
of_px, of_fee, of_notional = 0.0, 0.0, 0.0
|
||||||
reduce_only=True,
|
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)
|
now = int(time.time() * 1000)
|
||||||
with self.db._lock:
|
with self.db._lock:
|
||||||
if group_id:
|
if group_id:
|
||||||
@@ -961,7 +984,8 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
if err:
|
if err:
|
||||||
return CloseResult(ok=False, detail=err)
|
return CloseResult(ok=False, detail=err)
|
||||||
pos = self.current_position()
|
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="无期期持仓可平")
|
return CloseResult(ok=False, detail="无期期持仓可平")
|
||||||
if not (
|
if not (
|
||||||
str(pos.get("hedge_mode") or "") == "option_option"
|
str(pos.get("hedge_mode") or "") == "option_option"
|
||||||
@@ -1022,6 +1046,13 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.db._conn.commit()
|
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:
|
with self.db._lock:
|
||||||
self.db._conn.execute(
|
self.db._conn.execute(
|
||||||
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
|
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
|
||||||
@@ -1030,7 +1061,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
"closed",
|
"closed",
|
||||||
int(time.time() * 1000),
|
int(time.time() * 1000),
|
||||||
reason,
|
reason,
|
||||||
0.0,
|
net,
|
||||||
f"oo full close {reason} exchange_flat_mirror",
|
f"oo full close {reason} exchange_flat_mirror",
|
||||||
group_id,
|
group_id,
|
||||||
),
|
),
|
||||||
@@ -1050,7 +1081,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
return CloseResult(
|
return CloseResult(
|
||||||
ok=True,
|
ok=True,
|
||||||
detail="oo_full_closed_live_bn",
|
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(
|
def close_winning_oo_leave_residual(
|
||||||
@@ -1060,31 +1091,46 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
if err:
|
if err:
|
||||||
return CloseResult(ok=False, detail=err)
|
return CloseResult(ok=False, detail=err)
|
||||||
pos = self.current_position()
|
pos = self.current_position()
|
||||||
if str(pos.get("status") or "") == "closing":
|
st = str(pos.get("status") or "")
|
||||||
return super().close_winning_oo_leave_residual(
|
if st == "closing":
|
||||||
reason=reason, skip_market=True
|
return self._finish_oo_win_after_exchange(reason=reason, pos=pos)
|
||||||
)
|
if st != "open" or not pos.get("option2_inst_id"):
|
||||||
if str(pos.get("status") or "") != "open" or not pos.get("option2_inst_id"):
|
|
||||||
return CloseResult(ok=False, detail="无期期持仓")
|
return CloseResult(ok=False, detail="无期期持仓")
|
||||||
upl = self.unrealized()
|
upl = self.unrealized()
|
||||||
call_upl = float(upl.get("option_upl") or 0)
|
call_upl = float(upl.get("option_upl") or 0)
|
||||||
put_upl = float(upl.get("option2_upl") or 0)
|
put_upl = float(upl.get("option2_upl") or 0)
|
||||||
if call_upl >= put_upl and call_upl > 0:
|
if call_upl >= put_upl and call_upl > 0:
|
||||||
|
win_leg = "option"
|
||||||
win_id = str(pos["option_inst_id"])
|
win_id = str(pos["option_inst_id"])
|
||||||
win_contracts = float(pos.get("option_qty_contracts") or 0)
|
|
||||||
elif put_upl > 0:
|
elif put_upl > 0:
|
||||||
|
win_leg = "option2"
|
||||||
win_id = str(pos["option2_inst_id"])
|
win_id = str(pos["option2_inst_id"])
|
||||||
win_contracts = float(pos.get("option2_qty_contracts") or 0)
|
|
||||||
else:
|
else:
|
||||||
return CloseResult(ok=False, detail="无明确盈利腿")
|
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:
|
with self.db._lock:
|
||||||
self.db._conn.execute(
|
self.db._conn.execute(
|
||||||
"UPDATE positions SET status='closing' WHERE id=1 AND status='open'"
|
"UPDATE positions SET status='closing' WHERE id=1 AND status='open'"
|
||||||
)
|
)
|
||||||
self.db._conn.commit()
|
self.db._conn.commit()
|
||||||
try:
|
try:
|
||||||
self._client().place_option_market(
|
live = client.place_option_market(
|
||||||
symbol=win_id, side="SELL", quantity=win_contracts
|
symbol=win_id,
|
||||||
|
side="SELL",
|
||||||
|
quantity=float(ex_sz),
|
||||||
|
reduce_only=True,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
with self.db._lock:
|
with self.db._lock:
|
||||||
@@ -1093,8 +1139,62 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
)
|
)
|
||||||
self.db._conn.commit()
|
self.db._conn.commit()
|
||||||
return CloseResult(ok=False, detail=f"期期平盈利腿失败: {e}")
|
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(
|
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:
|
def close_group(self, *, reason: str, bypass_liquidity: bool = False) -> CloseResult:
|
||||||
@@ -1109,6 +1209,18 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
return self.recover_opening()
|
return self.recover_opening()
|
||||||
if st == "half_open":
|
if st == "half_open":
|
||||||
return self.repair_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"):
|
if st not in ("open", "option_closed_perp_pending") or not pos.get("group_id"):
|
||||||
return CloseResult(ok=False, detail="无持仓可平")
|
return CloseResult(ok=False, detail="无持仓可平")
|
||||||
|
|
||||||
@@ -1158,9 +1270,9 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
of_fee = float(prev["fee"] or 0)
|
of_fee = float(prev["fee"] or 0)
|
||||||
of_notional = float(prev["notional"] or (of_px * opt_qty))
|
of_notional = float(prev["notional"] or (of_px * opt_qty))
|
||||||
else:
|
else:
|
||||||
of_px = float(intrinsic) if intrinsic is not None else 0.0
|
of_px = 0.0
|
||||||
of_fee = 0.0
|
of_fee = 0.0
|
||||||
of_notional = of_px * opt_qty
|
of_notional = 0.0
|
||||||
self._ensure_option_closed_perp_pending(
|
self._ensure_option_closed_perp_pending(
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
option_inst_id=option_inst_id,
|
option_inst_id=option_inst_id,
|
||||||
@@ -1176,9 +1288,17 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
of_slip = 0.0
|
of_slip = 0.0
|
||||||
option_apply_cash = False
|
option_apply_cash = False
|
||||||
elif is_expiry:
|
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_fee = 0.0
|
||||||
of_notional = of_px * opt_qty
|
of_notional = 0.0
|
||||||
of_slip = 0.0
|
of_slip = 0.0
|
||||||
option_apply_cash = False
|
option_apply_cash = False
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1960,35 +2080,45 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
perp_qty_eth=perp_qty,
|
perp_qty_eth=perp_qty,
|
||||||
allow_db_fallback=False,
|
allow_db_fallback=False,
|
||||||
)
|
)
|
||||||
if perp_qty_close is None or perp_qty_close <= 0:
|
if perp_qty_close is None:
|
||||||
return CloseResult(
|
return CloseResult(
|
||||||
ok=False,
|
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:
|
except Exception as e:
|
||||||
return CloseResult(ok=False, detail=f"币安平永续失败: {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":
|
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:
|
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(
|
if abs(perp_pnl) + abs(pf_fee) > 1e-12:
|
||||||
perp_pnl - pf_fee,
|
self.ledger.apply_cash(
|
||||||
kind="close_perp",
|
perp_pnl - pf_fee,
|
||||||
group_id=group_id,
|
kind="close_perp",
|
||||||
note=f"LIVE-BN close perp abandon option {reason}",
|
group_id=group_id,
|
||||||
allow_negative=True,
|
note=f"LIVE-BN close perp abandon option {reason}",
|
||||||
)
|
allow_negative=True,
|
||||||
|
)
|
||||||
|
|
||||||
strike = self._group_strike(group_id, option_inst_id)
|
strike = self._group_strike(group_id, option_inst_id)
|
||||||
g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_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 to_usdt(float(upl), "USDT")
|
||||||
return 0.0
|
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:
|
def get_perp_pos_sz(self, symbol: str, *, position_side: str | None = None) -> float | None:
|
||||||
"""当前永续绝对持仓(ETH)。"""
|
"""当前永续绝对持仓(ETH)。"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
+194
-67
@@ -546,30 +546,52 @@ class OkxLiveExecutor(Matcher):
|
|||||||
option_inst_id = str(pos.get("option_inst_id") or "")
|
option_inst_id = str(pos.get("option_inst_id") or "")
|
||||||
opt_contracts = float(pos.get("option_qty_contracts") or 0)
|
opt_contracts = float(pos.get("option_qty_contracts") or 0)
|
||||||
opt_qty = float(pos.get("option_qty_eth") 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 缺期权合约信息")
|
return CloseResult(ok=False, detail="half_open 缺期权合约信息")
|
||||||
client = self._client()
|
client = self._client()
|
||||||
try:
|
ex_sz = exchange_option_abs_size(client, option_inst_id)
|
||||||
opt_live = client.place_market(
|
if ex_sz is None:
|
||||||
inst_id=option_inst_id,
|
return CloseResult(ok=False, detail="half_open:无法核对交易所期权仓位")
|
||||||
side="sell",
|
if ex_sz <= 1e-8:
|
||||||
sz=str(int(round(opt_contracts))),
|
of_px, of_fee, of_notional = 0.0, 0.0, 0.0
|
||||||
td_mode="cash",
|
opt_contracts = 0.0
|
||||||
reduce_only=True,
|
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)
|
now = int(time.time() * 1000)
|
||||||
with self.db._lock:
|
with self.db._lock:
|
||||||
if group_id:
|
if group_id:
|
||||||
@@ -1007,7 +1029,8 @@ class OkxLiveExecutor(Matcher):
|
|||||||
if err:
|
if err:
|
||||||
return CloseResult(ok=False, detail=err)
|
return CloseResult(ok=False, detail=err)
|
||||||
pos = self.current_position()
|
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="无期期持仓可平")
|
return CloseResult(ok=False, detail="无期期持仓可平")
|
||||||
if not (
|
if not (
|
||||||
str(pos.get("hedge_mode") or "") == "option_option"
|
str(pos.get("hedge_mode") or "") == "option_option"
|
||||||
@@ -1026,7 +1049,7 @@ class OkxLiveExecutor(Matcher):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return CloseResult(ok=False, detail=f"期期全平卖腿失败: {e}")
|
return CloseResult(ok=False, detail=f"期期全平卖腿失败: {e}")
|
||||||
# 必须以交易所两腿皆空才落本地 flat
|
# 必须以交易所两腿皆空才落本地 flat
|
||||||
for leg, inst, _qty, _c in legs:
|
for _leg, inst, _qty, _c in legs:
|
||||||
if not inst:
|
if not inst:
|
||||||
continue
|
continue
|
||||||
ex_sz = exchange_option_abs_size(client, inst)
|
ex_sz = exchange_option_abs_size(client, inst)
|
||||||
@@ -1069,6 +1092,13 @@ class OkxLiveExecutor(Matcher):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.db._conn.commit()
|
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:
|
with self.db._lock:
|
||||||
self.db._conn.execute(
|
self.db._conn.execute(
|
||||||
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
|
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
|
||||||
@@ -1077,7 +1107,7 @@ class OkxLiveExecutor(Matcher):
|
|||||||
"closed",
|
"closed",
|
||||||
int(time.time() * 1000),
|
int(time.time() * 1000),
|
||||||
reason,
|
reason,
|
||||||
0.0,
|
net,
|
||||||
f"oo full close {reason} exchange_flat_mirror",
|
f"oo full close {reason} exchange_flat_mirror",
|
||||||
group_id,
|
group_id,
|
||||||
),
|
),
|
||||||
@@ -1097,47 +1127,54 @@ class OkxLiveExecutor(Matcher):
|
|||||||
return CloseResult(
|
return CloseResult(
|
||||||
ok=True,
|
ok=True,
|
||||||
detail="oo_full_closed_live",
|
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(
|
def close_winning_oo_leave_residual(
|
||||||
self, *, reason: str = "target_oo_win"
|
self, *, reason: str = "target_oo_win"
|
||||||
) -> CloseResult:
|
) -> CloseResult:
|
||||||
"""期期达标:先标记 closing,再交易所卖掉盈利腿,再落库。"""
|
"""期期达标:先标记 closing,再按交易所张数卖掉盈利腿,验空后落库。"""
|
||||||
err = self._guard_live()
|
err = self._guard_live()
|
||||||
if err:
|
if err:
|
||||||
return CloseResult(ok=False, detail=err)
|
return CloseResult(ok=False, detail=err)
|
||||||
pos = self.current_position()
|
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="无期期持仓")
|
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()
|
upl = self.unrealized()
|
||||||
call_upl = float(upl.get("option_upl") or 0)
|
call_upl = float(upl.get("option_upl") or 0)
|
||||||
put_upl = float(upl.get("option2_upl") or 0)
|
put_upl = float(upl.get("option2_upl") or 0)
|
||||||
if call_upl >= put_upl and call_upl > 0:
|
if call_upl >= put_upl and call_upl > 0:
|
||||||
|
win_leg = "option"
|
||||||
win_id = str(pos["option_inst_id"])
|
win_id = str(pos["option_inst_id"])
|
||||||
win_contracts = float(pos.get("option_qty_contracts") or 0)
|
|
||||||
elif put_upl > 0:
|
elif put_upl > 0:
|
||||||
|
win_leg = "option2"
|
||||||
win_id = str(pos["option2_inst_id"])
|
win_id = str(pos["option2_inst_id"])
|
||||||
win_contracts = float(pos.get("option2_qty_contracts") or 0)
|
|
||||||
else:
|
else:
|
||||||
return CloseResult(ok=False, detail="无明确盈利腿")
|
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:
|
with self.db._lock:
|
||||||
self.db._conn.execute(
|
self.db._conn.execute(
|
||||||
"UPDATE positions SET status='closing' WHERE id=1 AND status='open'"
|
"UPDATE positions SET status='closing' WHERE id=1 AND status='open'"
|
||||||
)
|
)
|
||||||
self.db._conn.commit()
|
self.db._conn.commit()
|
||||||
client = self._client()
|
|
||||||
try:
|
try:
|
||||||
client.place_market(
|
live = client.place_market(
|
||||||
inst_id=win_id,
|
inst_id=win_id,
|
||||||
side="sell",
|
side="sell",
|
||||||
sz=str(int(round(win_contracts))),
|
sz=str(max(1, int(round(float(ex_sz))))),
|
||||||
td_mode="cash",
|
td_mode="cash",
|
||||||
|
reduce_only=True,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
with self.db._lock:
|
with self.db._lock:
|
||||||
@@ -1146,8 +1183,65 @@ class OkxLiveExecutor(Matcher):
|
|||||||
)
|
)
|
||||||
self.db._conn.commit()
|
self.db._conn.commit()
|
||||||
return CloseResult(ok=False, detail=f"期期平盈利腿失败: {e}")
|
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(
|
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:
|
def close_group(self, *, reason: str, bypass_liquidity: bool = False) -> CloseResult:
|
||||||
@@ -1162,6 +1256,17 @@ class OkxLiveExecutor(Matcher):
|
|||||||
return self.recover_opening()
|
return self.recover_opening()
|
||||||
if st == "half_open":
|
if st == "half_open":
|
||||||
return self.repair_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"):
|
if st not in ("open", "option_closed_perp_pending") or not pos.get("group_id"):
|
||||||
return CloseResult(ok=False, detail="无持仓可平")
|
return CloseResult(ok=False, detail="无持仓可平")
|
||||||
|
|
||||||
@@ -1213,9 +1318,9 @@ class OkxLiveExecutor(Matcher):
|
|||||||
of_fee = float(prev["fee"] or 0)
|
of_fee = float(prev["fee"] or 0)
|
||||||
of_notional = float(prev["notional"] or (of_px * opt_qty))
|
of_notional = float(prev["notional"] or (of_px * opt_qty))
|
||||||
else:
|
else:
|
||||||
of_px = float(intrinsic) if intrinsic is not None else 0.0
|
of_px = 0.0
|
||||||
of_fee = 0.0
|
of_fee = 0.0
|
||||||
of_notional = of_px * opt_qty
|
of_notional = 0.0
|
||||||
self._ensure_option_closed_perp_pending(
|
self._ensure_option_closed_perp_pending(
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
option_inst_id=option_inst_id,
|
option_inst_id=option_inst_id,
|
||||||
@@ -1231,10 +1336,18 @@ class OkxLiveExecutor(Matcher):
|
|||||||
of_slip = 0.0
|
of_slip = 0.0
|
||||||
option_apply_cash = False
|
option_apply_cash = False
|
||||||
elif is_expiry:
|
elif is_expiry:
|
||||||
# 到期:交易所自动结算期权,本地只平永续,不卖期权、不本地发明结算现金
|
# 到期:交易所自动结算期权,本地只平永续;不卖期权、不用 intrinsic 发明 fill
|
||||||
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(
|
||||||
|
"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_fee = 0.0
|
||||||
of_notional = of_px * opt_qty
|
of_notional = 0.0
|
||||||
of_slip = 0.0
|
of_slip = 0.0
|
||||||
option_apply_cash = False
|
option_apply_cash = False
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -2025,40 +2138,54 @@ class OkxLiveExecutor(Matcher):
|
|||||||
ct_val=ct_val,
|
ct_val=ct_val,
|
||||||
allow_db_fallback=False,
|
allow_db_fallback=False,
|
||||||
)
|
)
|
||||||
if perp_sz is None or perp_sz <= 0:
|
if perp_sz is None:
|
||||||
return CloseResult(
|
return CloseResult(
|
||||||
ok=False,
|
ok=False,
|
||||||
detail="弃期权平永续失败: 无法取得有效永续仓位数量",
|
detail="弃期权平永续失败: 无法核对交易所永续仓位",
|
||||||
)
|
)
|
||||||
if perp_side == "long":
|
if perp_sz > 0:
|
||||||
side, pos_side = "sell", "long"
|
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:
|
else:
|
||||||
side, pos_side = "buy", "short"
|
# 永续已空:仍归档期权 residual
|
||||||
perp_live = client.place_market(
|
pf_px = 0.0
|
||||||
inst_id=perp_inst,
|
pf_fee = 0.0
|
||||||
side=side,
|
logger.warning(
|
||||||
sz=str(perp_sz),
|
"abandon: perp already flat on exchange; archive option residual group=%s",
|
||||||
td_mode=self._perp_margin_mode_for_group(group_id),
|
group_id,
|
||||||
pos_side=pos_side,
|
)
|
||||||
reduce_only=True,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return CloseResult(ok=False, detail=f"实盘平永续失败: {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":
|
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:
|
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(
|
if abs(perp_pnl) + abs(pf_fee) > 1e-12:
|
||||||
perp_pnl - pf_fee,
|
self.ledger.apply_cash(
|
||||||
kind="close_perp",
|
perp_pnl - pf_fee,
|
||||||
group_id=group_id,
|
kind="close_perp",
|
||||||
note=f"LIVE close perp abandon option {reason}",
|
group_id=group_id,
|
||||||
allow_negative=True,
|
note=f"LIVE close perp abandon option {reason}",
|
||||||
)
|
allow_negative=True,
|
||||||
|
)
|
||||||
|
|
||||||
# 复用父类归档写入:临时改 fill 路径太重,直接调用父类会再平一次本地假价。
|
# 复用父类归档写入:临时改 fill 路径太重,直接调用父类会再平一次本地假价。
|
||||||
# 因此把实盘价写入后走父类结构——这里内联父类 abandon 的 DB 段。
|
# 因此把实盘价写入后走父类结构——这里内联父类 abandon 的 DB 段。
|
||||||
|
|||||||
@@ -278,14 +278,21 @@ def recover_stuck_opening(executor) -> CloseResult | None:
|
|||||||
group_id = str(pos.get("group_id") or "")
|
group_id = str(pos.get("group_id") or "")
|
||||||
perp_inst = resolve_perp_inst_id(executor.db, group_id=group_id or None)
|
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
|
perp_total = 0.0
|
||||||
for side in ("long", "short"):
|
if not oo_put_inst:
|
||||||
sz = exchange_perp_abs_size(client, ex_name or "", perp_inst, side)
|
for side in ("long", "short"):
|
||||||
if sz is None:
|
sz = exchange_perp_abs_size(client, ex_name or "", perp_inst, side)
|
||||||
return CloseResult(ok=False, detail="recover_opening: 无法查永续")
|
if sz is None:
|
||||||
perp_total += float(sz)
|
return CloseResult(ok=False, detail="recover_opening: 无法查永续")
|
||||||
|
perp_total += float(sz)
|
||||||
|
|
||||||
opt_sz = 0.0
|
opt_sz = 0.0
|
||||||
|
put_sz = 0.0
|
||||||
if option_inst:
|
if option_inst:
|
||||||
raw = exchange_option_abs_size(client, option_inst)
|
raw = exchange_option_abs_size(client, option_inst)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
@@ -304,12 +311,34 @@ def recover_stuck_opening(executor) -> CloseResult | None:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
opt_sz = 0.0
|
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)
|
||||||
|
|
||||||
# 两边皆空 → 清槽
|
# 两边皆空 → 清槽(期期须 Call+Put 皆空)
|
||||||
if opt_sz <= _OPT_EPS and perp_total <= _PERP_EPS:
|
if opt_sz <= _OPT_EPS and put_sz <= _OPT_EPS and perp_total <= _PERP_EPS:
|
||||||
release_open_slot_if_opening(executor.db)
|
release_open_slot_if_opening(executor.db)
|
||||||
return CloseResult(ok=True, detail="recover_opening: 交易所空仓,已释放 opening")
|
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:
|
if not option_inst:
|
||||||
return CloseResult(
|
return CloseResult(
|
||||||
|
|||||||
+67
-21
@@ -26,7 +26,13 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# 禁止新开仓的本地仓位状态(实盘防卡)
|
# 禁止新开仓的本地仓位状态(实盘防卡)
|
||||||
BLOCKING_STATUSES = frozenset(
|
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(
|
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:
|
) -> CloseResult:
|
||||||
"""期期达标:平盈利腿,亏损腿进 residual。skip_market=True 时假定已在交易所卖掉盈利腿。"""
|
"""期期达标:平盈利腿,亏损腿进 residual。skip_market=True 时假定已在交易所卖掉盈利腿。"""
|
||||||
pos = self.current_position()
|
pos = self.current_position()
|
||||||
@@ -649,6 +662,13 @@ class Matcher:
|
|||||||
upl = self.unrealized()
|
upl = self.unrealized()
|
||||||
call_upl = float(upl.get("option_upl") or 0)
|
call_upl = float(upl.get("option_upl") or 0)
|
||||||
put_upl = float(upl.get("option2_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
|
# 盈利腿:UPL 更高且 > 0
|
||||||
if call_upl >= put_upl and call_upl > 0:
|
if call_upl >= put_upl and call_upl > 0:
|
||||||
win_leg, lose_leg = "option", "option2"
|
win_leg, lose_leg = "option", "option2"
|
||||||
@@ -681,16 +701,38 @@ class Matcher:
|
|||||||
return CloseResult(ok=False, detail="无明确盈利腿,暂不平")
|
return CloseResult(ok=False, detail="无明确盈利腿,暂不平")
|
||||||
|
|
||||||
fee_rate = self._fee_rate()
|
fee_rate = self._fee_rate()
|
||||||
if skip_market:
|
exec_mode = "SIM" if get_settings().is_sim else "LIVE"
|
||||||
oq = self._quote_held_option(win_id)
|
from .pricing import PriceResult
|
||||||
fill_px = float(oq.bid) if oq and oq.bid else float(win_entry)
|
|
||||||
of = option_fill(
|
if skip_market and live_fill_px is not None:
|
||||||
action="close",
|
fill_px = float(live_fill_px)
|
||||||
bid=fill_px,
|
fill_fee = float(live_fill_fee or 0)
|
||||||
ask=fill_px,
|
if live_fill_contracts is not None and float(live_fill_contracts) > 0:
|
||||||
qty_eth=win_qty,
|
win_contracts = float(live_fill_contracts)
|
||||||
fee_rate=fee_rate,
|
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:
|
else:
|
||||||
oq = self._quote_held_option(win_id)
|
oq = self._quote_held_option(win_id)
|
||||||
if oq is None or oq.bid is None or float(oq.bid) <= 0:
|
if oq is None or oq.bid is None or float(oq.bid) <= 0:
|
||||||
@@ -715,21 +757,24 @@ class Matcher:
|
|||||||
fee_rate=fee_rate,
|
fee_rate=fee_rate,
|
||||||
)
|
)
|
||||||
cash = of.notional - of.fee
|
cash = of.notional - of.fee
|
||||||
if get_settings().is_sim or not skip_market:
|
apply_live_cash = bool(
|
||||||
self.ledger.apply_cash(
|
get_settings().is_sim
|
||||||
cash, kind="close_option", group_id=group_id, note=f"oo win {win_leg}"
|
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)
|
||||||
elif skip_market:
|
)
|
||||||
# LIVE:交易所已成交,仍记本地账本现金(与其它 LIVE 平仓一致)
|
if apply_live_cash:
|
||||||
try:
|
try:
|
||||||
self.ledger.apply_cash(
|
self.ledger.apply_cash(
|
||||||
cash,
|
cash,
|
||||||
kind="close_option",
|
kind="close_option",
|
||||||
group_id=group_id,
|
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:
|
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)
|
now = int(time.time() * 1000)
|
||||||
expiry_ymd = None
|
expiry_ymd = None
|
||||||
@@ -764,7 +809,7 @@ class Matcher:
|
|||||||
of.slip,
|
of.slip,
|
||||||
of.notional,
|
of.notional,
|
||||||
now,
|
now,
|
||||||
"SIM",
|
exec_mode,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.db._conn.execute(
|
self.db._conn.execute(
|
||||||
@@ -836,7 +881,8 @@ class Matcher:
|
|||||||
) -> CloseResult:
|
) -> CloseResult:
|
||||||
"""期期全平两腿(到期/紧急);无永续。"""
|
"""期期全平两腿(到期/紧急);无永续。"""
|
||||||
pos = self.current_position()
|
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="无期期持仓可平")
|
return CloseResult(ok=False, detail="无期期持仓可平")
|
||||||
if not (
|
if not (
|
||||||
str(pos.get("hedge_mode") or "") == "option_option"
|
str(pos.get("hedge_mode") or "") == "option_option"
|
||||||
|
|||||||
@@ -477,19 +477,53 @@ class StrategyEngine:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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"):
|
elif st in ("open", "option_closed_perp_pending"):
|
||||||
# A:双腿(或续平永续)
|
is_oo = (
|
||||||
r = self.matcher.close_group(reason="emergency", bypass_liquidity=True)
|
str(pos.get("hedge_mode") or "") == "option_option"
|
||||||
if not r.ok and st == "open":
|
or bool(pos.get("option2_inst_id"))
|
||||||
# B:砸不出期权时强制只平永续(不要求远虚)
|
)
|
||||||
abandon = getattr(self.matcher, "close_perp_abandon_option", None)
|
if is_oo and st == "open":
|
||||||
if callable(abandon):
|
close_full = getattr(self.matcher, "close_oo_full", None)
|
||||||
try:
|
if callable(close_full):
|
||||||
r2 = abandon(reason="emergency_perp", require_deep_otm=False)
|
r = close_full(reason="emergency", bypass_liquidity=True)
|
||||||
except TypeError:
|
else:
|
||||||
r2 = abandon(reason="emergency_perp")
|
r = self.matcher.close_group(
|
||||||
if r2.ok:
|
reason="emergency", bypass_liquidity=True
|
||||||
r = r2
|
)
|
||||||
|
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
|
ok = r.ok
|
||||||
detail = r.detail
|
detail = r.detail
|
||||||
close_data = r.data
|
close_data = r.data
|
||||||
@@ -948,6 +982,24 @@ class StrategyEngine:
|
|||||||
)
|
)
|
||||||
return
|
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":
|
if st_pos == "open":
|
||||||
upl = self.matcher.unrealized()
|
upl = self.matcher.unrealized()
|
||||||
from .exits import lock_trade_exit_target, read_locked_exit_target
|
from .exits import lock_trade_exit_target, read_locked_exit_target
|
||||||
@@ -1001,8 +1053,24 @@ class StrategyEngine:
|
|||||||
# 复用 ExitDecision 形态
|
# 复用 ExitDecision 形态
|
||||||
from .exits import 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(
|
decision = ExitDecision(
|
||||||
bool(semi_d.should_close),
|
should_semi,
|
||||||
str(semi_d.reason or ""),
|
str(semi_d.reason or ""),
|
||||||
float(semi_d.net_target or 0),
|
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()
|
ex = str(load_runtime_settings().exchange or "").strip().lower()
|
||||||
if ex in ("binance", "bn"):
|
if ex in ("binance", "bn"):
|
||||||
# 币安资金接口尚未接入;返回 None 使可开判定为「未知」而非误用 OKX
|
from ..live.binance_trade import BinanceTradeClient
|
||||||
logger.debug("open_capacity: binance live balance not wired; treating as unknown")
|
|
||||||
|
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:
|
else:
|
||||||
from ..live.okx_funds import OkxFundsClient
|
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(到期只平永续)
|
## 2026-08-08 — 实盘以交易所为 SoT(到期只平永续)
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|||||||
Reference in New Issue
Block a user