Treat exchange as LIVE SoT: expiry closes perp only, no invented option settles.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,7 +9,7 @@ from ..config import get_settings
|
||||
from ..env_store import live_ready
|
||||
from ..sim.liquidity import contracts_for_eth, eth_from_contracts
|
||||
from ..sim.matcher import CloseResult, Matcher, OpenResult
|
||||
from ..sim.pricing import option_expiry_settle, option_intrinsic
|
||||
from ..sim.pricing import option_intrinsic
|
||||
from ..strategy.session import get_session
|
||||
from .binance_trade import BinanceTradeClient
|
||||
from .reconcile import (
|
||||
@@ -626,10 +626,14 @@ class BinanceLiveExecutor(Matcher):
|
||||
option_side = str(pos.get("option_side") or "call")
|
||||
perp_side = str(pos.get("perp_side") or "long")
|
||||
of_px = float(pos.get("option_entry_px") or 0) or 0.0
|
||||
opt_qty = float(pos.get("option_qty_eth") or 0)
|
||||
opt_contracts = float(pos.get("option_qty_contracts") or opt_sz or 0)
|
||||
if opt_qty <= 0 and opt_contracts > 0:
|
||||
opt_qty = eth_from_contracts(opt_contracts, self._ct_mult(option_inst_id))
|
||||
opt_contracts = float(opt_sz) if opt_sz > 0 else float(
|
||||
pos.get("option_qty_contracts") or 0
|
||||
)
|
||||
opt_qty = (
|
||||
eth_from_contracts(opt_contracts, self._ct_mult(option_inst_id))
|
||||
if opt_contracts > 0
|
||||
else float(pos.get("option_qty_eth") or 0)
|
||||
)
|
||||
perp_qty = float(pos.get("perp_qty_eth") or 0) or float(
|
||||
self.ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth)
|
||||
)
|
||||
@@ -916,24 +920,139 @@ class BinanceLiveExecutor(Matcher):
|
||||
data={"hedge_mode": "option_option", "exec_mode": "LIVE"},
|
||||
)
|
||||
|
||||
def live_sell_oo_both(self, *, bypass_liquidity: bool = False) -> None:
|
||||
def live_sell_oo_both(
|
||||
self, *, bypass_liquidity: bool = False, reason: str = ""
|
||||
) -> None:
|
||||
if str(reason or "") == "expiry":
|
||||
logger.info("bn live_sell_oo_both: skip on expiry (exchange auto-settle)")
|
||||
return
|
||||
pos = self.current_position()
|
||||
client = self._client()
|
||||
for inst, contracts in (
|
||||
for inst, _contracts in (
|
||||
(str(pos.get("option_inst_id") or ""), float(pos.get("option_qty_contracts") or 0)),
|
||||
(str(pos.get("option2_inst_id") or ""), float(pos.get("option2_qty_contracts") or 0)),
|
||||
):
|
||||
if not inst or contracts <= 0:
|
||||
if not inst:
|
||||
continue
|
||||
ex_sz = exchange_option_abs_size(client, inst)
|
||||
if ex_sz is None:
|
||||
if not bypass_liquidity:
|
||||
raise RuntimeError(f"期期卖腿查仓失败: {inst}")
|
||||
continue
|
||||
if ex_sz <= 1e-8:
|
||||
continue
|
||||
try:
|
||||
client.place_option_market(
|
||||
symbol=inst, side="SELL", quantity=contracts
|
||||
symbol=inst,
|
||||
side="SELL",
|
||||
quantity=float(ex_sz),
|
||||
reduce_only=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("bn live_sell_oo_both failed inst=%s", inst)
|
||||
if not bypass_liquidity:
|
||||
raise
|
||||
|
||||
def close_oo_full(
|
||||
self, *, reason: str = "expiry", bypass_liquidity: bool = False
|
||||
) -> CloseResult:
|
||||
"""期期 LIVE 全平:以交易所空仓为准;到期不卖期权。"""
|
||||
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("group_id"):
|
||||
return CloseResult(ok=False, detail="无期期持仓可平")
|
||||
if not (
|
||||
str(pos.get("hedge_mode") or "") == "option_option"
|
||||
or pos.get("option2_inst_id")
|
||||
):
|
||||
return CloseResult(ok=False, detail="非期期持仓")
|
||||
group_id = str(pos["group_id"])
|
||||
client = self._client()
|
||||
legs = [
|
||||
("option", str(pos.get("option_inst_id") or ""), float(pos.get("option_qty_eth") or 0), float(pos.get("option_qty_contracts") or 0)),
|
||||
("option2", str(pos.get("option2_inst_id") or ""), float(pos.get("option2_qty_eth") or 0), float(pos.get("option2_qty_contracts") or 0)),
|
||||
]
|
||||
if reason != "expiry":
|
||||
try:
|
||||
self.live_sell_oo_both(bypass_liquidity=bypass_liquidity, reason=reason)
|
||||
except Exception as e:
|
||||
return CloseResult(ok=False, detail=f"期期全平卖腿失败: {e}")
|
||||
for _leg, inst, _qty, _c in legs:
|
||||
if not inst:
|
||||
continue
|
||||
ex_sz = exchange_option_abs_size(client, inst)
|
||||
if ex_sz is None:
|
||||
return CloseResult(
|
||||
ok=False, detail=f"期期全平无法核对交易所仓位: {inst}"
|
||||
)
|
||||
if ex_sz > 1e-8:
|
||||
return CloseResult(
|
||||
ok=False,
|
||||
detail=(
|
||||
f"期期全平等待交易所{'到期结算' if reason == 'expiry' else '成交'}"
|
||||
f": {inst} 仍有 {ex_sz}"
|
||||
),
|
||||
)
|
||||
now = int(time.time() * 1000)
|
||||
for i, (leg, inst, qty, contracts) in enumerate(legs):
|
||||
if not inst:
|
||||
continue
|
||||
with self.db._lock:
|
||||
self.db._conn.execute(
|
||||
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
|
||||
base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
group_id,
|
||||
leg,
|
||||
"close",
|
||||
"flat",
|
||||
inst,
|
||||
qty,
|
||||
contracts,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
now + i,
|
||||
"LIVE",
|
||||
),
|
||||
)
|
||||
self.db._conn.commit()
|
||||
with self.db._lock:
|
||||
self.db._conn.execute(
|
||||
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
|
||||
note=? WHERE group_id=?""",
|
||||
(
|
||||
"closed",
|
||||
int(time.time() * 1000),
|
||||
reason,
|
||||
0.0,
|
||||
f"oo full close {reason} exchange_flat_mirror",
|
||||
group_id,
|
||||
),
|
||||
)
|
||||
self.db._conn.execute(
|
||||
"""UPDATE positions SET
|
||||
group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
|
||||
option_inst_id=NULL, option_side=NULL, option_qty_eth=0,
|
||||
option_qty_contracts=0, option_entry_px=NULL, entry_index_px=NULL,
|
||||
initial_premium=0, exit_target_usdt=NULL, status='flat',
|
||||
hedge_mode=NULL, option2_inst_id=NULL, option2_side=NULL,
|
||||
option2_qty_eth=0, option2_qty_contracts=0, option2_entry_px=NULL,
|
||||
strike2=NULL, initial_premium2=NULL
|
||||
WHERE id=1"""
|
||||
)
|
||||
self.db._conn.commit()
|
||||
return CloseResult(
|
||||
ok=True,
|
||||
detail="oo_full_closed_live_bn",
|
||||
data={"group_id": group_id, "reason": reason, "net": 0.0},
|
||||
)
|
||||
|
||||
def close_winning_oo_leave_residual(
|
||||
self, *, reason: str = "target_oo_win"
|
||||
) -> CloseResult:
|
||||
@@ -1003,7 +1122,6 @@ class BinanceLiveExecutor(Matcher):
|
||||
perp_inst = resolve_perp_inst_id(self.db, group_id=group_id)
|
||||
client = self._client()
|
||||
is_expiry = reason == "expiry"
|
||||
fee_rate = self._fee_rate()
|
||||
pending_perp_only = st == "option_closed_perp_pending"
|
||||
|
||||
sess = get_session()
|
||||
@@ -1020,27 +1138,79 @@ class BinanceLiveExecutor(Matcher):
|
||||
of_fee = 0.0
|
||||
of_slip = 0.0
|
||||
of_notional = 0.0
|
||||
option_apply_cash = True
|
||||
perp_already_flat = False
|
||||
|
||||
if pending_perp_only:
|
||||
# 期权已在上次成交并入账;只读上次平期权 fill
|
||||
prev = self.db.fetchone(
|
||||
"""SELECT fill_px, fee, notional, slip FROM fills
|
||||
WHERE group_id=? AND leg='option' AND action='close'
|
||||
ORDER BY id DESC LIMIT 1""",
|
||||
(group_id,),
|
||||
)
|
||||
if prev is None:
|
||||
if prev is None and not is_expiry:
|
||||
return CloseResult(
|
||||
ok=False,
|
||||
detail="option_closed_perp_pending 缺期权平仓记录,请人工核对",
|
||||
)
|
||||
of_px = float(prev["fill_px"])
|
||||
of_fee = float(prev["fee"] or 0)
|
||||
of_notional = float(prev["notional"] or (of_px * opt_qty))
|
||||
of_slip = 0.0 # LIVE 不计模拟滑点
|
||||
if prev is not None:
|
||||
of_px = float(prev["fill_px"])
|
||||
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_fee = 0.0
|
||||
of_notional = of_px * opt_qty
|
||||
self._ensure_option_closed_perp_pending(
|
||||
group_id=group_id,
|
||||
option_inst_id=option_inst_id,
|
||||
opt_qty=opt_qty,
|
||||
opt_contracts=opt_contracts,
|
||||
of_px=of_px,
|
||||
of_fee=of_fee,
|
||||
of_notional=of_notional,
|
||||
of_slip=0.0,
|
||||
reason=reason,
|
||||
apply_cash=False,
|
||||
)
|
||||
of_slip = 0.0
|
||||
option_apply_cash = False
|
||||
elif is_expiry:
|
||||
of_px = float(intrinsic) if intrinsic is not None else 0.0
|
||||
of_fee = 0.0
|
||||
of_notional = of_px * opt_qty
|
||||
of_slip = 0.0
|
||||
option_apply_cash = False
|
||||
logger.info(
|
||||
"bn expiry: skip option order, close perp only group=%s", group_id
|
||||
)
|
||||
self._ensure_option_closed_perp_pending(
|
||||
group_id=group_id,
|
||||
option_inst_id=option_inst_id,
|
||||
opt_qty=opt_qty,
|
||||
opt_contracts=opt_contracts,
|
||||
of_px=of_px,
|
||||
of_fee=of_fee,
|
||||
of_notional=of_notional,
|
||||
of_slip=of_slip,
|
||||
reason=reason,
|
||||
apply_cash=False,
|
||||
)
|
||||
pending_perp_only = True
|
||||
else:
|
||||
# 含到期:优先交易所真实平期权;失败且无内在价值时可本地结算
|
||||
ex_opt_pre = exchange_option_abs_size(client, option_inst_id)
|
||||
if ex_opt_pre is not None and ex_opt_pre > 1e-8:
|
||||
opt_contracts = float(ex_opt_pre)
|
||||
opt_qty = eth_from_contracts(
|
||||
opt_contracts, self._ct_mult(option_inst_id)
|
||||
)
|
||||
try:
|
||||
if ex_opt_pre is not None and ex_opt_pre <= 1e-8:
|
||||
raise RuntimeError("option already flat on exchange")
|
||||
if opt_contracts <= 0:
|
||||
return CloseResult(
|
||||
ok=False, detail="币安平期权失败: 无有效张数"
|
||||
)
|
||||
opt_live = client.place_option_market(
|
||||
symbol=option_inst_id,
|
||||
side="SELL",
|
||||
@@ -1049,10 +1219,31 @@ class BinanceLiveExecutor(Matcher):
|
||||
)
|
||||
of_px = float(opt_live.avg_px)
|
||||
of_fee = float(opt_live.fee)
|
||||
filled_c = float(opt_live.sz) if opt_live.sz and opt_live.sz > 0 else opt_contracts
|
||||
opt_contracts = filled_c
|
||||
opt_qty = eth_from_contracts(opt_contracts, self._ct_mult(option_inst_id))
|
||||
of_notional = of_px * opt_qty
|
||||
filled_c = (
|
||||
float(opt_live.sz) if opt_live.sz and opt_live.sz > 0 else 0.0
|
||||
)
|
||||
if filled_c <= 1e-12:
|
||||
ex_after = exchange_option_abs_size(client, option_inst_id)
|
||||
if ex_after is None:
|
||||
return CloseResult(
|
||||
ok=False,
|
||||
detail="币安平期权失败: 成交张数未知且无法核对仓位",
|
||||
)
|
||||
if ex_after > 1e-8:
|
||||
return CloseResult(
|
||||
ok=False,
|
||||
detail=f"币安平期权失败: 未确认成交仍有仓 {ex_after}",
|
||||
)
|
||||
of_px = 0.0
|
||||
of_fee = 0.0
|
||||
of_notional = 0.0
|
||||
option_apply_cash = False
|
||||
else:
|
||||
opt_contracts = filled_c
|
||||
opt_qty = eth_from_contracts(
|
||||
opt_contracts, self._ct_mult(option_inst_id)
|
||||
)
|
||||
of_notional = of_px * opt_qty
|
||||
except Exception as e:
|
||||
ex_opt = exchange_option_abs_size(client, option_inst_id)
|
||||
if ex_opt is not None and ex_opt <= 1e-8:
|
||||
@@ -1067,33 +1258,16 @@ class BinanceLiveExecutor(Matcher):
|
||||
of_fee = float(prev["fee"] or 0)
|
||||
of_notional = float(prev["notional"] or (of_px * opt_qty))
|
||||
of_slip = float(prev["slip"] or 0)
|
||||
elif is_expiry and intrinsic is not None:
|
||||
of = option_expiry_settle(
|
||||
intrinsic=float(intrinsic), qty_eth=opt_qty, fee_rate=fee_rate
|
||||
)
|
||||
of_px, of_fee, of_notional = of.fill_px, of.fee, of.notional
|
||||
of_slip = 0.0
|
||||
option_apply_cash = False
|
||||
else:
|
||||
of_px = float(pos.get("option_entry_px") or 0) or 0.0
|
||||
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.warning(
|
||||
"binance option already flat on exchange; skip resell: %s", e
|
||||
)
|
||||
elif is_expiry and intrinsic is not None:
|
||||
of = option_expiry_settle(
|
||||
intrinsic=float(intrinsic), qty_eth=opt_qty, fee_rate=fee_rate
|
||||
)
|
||||
of_px, of_fee, of_notional = (
|
||||
of.fill_px,
|
||||
of.fee,
|
||||
of.notional,
|
||||
)
|
||||
of_slip = 0.0 # LIVE 不计模拟滑点
|
||||
logger.warning(
|
||||
"expiry option exchange close failed, local settle: %s", e
|
||||
)
|
||||
elif not bypass_liquidity:
|
||||
return CloseResult(
|
||||
ok=False,
|
||||
@@ -1103,32 +1277,18 @@ class BinanceLiveExecutor(Matcher):
|
||||
else:
|
||||
return CloseResult(ok=False, detail=f"币安平期权失败: {e}")
|
||||
|
||||
st_now = str(self.current_position().get("status") or "")
|
||||
prev_close = self.db.fetchone(
|
||||
"""SELECT id FROM fills
|
||||
WHERE group_id=? AND leg='option' AND action='close'
|
||||
ORDER BY id DESC LIMIT 1""",
|
||||
(group_id,),
|
||||
self._ensure_option_closed_perp_pending(
|
||||
group_id=group_id,
|
||||
option_inst_id=option_inst_id,
|
||||
opt_qty=opt_qty,
|
||||
opt_contracts=opt_contracts,
|
||||
of_px=of_px,
|
||||
of_fee=of_fee,
|
||||
of_notional=of_notional,
|
||||
of_slip=of_slip,
|
||||
reason=reason,
|
||||
apply_cash=option_apply_cash,
|
||||
)
|
||||
if st_now == "option_closed_perp_pending" or prev_close is not None:
|
||||
if st_now != "option_closed_perp_pending":
|
||||
with self.db._lock:
|
||||
self.db._conn.execute(
|
||||
"UPDATE positions SET status='option_closed_perp_pending' WHERE id=1"
|
||||
)
|
||||
self.db._conn.commit()
|
||||
else:
|
||||
self._mark_option_closed_perp_pending(
|
||||
group_id=group_id,
|
||||
option_inst_id=option_inst_id,
|
||||
opt_qty=opt_qty,
|
||||
opt_contracts=opt_contracts,
|
||||
of_px=of_px,
|
||||
of_fee=of_fee,
|
||||
of_notional=of_notional,
|
||||
of_slip=of_slip,
|
||||
reason=reason,
|
||||
)
|
||||
pending_perp_only = True
|
||||
|
||||
try:
|
||||
@@ -1141,7 +1301,7 @@ class BinanceLiveExecutor(Matcher):
|
||||
perp_inst=perp_inst,
|
||||
perp_side=perp_side,
|
||||
perp_qty_eth=perp_qty,
|
||||
allow_db_fallback=not pending_perp_only,
|
||||
allow_db_fallback=False,
|
||||
)
|
||||
if perp_qty_close is None:
|
||||
return CloseResult(
|
||||
@@ -1149,8 +1309,9 @@ class BinanceLiveExecutor(Matcher):
|
||||
detail="期权已平,永续待平(无法核对交易所仓位,禁止空仓 finalize)",
|
||||
)
|
||||
if perp_qty_close <= 0:
|
||||
pf_px = float(pos.get("perp_entry_px") or 0) or 0.0
|
||||
pf_px = 0.0
|
||||
pf_fee = 0.0
|
||||
perp_already_flat = True
|
||||
logger.warning(
|
||||
"binance perp already flat; finalize without order group=%s",
|
||||
group_id,
|
||||
@@ -1165,14 +1326,14 @@ class BinanceLiveExecutor(Matcher):
|
||||
)
|
||||
pf_px = float(perp_live.avg_px)
|
||||
pf_fee = float(perp_live.fee)
|
||||
perp_qty = float(perp_qty_close)
|
||||
pos = {**pos, "perp_qty_eth": perp_qty}
|
||||
except Exception as e:
|
||||
return CloseResult(
|
||||
ok=False,
|
||||
detail=f"期权已平,永续待平(option_closed_perp_pending): {e}",
|
||||
)
|
||||
|
||||
# 期权已在 _mark_option_closed_perp_pending 入账/写 fill(含到期本地结算),
|
||||
# 此处 pending_perp_only 必为 True;勿再按 is_expiry 二次入账。
|
||||
return self._finalize_dual_close(
|
||||
pos=pos,
|
||||
group_id=group_id,
|
||||
@@ -1187,7 +1348,52 @@ class BinanceLiveExecutor(Matcher):
|
||||
pf_fee=pf_fee,
|
||||
reason=reason,
|
||||
option_fill_already_written=bool(pending_perp_only),
|
||||
skip_option_cash=bool(pending_perp_only),
|
||||
skip_option_cash=True,
|
||||
skip_perp_cash=bool(perp_already_flat),
|
||||
skip_perp_fill=bool(perp_already_flat),
|
||||
settle_index_px=spot,
|
||||
)
|
||||
|
||||
def _ensure_option_closed_perp_pending(
|
||||
self,
|
||||
*,
|
||||
group_id: str,
|
||||
option_inst_id: str,
|
||||
opt_qty: float,
|
||||
opt_contracts: float,
|
||||
of_px: float,
|
||||
of_fee: float,
|
||||
of_notional: float,
|
||||
of_slip: float,
|
||||
reason: str,
|
||||
apply_cash: bool = True,
|
||||
) -> None:
|
||||
st_now = str(self.current_position().get("status") or "")
|
||||
prev_close = self.db.fetchone(
|
||||
"""SELECT id FROM fills
|
||||
WHERE group_id=? AND leg='option' AND action='close'
|
||||
ORDER BY id DESC LIMIT 1""",
|
||||
(group_id,),
|
||||
)
|
||||
if st_now == "option_closed_perp_pending" or prev_close is not None:
|
||||
if st_now != "option_closed_perp_pending":
|
||||
with self.db._lock:
|
||||
self.db._conn.execute(
|
||||
"UPDATE positions SET status='option_closed_perp_pending' WHERE id=1"
|
||||
)
|
||||
self.db._conn.commit()
|
||||
return
|
||||
self._mark_option_closed_perp_pending(
|
||||
group_id=group_id,
|
||||
option_inst_id=option_inst_id,
|
||||
opt_qty=opt_qty,
|
||||
opt_contracts=opt_contracts,
|
||||
of_px=of_px,
|
||||
of_fee=of_fee,
|
||||
of_notional=of_notional,
|
||||
of_slip=of_slip,
|
||||
reason=reason,
|
||||
apply_cash=apply_cash,
|
||||
)
|
||||
|
||||
def _mark_option_closed_perp_pending(
|
||||
@@ -1202,14 +1408,16 @@ class BinanceLiveExecutor(Matcher):
|
||||
of_notional: float,
|
||||
of_slip: float,
|
||||
reason: str,
|
||||
apply_cash: bool = True,
|
||||
) -> None:
|
||||
self.ledger.apply_cash(
|
||||
of_notional - of_fee,
|
||||
kind="close_option",
|
||||
group_id=group_id,
|
||||
note=f"LIVE-BN close option pending perp {reason}",
|
||||
allow_negative=True,
|
||||
)
|
||||
if apply_cash:
|
||||
self.ledger.apply_cash(
|
||||
of_notional - of_fee,
|
||||
kind="close_option",
|
||||
group_id=group_id,
|
||||
note=f"LIVE-BN close option pending perp {reason}",
|
||||
allow_negative=True,
|
||||
)
|
||||
now = int(time.time() * 1000)
|
||||
with self.db._lock:
|
||||
self.db._conn.execute(
|
||||
@@ -1236,9 +1444,14 @@ class BinanceLiveExecutor(Matcher):
|
||||
self.db._conn.execute(
|
||||
"UPDATE positions SET status='option_closed_perp_pending' WHERE id=1"
|
||||
)
|
||||
note = (
|
||||
f"option_closed_perp_pending:{reason}"
|
||||
if apply_cash
|
||||
else f"option_closed_perp_pending:{reason}:no_cash_exchange_sot"
|
||||
)
|
||||
self.db._conn.execute(
|
||||
"UPDATE groups SET fees=COALESCE(fees,0)+?, note=? WHERE group_id=?",
|
||||
(of_fee, f"option_closed_perp_pending:{reason}", group_id),
|
||||
(of_fee if apply_cash else 0.0, note, group_id),
|
||||
)
|
||||
self.db._conn.commit()
|
||||
|
||||
@@ -1259,13 +1472,16 @@ class BinanceLiveExecutor(Matcher):
|
||||
reason: str,
|
||||
option_fill_already_written: bool,
|
||||
skip_option_cash: bool,
|
||||
skip_perp_cash: bool = False,
|
||||
skip_perp_fill: bool = False,
|
||||
settle_index_px: float | None = None,
|
||||
) -> CloseResult:
|
||||
s = live_settings()
|
||||
perp_inst = resolve_perp_inst_id(self.db, group_id=group_id)
|
||||
perp_side = str(pos["perp_side"])
|
||||
perp_qty = float(pos["perp_qty_eth"])
|
||||
opt_entry = float(pos["option_entry_px"])
|
||||
perp_entry = float(pos["perp_entry_px"] or pf_px)
|
||||
opt_entry = float(pos["option_entry_px"] or 0)
|
||||
perp_entry = float(pos["perp_entry_px"] or pf_px or 0)
|
||||
opt_pnl = (of_px - opt_entry) * opt_qty
|
||||
if perp_side == "long":
|
||||
perp_pnl = (pf_px - perp_entry) * perp_qty
|
||||
@@ -1280,18 +1496,23 @@ class BinanceLiveExecutor(Matcher):
|
||||
note=f"LIVE-BN close option {reason}",
|
||||
allow_negative=True,
|
||||
)
|
||||
self.ledger.apply_cash(
|
||||
perp_pnl - pf_fee,
|
||||
kind="close_perp",
|
||||
group_id=group_id,
|
||||
note=f"LIVE-BN close perp {reason}",
|
||||
allow_negative=True,
|
||||
)
|
||||
if not skip_perp_cash:
|
||||
self.ledger.apply_cash(
|
||||
perp_pnl - pf_fee,
|
||||
kind="close_perp",
|
||||
group_id=group_id,
|
||||
note=f"LIVE-BN close perp {reason}",
|
||||
allow_negative=True,
|
||||
)
|
||||
|
||||
now = int(time.time() * 1000)
|
||||
g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
|
||||
base_fees = float((g["fees"] if g else 0) or 0)
|
||||
fees = base_fees + (0.0 if skip_option_cash else of_fee) + pf_fee
|
||||
fees = (
|
||||
base_fees
|
||||
+ (0.0 if skip_option_cash else of_fee)
|
||||
+ (0.0 if skip_perp_cash else pf_fee)
|
||||
)
|
||||
# LIVE:真实成交价已含盘口冲击,不另计/不计模拟滑点
|
||||
of_slip = 0.0
|
||||
slip = 0.0
|
||||
@@ -1320,27 +1541,28 @@ class BinanceLiveExecutor(Matcher):
|
||||
"LIVE",
|
||||
),
|
||||
)
|
||||
self.db._conn.execute(
|
||||
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
|
||||
base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
group_id,
|
||||
"perp",
|
||||
"close",
|
||||
"flat",
|
||||
perp_inst,
|
||||
perp_qty,
|
||||
None,
|
||||
pf_px,
|
||||
pf_px,
|
||||
pf_fee,
|
||||
0.0,
|
||||
pf_px * perp_qty,
|
||||
now + 1,
|
||||
"LIVE",
|
||||
),
|
||||
)
|
||||
if not skip_perp_fill:
|
||||
self.db._conn.execute(
|
||||
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
|
||||
base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
group_id,
|
||||
"perp",
|
||||
"close",
|
||||
"flat",
|
||||
perp_inst,
|
||||
perp_qty,
|
||||
None,
|
||||
pf_px,
|
||||
pf_px,
|
||||
pf_fee,
|
||||
0.0,
|
||||
pf_px * perp_qty,
|
||||
now + 1,
|
||||
"LIVE",
|
||||
),
|
||||
)
|
||||
fills = self.db._conn.execute(
|
||||
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
|
||||
).fetchall()
|
||||
@@ -1348,10 +1570,13 @@ class BinanceLiveExecutor(Matcher):
|
||||
net = summary.get("net_pnl")
|
||||
if net is None:
|
||||
net = opt_pnl + perp_pnl - of_fee - pf_fee
|
||||
close_index = (
|
||||
float(settle_index_px) if settle_index_px is not None else None
|
||||
)
|
||||
self.db._conn.execute(
|
||||
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
|
||||
fees=?, slip_cost=? WHERE group_id=?""",
|
||||
("closed", now, reason, float(net), fees, slip, group_id),
|
||||
fees=?, slip_cost=?, settle_index_px=COALESCE(?, settle_index_px) WHERE group_id=?""",
|
||||
("closed", now, reason, float(net), fees, slip, close_index, group_id),
|
||||
)
|
||||
self.db._conn.execute(
|
||||
"""UPDATE positions SET
|
||||
@@ -1415,7 +1640,10 @@ class BinanceLiveExecutor(Matcher):
|
||||
client = self._client()
|
||||
ex_sz = exchange_option_abs_size(client, option_inst_id)
|
||||
if ex_sz is None:
|
||||
return row
|
||||
logger.warning(
|
||||
"residual %s: exchange size unknown, skip until query ok", group_id
|
||||
)
|
||||
return None
|
||||
ct = self._ct_mult(option_inst_id)
|
||||
local_c = float(row.get("option_qty_contracts") or 0)
|
||||
if local_c <= 0:
|
||||
@@ -1443,7 +1671,7 @@ class BinanceLiveExecutor(Matcher):
|
||||
booked is not None,
|
||||
)
|
||||
return None
|
||||
if local_c > ex_sz + 1e-8:
|
||||
if abs(local_c - ex_sz) > 1e-8:
|
||||
rem_eth = eth_from_contracts(float(ex_sz), ct)
|
||||
init = float(row.get("initial_premium") or 0)
|
||||
local_eth = float(row.get("option_qty_eth") or 0)
|
||||
@@ -1536,11 +1764,13 @@ class BinanceLiveExecutor(Matcher):
|
||||
if filled_c <= 1e-12:
|
||||
return None
|
||||
ex_left = exchange_option_abs_size(client, option_inst_id)
|
||||
remaining = (
|
||||
max(0.0, float(ex_left))
|
||||
if ex_left is not None
|
||||
else max(0.0, opt_contracts - filled_c)
|
||||
)
|
||||
if ex_left is None:
|
||||
logger.warning(
|
||||
"residual close %s: filled but remaining size unknown; leave pending",
|
||||
row.get("group_id"),
|
||||
)
|
||||
return None
|
||||
remaining = max(0.0, float(ex_left))
|
||||
fill_eth = eth_from_contracts(filled_c, self._ct_mult(option_inst_id))
|
||||
now_ms = int(time.time() * 1000)
|
||||
tag = "manual" if skip_premium_ratio else "mid"
|
||||
@@ -1571,7 +1801,12 @@ class BinanceLiveExecutor(Matcher):
|
||||
option_inst_id = str(row.get("option_inst_id") or "")
|
||||
client = self._client()
|
||||
ex_sz = exchange_option_abs_size(client, option_inst_id)
|
||||
if ex_sz is not None and ex_sz <= 1e-8:
|
||||
if ex_sz is None:
|
||||
logger.warning(
|
||||
"residual flatten %s: exchange size unknown", row.get("group_id")
|
||||
)
|
||||
return None
|
||||
if ex_sz <= 1e-8:
|
||||
return {
|
||||
"fill_px": 0.0,
|
||||
"fee": 0.0,
|
||||
@@ -1583,17 +1818,14 @@ class BinanceLiveExecutor(Matcher):
|
||||
"exec_mode": "LIVE",
|
||||
"close_reason": "emergency" if force else "expiry",
|
||||
}
|
||||
opt_contracts = float(row.get("option_qty_contracts") or 0)
|
||||
if ex_sz is not None and ex_sz > 0:
|
||||
opt_contracts = float(ex_sz)
|
||||
if opt_contracts <= 0:
|
||||
opt_contracts = float(
|
||||
contracts_for_eth(
|
||||
float(row.get("option_qty_eth") or 0),
|
||||
self._ct_mult(option_inst_id),
|
||||
)
|
||||
or 0
|
||||
if not force:
|
||||
logger.info(
|
||||
"residual expiry %s: exchange still holds %.4f, wait auto-settle",
|
||||
row.get("group_id"),
|
||||
ex_sz,
|
||||
)
|
||||
return None
|
||||
opt_contracts = float(ex_sz)
|
||||
if opt_contracts <= 0:
|
||||
return None
|
||||
oq = self._quote_held_option(option_inst_id)
|
||||
@@ -1726,6 +1958,7 @@ class BinanceLiveExecutor(Matcher):
|
||||
perp_inst=perp_inst,
|
||||
perp_side=perp_side,
|
||||
perp_qty_eth=perp_qty,
|
||||
allow_db_fallback=False,
|
||||
)
|
||||
if perp_qty_close is None or perp_qty_close <= 0:
|
||||
return CloseResult(
|
||||
|
||||
+383
-137
@@ -11,7 +11,7 @@ from ..exchange.runtime import load_runtime_settings
|
||||
from ..models.db import get_db
|
||||
from ..sim.liquidity import contracts_for_eth, eth_from_contracts
|
||||
from ..sim.matcher import CloseResult, Matcher, OpenResult
|
||||
from ..sim.pricing import option_expiry_settle, option_intrinsic
|
||||
from ..sim.pricing import option_intrinsic
|
||||
from ..strategy.session import get_session
|
||||
from .okx_trade import OkxTradeClient
|
||||
from .reconcile import (
|
||||
@@ -647,10 +647,15 @@ class OkxLiveExecutor(Matcher):
|
||||
option_side = str(pos.get("option_side") or "call")
|
||||
perp_side = str(pos.get("perp_side") or "long")
|
||||
of_px = float(pos.get("option_entry_px") or 0) or 0.0
|
||||
opt_qty = float(pos.get("option_qty_eth") or 0)
|
||||
opt_contracts = float(pos.get("option_qty_contracts") or opt_sz or 0)
|
||||
if opt_qty <= 0 and opt_contracts > 0:
|
||||
opt_qty = eth_from_contracts(opt_contracts, self._ct_mult(option_inst_id))
|
||||
# 数量以交易所为准
|
||||
opt_contracts = float(opt_sz) if opt_sz > 0 else float(
|
||||
pos.get("option_qty_contracts") or 0
|
||||
)
|
||||
opt_qty = (
|
||||
eth_from_contracts(opt_contracts, self._ct_mult(option_inst_id))
|
||||
if opt_contracts > 0
|
||||
else float(pos.get("option_qty_eth") or 0)
|
||||
)
|
||||
perp_qty = float(pos.get("perp_qty_eth") or 0) or float(
|
||||
self.ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth)
|
||||
)
|
||||
@@ -958,28 +963,143 @@ class OkxLiveExecutor(Matcher):
|
||||
data={"hedge_mode": "option_option", "exec_mode": "LIVE"},
|
||||
)
|
||||
|
||||
def live_sell_oo_both(self, *, bypass_liquidity: bool = False) -> None:
|
||||
"""到期/紧急:交易所市价卖掉 Call+Put。"""
|
||||
def live_sell_oo_both(
|
||||
self, *, bypass_liquidity: bool = False, reason: str = ""
|
||||
) -> None:
|
||||
"""紧急等:按交易所张数市价卖掉 Call+Put。到期不卖(交易所自动结算)。"""
|
||||
if str(reason or "") == "expiry":
|
||||
logger.info("live_sell_oo_both: skip on expiry (exchange auto-settle)")
|
||||
return
|
||||
pos = self.current_position()
|
||||
client = self._client()
|
||||
for inst, contracts in (
|
||||
(str(pos.get("option_inst_id") or ""), float(pos.get("option_qty_contracts") or 0)),
|
||||
(str(pos.get("option2_inst_id") or ""), float(pos.get("option2_qty_contracts") or 0)),
|
||||
):
|
||||
if not inst or contracts <= 0:
|
||||
if not inst:
|
||||
continue
|
||||
ex_sz = exchange_option_abs_size(client, inst)
|
||||
if ex_sz is None:
|
||||
if not bypass_liquidity:
|
||||
raise RuntimeError(f"期期卖腿查仓失败: {inst}")
|
||||
continue
|
||||
if ex_sz <= 1e-8:
|
||||
continue
|
||||
sz = max(1, int(round(float(ex_sz))))
|
||||
try:
|
||||
client.place_market(
|
||||
inst_id=inst,
|
||||
side="sell",
|
||||
sz=str(int(round(contracts))),
|
||||
sz=str(sz),
|
||||
td_mode="cash",
|
||||
reduce_only=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("live_sell_oo_both failed inst=%s", inst)
|
||||
if not bypass_liquidity:
|
||||
raise
|
||||
|
||||
def close_oo_full(
|
||||
self, *, reason: str = "expiry", bypass_liquidity: bool = False
|
||||
) -> CloseResult:
|
||||
"""期期 LIVE 全平:以交易所空仓为准;到期不卖期权,仅镜像已结算。"""
|
||||
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("group_id"):
|
||||
return CloseResult(ok=False, detail="无期期持仓可平")
|
||||
if not (
|
||||
str(pos.get("hedge_mode") or "") == "option_option"
|
||||
or pos.get("option2_inst_id")
|
||||
):
|
||||
return CloseResult(ok=False, detail="非期期持仓")
|
||||
group_id = str(pos["group_id"])
|
||||
client = self._client()
|
||||
legs = [
|
||||
("option", str(pos.get("option_inst_id") or ""), float(pos.get("option_qty_eth") or 0), float(pos.get("option_qty_contracts") or 0)),
|
||||
("option2", str(pos.get("option2_inst_id") or ""), float(pos.get("option2_qty_eth") or 0), float(pos.get("option2_qty_contracts") or 0)),
|
||||
]
|
||||
if reason != "expiry":
|
||||
try:
|
||||
self.live_sell_oo_both(bypass_liquidity=bypass_liquidity, reason=reason)
|
||||
except Exception as e:
|
||||
return CloseResult(ok=False, detail=f"期期全平卖腿失败: {e}")
|
||||
# 必须以交易所两腿皆空才落本地 flat
|
||||
for leg, inst, _qty, _c in legs:
|
||||
if not inst:
|
||||
continue
|
||||
ex_sz = exchange_option_abs_size(client, inst)
|
||||
if ex_sz is None:
|
||||
return CloseResult(
|
||||
ok=False, detail=f"期期全平无法核对交易所仓位: {inst}"
|
||||
)
|
||||
if ex_sz > 1e-8:
|
||||
return CloseResult(
|
||||
ok=False,
|
||||
detail=(
|
||||
f"期期全平等待交易所{'到期结算' if reason == 'expiry' else '成交'}"
|
||||
f": {inst} 仍有 {ex_sz}"
|
||||
),
|
||||
)
|
||||
now = int(time.time() * 1000)
|
||||
for i, (leg, inst, qty, contracts) in enumerate(legs):
|
||||
if not inst:
|
||||
continue
|
||||
with self.db._lock:
|
||||
self.db._conn.execute(
|
||||
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
|
||||
base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
group_id,
|
||||
leg,
|
||||
"close",
|
||||
"flat",
|
||||
inst,
|
||||
qty,
|
||||
contracts,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
now + i,
|
||||
"LIVE",
|
||||
),
|
||||
)
|
||||
self.db._conn.commit()
|
||||
with self.db._lock:
|
||||
self.db._conn.execute(
|
||||
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
|
||||
note=? WHERE group_id=?""",
|
||||
(
|
||||
"closed",
|
||||
int(time.time() * 1000),
|
||||
reason,
|
||||
0.0,
|
||||
f"oo full close {reason} exchange_flat_mirror",
|
||||
group_id,
|
||||
),
|
||||
)
|
||||
self.db._conn.execute(
|
||||
"""UPDATE positions SET
|
||||
group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
|
||||
option_inst_id=NULL, option_side=NULL, option_qty_eth=0,
|
||||
option_qty_contracts=0, option_entry_px=NULL, entry_index_px=NULL,
|
||||
initial_premium=0, exit_target_usdt=NULL, status='flat',
|
||||
hedge_mode=NULL, option2_inst_id=NULL, option2_side=NULL,
|
||||
option2_qty_eth=0, option2_qty_contracts=0, option2_entry_px=NULL,
|
||||
strike2=NULL, initial_premium2=NULL
|
||||
WHERE id=1"""
|
||||
)
|
||||
self.db._conn.commit()
|
||||
return CloseResult(
|
||||
ok=True,
|
||||
detail="oo_full_closed_live",
|
||||
data={"group_id": group_id, "reason": reason, "net": 0.0},
|
||||
)
|
||||
|
||||
def close_winning_oo_leave_residual(
|
||||
self, *, reason: str = "target_oo_win"
|
||||
) -> CloseResult:
|
||||
@@ -1055,7 +1175,6 @@ class OkxLiveExecutor(Matcher):
|
||||
perp_inst = resolve_perp_inst_id(self.db, group_id=group_id)
|
||||
client = self._client()
|
||||
is_expiry = reason == "expiry"
|
||||
fee_rate = self._fee_rate()
|
||||
pending_perp_only = st == "option_closed_perp_pending"
|
||||
|
||||
sess = get_session()
|
||||
@@ -1073,26 +1192,82 @@ class OkxLiveExecutor(Matcher):
|
||||
of_slip = 0.0
|
||||
of_notional = 0.0
|
||||
|
||||
option_apply_cash = True
|
||||
perp_already_flat = False
|
||||
|
||||
if pending_perp_only:
|
||||
# 期权已在上次成交并入账;只读上次平期权 fill
|
||||
# 期权已在上次处理;只读上次平期权 fill(到期可无 fill:交易所自动结算)
|
||||
prev = self.db.fetchone(
|
||||
"""SELECT fill_px, fee, notional, slip FROM fills
|
||||
WHERE group_id=? AND leg='option' AND action='close'
|
||||
ORDER BY id DESC LIMIT 1""",
|
||||
(group_id,),
|
||||
)
|
||||
if prev is None:
|
||||
if prev is None and not is_expiry:
|
||||
return CloseResult(
|
||||
ok=False,
|
||||
detail="option_closed_perp_pending 缺期权平仓记录,请人工核对",
|
||||
)
|
||||
of_px = float(prev["fill_px"])
|
||||
of_fee = float(prev["fee"] or 0)
|
||||
of_notional = float(prev["notional"] or (of_px * opt_qty))
|
||||
of_slip = 0.0 # LIVE 不计模拟滑点
|
||||
if prev is not None:
|
||||
of_px = float(prev["fill_px"])
|
||||
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_fee = 0.0
|
||||
of_notional = of_px * opt_qty
|
||||
self._ensure_option_closed_perp_pending(
|
||||
group_id=group_id,
|
||||
option_inst_id=option_inst_id,
|
||||
opt_qty=opt_qty,
|
||||
opt_contracts=opt_contracts,
|
||||
of_px=of_px,
|
||||
of_fee=of_fee,
|
||||
of_notional=of_notional,
|
||||
of_slip=0.0,
|
||||
reason=reason,
|
||||
apply_cash=False,
|
||||
)
|
||||
of_slip = 0.0
|
||||
option_apply_cash = False
|
||||
elif is_expiry:
|
||||
# 到期:交易所自动结算期权,本地只平永续,不卖期权、不本地发明结算现金
|
||||
of_px = float(intrinsic) if intrinsic is not None else 0.0
|
||||
of_fee = 0.0
|
||||
of_notional = of_px * opt_qty
|
||||
of_slip = 0.0
|
||||
option_apply_cash = False
|
||||
logger.info(
|
||||
"expiry: skip option order, close perp only group=%s", group_id
|
||||
)
|
||||
self._ensure_option_closed_perp_pending(
|
||||
group_id=group_id,
|
||||
option_inst_id=option_inst_id,
|
||||
opt_qty=opt_qty,
|
||||
opt_contracts=opt_contracts,
|
||||
of_px=of_px,
|
||||
of_fee=of_fee,
|
||||
of_notional=of_notional,
|
||||
of_slip=of_slip,
|
||||
reason=reason,
|
||||
apply_cash=False,
|
||||
)
|
||||
pending_perp_only = True
|
||||
else:
|
||||
# 含到期:优先交易所真实平期权;失败且无内在价值时可本地结算
|
||||
# 非到期:按交易所仓位卖期权
|
||||
ex_opt_pre = exchange_option_abs_size(client, option_inst_id)
|
||||
if ex_opt_pre is not None and ex_opt_pre > 1e-8:
|
||||
opt_contracts = float(ex_opt_pre)
|
||||
opt_qty = eth_from_contracts(
|
||||
opt_contracts, self._ct_mult(option_inst_id)
|
||||
)
|
||||
try:
|
||||
if ex_opt_pre is not None and ex_opt_pre <= 1e-8:
|
||||
raise RuntimeError("option already flat on exchange")
|
||||
if opt_contracts <= 0:
|
||||
return CloseResult(
|
||||
ok=False, detail="实盘平期权失败: 无有效张数"
|
||||
)
|
||||
opt_live = client.place_market(
|
||||
inst_id=option_inst_id,
|
||||
side="sell",
|
||||
@@ -1102,12 +1277,33 @@ class OkxLiveExecutor(Matcher):
|
||||
)
|
||||
of_px = float(opt_live.avg_px)
|
||||
of_fee = float(opt_live.fee)
|
||||
filled_c = float(opt_live.sz) if opt_live.sz and opt_live.sz > 0 else opt_contracts
|
||||
opt_contracts = filled_c
|
||||
opt_qty = eth_from_contracts(opt_contracts, self._ct_mult(option_inst_id))
|
||||
of_notional = of_px * opt_qty
|
||||
filled_c = (
|
||||
float(opt_live.sz) if opt_live.sz and opt_live.sz > 0 else 0.0
|
||||
)
|
||||
if filled_c <= 1e-12:
|
||||
ex_after = exchange_option_abs_size(client, option_inst_id)
|
||||
if ex_after is None:
|
||||
return CloseResult(
|
||||
ok=False,
|
||||
detail="实盘平期权失败: 成交张数未知且无法核对仓位",
|
||||
)
|
||||
if ex_after > 1e-8:
|
||||
return CloseResult(
|
||||
ok=False,
|
||||
detail=f"实盘平期权失败: 未确认成交仍有仓 {ex_after}",
|
||||
)
|
||||
# 已空:零现金镜像
|
||||
of_px = 0.0
|
||||
of_fee = 0.0
|
||||
of_notional = 0.0
|
||||
option_apply_cash = False
|
||||
else:
|
||||
opt_contracts = filled_c
|
||||
opt_qty = eth_from_contracts(
|
||||
opt_contracts, self._ct_mult(option_inst_id)
|
||||
)
|
||||
of_notional = of_px * opt_qty
|
||||
except Exception as e:
|
||||
# 交易所期权可能已空(上次卖出成功但未 mark):跳过再卖,直接 pending
|
||||
ex_opt = exchange_option_abs_size(client, option_inst_id)
|
||||
if ex_opt is not None and ex_opt <= 1e-8:
|
||||
prev = self.db.fetchone(
|
||||
@@ -1121,34 +1317,17 @@ class OkxLiveExecutor(Matcher):
|
||||
of_fee = float(prev["fee"] or 0)
|
||||
of_notional = float(prev["notional"] or (of_px * opt_qty))
|
||||
of_slip = float(prev["slip"] or 0)
|
||||
elif is_expiry and intrinsic is not None:
|
||||
of = option_expiry_settle(
|
||||
intrinsic=float(intrinsic), qty_eth=opt_qty, fee_rate=fee_rate
|
||||
)
|
||||
of_px, of_fee, of_notional = of.fill_px, of.fee, of.notional
|
||||
of_slip = 0.0
|
||||
option_apply_cash = False
|
||||
else:
|
||||
of_px = float(pos.get("option_entry_px") or 0) or 0.0
|
||||
# 已空且无历史 fill:零现金同步,禁止发明成交
|
||||
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.warning(
|
||||
"option already flat on exchange; skip resell: %s", e
|
||||
)
|
||||
elif is_expiry and intrinsic is not None:
|
||||
# 到期后交易所可能已不能交易:用本地结算,仍进入 pending 再平永续
|
||||
of = option_expiry_settle(
|
||||
intrinsic=float(intrinsic), qty_eth=opt_qty, fee_rate=fee_rate
|
||||
)
|
||||
of_px, of_fee, of_notional = (
|
||||
of.fill_px,
|
||||
of.fee,
|
||||
of.notional,
|
||||
)
|
||||
of_slip = 0.0 # LIVE 不计模拟滑点
|
||||
logger.warning(
|
||||
"expiry option exchange close failed, local settle: %s", e
|
||||
)
|
||||
elif not bypass_liquidity:
|
||||
return CloseResult(
|
||||
ok=False,
|
||||
@@ -1158,46 +1337,30 @@ class OkxLiveExecutor(Matcher):
|
||||
else:
|
||||
return CloseResult(ok=False, detail=f"实盘平期权失败: {e}")
|
||||
|
||||
# 期权已平(或到期本地结算):立刻落 pending,避免永续失败后重试再卖期权
|
||||
st_now = str(self.current_position().get("status") or "")
|
||||
prev_close = self.db.fetchone(
|
||||
"""SELECT id FROM fills
|
||||
WHERE group_id=? AND leg='option' AND action='close'
|
||||
ORDER BY id DESC LIMIT 1""",
|
||||
(group_id,),
|
||||
self._ensure_option_closed_perp_pending(
|
||||
group_id=group_id,
|
||||
option_inst_id=option_inst_id,
|
||||
opt_qty=opt_qty,
|
||||
opt_contracts=opt_contracts,
|
||||
of_px=of_px,
|
||||
of_fee=of_fee,
|
||||
of_notional=of_notional,
|
||||
of_slip=of_slip,
|
||||
reason=reason,
|
||||
apply_cash=option_apply_cash,
|
||||
)
|
||||
if st_now == "option_closed_perp_pending" or prev_close is not None:
|
||||
# 已入账过平期权:只保证 pending,禁止二次现金
|
||||
if st_now != "option_closed_perp_pending":
|
||||
with self.db._lock:
|
||||
self.db._conn.execute(
|
||||
"UPDATE positions SET status='option_closed_perp_pending' WHERE id=1"
|
||||
)
|
||||
self.db._conn.commit()
|
||||
else:
|
||||
self._mark_option_closed_perp_pending(
|
||||
group_id=group_id,
|
||||
option_inst_id=option_inst_id,
|
||||
opt_qty=opt_qty,
|
||||
opt_contracts=opt_contracts,
|
||||
of_px=of_px,
|
||||
of_fee=of_fee,
|
||||
of_notional=of_notional,
|
||||
of_slip=of_slip,
|
||||
reason=reason,
|
||||
)
|
||||
pending_perp_only = True
|
||||
|
||||
try:
|
||||
ct_val = client.get_ct_val(perp_inst, inst_type="SWAP")
|
||||
# pending 路径:交易所已空则禁止用 DB 数量再下单
|
||||
# 实盘平仓数量一律以交易所为准,禁止 DB fallback
|
||||
perp_sz = perp_close_contracts_okx(
|
||||
client,
|
||||
perp_inst=perp_inst,
|
||||
perp_side=perp_side,
|
||||
perp_qty_eth=perp_qty,
|
||||
ct_val=ct_val,
|
||||
allow_db_fallback=not pending_perp_only,
|
||||
allow_db_fallback=False,
|
||||
)
|
||||
if perp_sz is None:
|
||||
return CloseResult(
|
||||
@@ -1205,9 +1368,9 @@ class OkxLiveExecutor(Matcher):
|
||||
detail="期权已平,永续待平(无法核对交易所仓位,禁止空仓 finalize)",
|
||||
)
|
||||
if perp_sz <= 0:
|
||||
# 永续已在交易所平掉:用入场价近似 finalize(净盈亏由对账校正)
|
||||
pf_px = float(pos.get("perp_entry_px") or 0) or 0.0
|
||||
pf_px = 0.0
|
||||
pf_fee = 0.0
|
||||
perp_already_flat = True
|
||||
logger.warning(
|
||||
"perp already flat on exchange; finalize without order group=%s",
|
||||
group_id,
|
||||
@@ -1227,14 +1390,17 @@ class OkxLiveExecutor(Matcher):
|
||||
)
|
||||
pf_px = float(perp_live.avg_px)
|
||||
pf_fee = float(perp_live.fee)
|
||||
try:
|
||||
perp_qty = float(perp_sz) * float(ct_val)
|
||||
pos = {**pos, "perp_qty_eth": perp_qty}
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
return CloseResult(
|
||||
ok=False,
|
||||
detail=f"期权已平,永续待平(option_closed_perp_pending): {e}",
|
||||
)
|
||||
|
||||
# 期权已在 _mark_option_closed_perp_pending 入账/写 fill(含到期本地结算),
|
||||
# 此处 pending_perp_only 必为 True;勿再按 is_expiry 二次入账。
|
||||
return self._finalize_dual_close(
|
||||
pos=pos,
|
||||
group_id=group_id,
|
||||
@@ -1249,7 +1415,53 @@ class OkxLiveExecutor(Matcher):
|
||||
pf_fee=pf_fee,
|
||||
reason=reason,
|
||||
option_fill_already_written=bool(pending_perp_only),
|
||||
skip_option_cash=bool(pending_perp_only),
|
||||
skip_option_cash=True, # 已在 pending 路径入账或到期不入账
|
||||
skip_perp_cash=bool(perp_already_flat),
|
||||
skip_perp_fill=bool(perp_already_flat),
|
||||
settle_index_px=spot,
|
||||
)
|
||||
|
||||
def _ensure_option_closed_perp_pending(
|
||||
self,
|
||||
*,
|
||||
group_id: str,
|
||||
option_inst_id: str,
|
||||
opt_qty: float,
|
||||
opt_contracts: float,
|
||||
of_px: float,
|
||||
of_fee: float,
|
||||
of_notional: float,
|
||||
of_slip: float,
|
||||
reason: str,
|
||||
apply_cash: bool = True,
|
||||
) -> None:
|
||||
"""幂等:落 option_closed_perp_pending;已有 close fill 则只改状态、不二次入账。"""
|
||||
st_now = str(self.current_position().get("status") or "")
|
||||
prev_close = self.db.fetchone(
|
||||
"""SELECT id FROM fills
|
||||
WHERE group_id=? AND leg='option' AND action='close'
|
||||
ORDER BY id DESC LIMIT 1""",
|
||||
(group_id,),
|
||||
)
|
||||
if st_now == "option_closed_perp_pending" or prev_close is not None:
|
||||
if st_now != "option_closed_perp_pending":
|
||||
with self.db._lock:
|
||||
self.db._conn.execute(
|
||||
"UPDATE positions SET status='option_closed_perp_pending' WHERE id=1"
|
||||
)
|
||||
self.db._conn.commit()
|
||||
return
|
||||
self._mark_option_closed_perp_pending(
|
||||
group_id=group_id,
|
||||
option_inst_id=option_inst_id,
|
||||
opt_qty=opt_qty,
|
||||
opt_contracts=opt_contracts,
|
||||
of_px=of_px,
|
||||
of_fee=of_fee,
|
||||
of_notional=of_notional,
|
||||
of_slip=of_slip,
|
||||
reason=reason,
|
||||
apply_cash=apply_cash,
|
||||
)
|
||||
|
||||
def _mark_option_closed_perp_pending(
|
||||
@@ -1264,15 +1476,22 @@ class OkxLiveExecutor(Matcher):
|
||||
of_notional: float,
|
||||
of_slip: float,
|
||||
reason: str,
|
||||
apply_cash: bool = True,
|
||||
) -> None:
|
||||
self.ledger.apply_cash(
|
||||
of_notional - of_fee,
|
||||
kind="close_option",
|
||||
group_id=group_id,
|
||||
note=f"LIVE close option pending perp {reason}",
|
||||
allow_negative=True,
|
||||
)
|
||||
if apply_cash:
|
||||
self.ledger.apply_cash(
|
||||
of_notional - of_fee,
|
||||
kind="close_option",
|
||||
group_id=group_id,
|
||||
note=f"LIVE close option pending perp {reason}",
|
||||
allow_negative=True,
|
||||
)
|
||||
now = int(time.time() * 1000)
|
||||
note = (
|
||||
f"option_closed_perp_pending:{reason}"
|
||||
if apply_cash
|
||||
else f"option_closed_perp_pending:{reason}:no_cash_exchange_sot"
|
||||
)
|
||||
with self.db._lock:
|
||||
self.db._conn.execute(
|
||||
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
|
||||
@@ -1300,7 +1519,7 @@ class OkxLiveExecutor(Matcher):
|
||||
)
|
||||
self.db._conn.execute(
|
||||
"UPDATE groups SET fees=COALESCE(fees,0)+?, note=? WHERE group_id=?",
|
||||
(of_fee, f"option_closed_perp_pending:{reason}", group_id),
|
||||
(of_fee if apply_cash else 0.0, note, group_id),
|
||||
)
|
||||
self.db._conn.commit()
|
||||
|
||||
@@ -1321,13 +1540,16 @@ class OkxLiveExecutor(Matcher):
|
||||
reason: str,
|
||||
option_fill_already_written: bool,
|
||||
skip_option_cash: bool,
|
||||
skip_perp_cash: bool = False,
|
||||
skip_perp_fill: bool = False,
|
||||
settle_index_px: float | None = None,
|
||||
) -> CloseResult:
|
||||
s = live_settings()
|
||||
perp_inst = resolve_perp_inst_id(self.db, group_id=group_id)
|
||||
perp_side = str(pos["perp_side"])
|
||||
perp_qty = float(pos["perp_qty_eth"])
|
||||
opt_entry = float(pos["option_entry_px"])
|
||||
perp_entry = float(pos["perp_entry_px"] or pf_px)
|
||||
opt_entry = float(pos["option_entry_px"] or 0)
|
||||
perp_entry = float(pos["perp_entry_px"] or pf_px or 0)
|
||||
opt_pnl = (of_px - opt_entry) * opt_qty
|
||||
if perp_side == "long":
|
||||
perp_pnl = (pf_px - perp_entry) * perp_qty
|
||||
@@ -1342,18 +1564,23 @@ class OkxLiveExecutor(Matcher):
|
||||
note=f"LIVE close option {reason}",
|
||||
allow_negative=True,
|
||||
)
|
||||
self.ledger.apply_cash(
|
||||
perp_pnl - pf_fee,
|
||||
kind="close_perp",
|
||||
group_id=group_id,
|
||||
note=f"LIVE close perp {reason}",
|
||||
allow_negative=True,
|
||||
)
|
||||
if not skip_perp_cash:
|
||||
self.ledger.apply_cash(
|
||||
perp_pnl - pf_fee,
|
||||
kind="close_perp",
|
||||
group_id=group_id,
|
||||
note=f"LIVE close perp {reason}",
|
||||
allow_negative=True,
|
||||
)
|
||||
|
||||
now = int(time.time() * 1000)
|
||||
g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
|
||||
base_fees = float((g["fees"] if g else 0) or 0)
|
||||
fees = base_fees + (0.0 if skip_option_cash else of_fee) + pf_fee
|
||||
fees = (
|
||||
base_fees
|
||||
+ (0.0 if skip_option_cash else of_fee)
|
||||
+ (0.0 if skip_perp_cash else pf_fee)
|
||||
)
|
||||
# LIVE:真实成交价已含盘口冲击,不另计/不计模拟滑点
|
||||
of_slip = 0.0
|
||||
slip = 0.0
|
||||
@@ -1382,27 +1609,28 @@ class OkxLiveExecutor(Matcher):
|
||||
"LIVE",
|
||||
),
|
||||
)
|
||||
self.db._conn.execute(
|
||||
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
|
||||
base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
group_id,
|
||||
"perp",
|
||||
"close",
|
||||
"flat",
|
||||
perp_inst,
|
||||
perp_qty,
|
||||
None,
|
||||
pf_px,
|
||||
pf_px,
|
||||
pf_fee,
|
||||
0.0,
|
||||
pf_px * perp_qty,
|
||||
now + 1,
|
||||
"LIVE",
|
||||
),
|
||||
)
|
||||
if not skip_perp_fill:
|
||||
self.db._conn.execute(
|
||||
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
|
||||
base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
group_id,
|
||||
"perp",
|
||||
"close",
|
||||
"flat",
|
||||
perp_inst,
|
||||
perp_qty,
|
||||
None,
|
||||
pf_px,
|
||||
pf_px,
|
||||
pf_fee,
|
||||
0.0,
|
||||
pf_px * perp_qty,
|
||||
now + 1,
|
||||
"LIVE",
|
||||
),
|
||||
)
|
||||
fills = self.db._conn.execute(
|
||||
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
|
||||
).fetchall()
|
||||
@@ -1410,7 +1638,9 @@ class OkxLiveExecutor(Matcher):
|
||||
net = summary.get("net_pnl")
|
||||
if net is None:
|
||||
net = opt_pnl + perp_pnl - of_fee - pf_fee
|
||||
close_index = float(spot) if spot is not None else None
|
||||
close_index = (
|
||||
float(settle_index_px) if settle_index_px is not None else None
|
||||
)
|
||||
self.db._conn.execute(
|
||||
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
|
||||
fees=?, slip_cost=?, settle_index_px=COALESCE(?, settle_index_px) WHERE group_id=?""",
|
||||
@@ -1483,7 +1713,10 @@ class OkxLiveExecutor(Matcher):
|
||||
client = self._client()
|
||||
ex_sz = exchange_option_abs_size(client, option_inst_id)
|
||||
if ex_sz is None:
|
||||
return row
|
||||
logger.warning(
|
||||
"residual %s: exchange size unknown, skip until query ok", group_id
|
||||
)
|
||||
return None
|
||||
ct = self._ct_mult(option_inst_id)
|
||||
local_c = float(row.get("option_qty_contracts") or 0)
|
||||
if local_c <= 0:
|
||||
@@ -1511,8 +1744,8 @@ class OkxLiveExecutor(Matcher):
|
||||
booked is not None,
|
||||
)
|
||||
return None
|
||||
# 交易所更少:缩到交易所数量,避免超卖
|
||||
if local_c > ex_sz + 1e-8:
|
||||
# 交易所数量为准:本地偏离则同步(含本地偏少)
|
||||
if abs(local_c - ex_sz) > 1e-8:
|
||||
rem_eth = eth_from_contracts(float(ex_sz), ct)
|
||||
init = float(row.get("initial_premium") or 0)
|
||||
local_eth = float(row.get("option_qty_eth") or 0)
|
||||
@@ -1532,6 +1765,12 @@ class OkxLiveExecutor(Matcher):
|
||||
"option_qty_contracts": float(ex_sz),
|
||||
"initial_premium": init,
|
||||
}
|
||||
logger.info(
|
||||
"residual %s sync contracts local=%.4f → ex=%.4f",
|
||||
group_id,
|
||||
local_c,
|
||||
ex_sz,
|
||||
)
|
||||
return row
|
||||
|
||||
def try_close_one_residual(
|
||||
@@ -1606,10 +1845,13 @@ class OkxLiveExecutor(Matcher):
|
||||
if filled_c <= 1e-12:
|
||||
return None
|
||||
ex_left = exchange_option_abs_size(client, option_inst_id)
|
||||
if ex_left is not None:
|
||||
remaining = max(0.0, float(ex_left))
|
||||
else:
|
||||
remaining = max(0.0, opt_contracts - filled_c)
|
||||
if ex_left is None:
|
||||
logger.warning(
|
||||
"residual close %s: filled but remaining size unknown; leave pending",
|
||||
row.get("group_id"),
|
||||
)
|
||||
return None
|
||||
remaining = max(0.0, float(ex_left))
|
||||
fill_eth = eth_from_contracts(filled_c, self._ct_mult(option_inst_id))
|
||||
now_ms = int(time.time() * 1000)
|
||||
tag = "manual" if skip_premium_ratio else "mid"
|
||||
@@ -1634,14 +1876,19 @@ class OkxLiveExecutor(Matcher):
|
||||
def _try_exchange_flatten_residual(
|
||||
self, row: dict, *, force: bool = False
|
||||
) -> dict | None:
|
||||
"""到期/紧急:优先交易所卖掉残留;失败返回 None 走内在价值。"""
|
||||
"""到期/紧急:优先交易所卖掉残留;失败返回 None(LIVE 禁止本地发明结算)。"""
|
||||
err = self._guard_live()
|
||||
if err:
|
||||
return None
|
||||
option_inst_id = str(row.get("option_inst_id") or "")
|
||||
client = self._client()
|
||||
ex_sz = exchange_option_abs_size(client, option_inst_id)
|
||||
if ex_sz is not None and ex_sz <= 1e-8:
|
||||
if ex_sz is None:
|
||||
logger.warning(
|
||||
"residual flatten %s: exchange size unknown", row.get("group_id")
|
||||
)
|
||||
return None
|
||||
if ex_sz <= 1e-8:
|
||||
return {
|
||||
"fill_px": 0.0,
|
||||
"fee": 0.0,
|
||||
@@ -1653,17 +1900,15 @@ class OkxLiveExecutor(Matcher):
|
||||
"exec_mode": "LIVE",
|
||||
"close_reason": "emergency" if force else "expiry",
|
||||
}
|
||||
opt_contracts = float(row.get("option_qty_contracts") or 0)
|
||||
if ex_sz is not None and ex_sz > 0:
|
||||
opt_contracts = float(ex_sz)
|
||||
if opt_contracts <= 0:
|
||||
opt_contracts = float(
|
||||
contracts_for_eth(
|
||||
float(row.get("option_qty_eth") or 0),
|
||||
self._ct_mult(option_inst_id),
|
||||
)
|
||||
or 0
|
||||
if not force:
|
||||
# 到期:交易所自动结算期权,本地只镜像已空;仍有仓则等下次对账
|
||||
logger.info(
|
||||
"residual expiry %s: exchange still holds %.4f, wait auto-settle",
|
||||
row.get("group_id"),
|
||||
ex_sz,
|
||||
)
|
||||
return None
|
||||
opt_contracts = float(ex_sz)
|
||||
if opt_contracts <= 0:
|
||||
return None
|
||||
oq = self._quote_held_option(option_inst_id)
|
||||
@@ -1778,6 +2023,7 @@ class OkxLiveExecutor(Matcher):
|
||||
perp_side=perp_side,
|
||||
perp_qty_eth=perp_qty,
|
||||
ct_val=ct_val,
|
||||
allow_db_fallback=False,
|
||||
)
|
||||
if perp_sz is None or perp_sz <= 0:
|
||||
return CloseResult(
|
||||
|
||||
@@ -209,12 +209,12 @@ def perp_close_contracts_okx(
|
||||
perp_side: str,
|
||||
perp_qty_eth: float,
|
||||
ct_val: float,
|
||||
allow_db_fallback: bool = True,
|
||||
allow_db_fallback: bool = False,
|
||||
) -> int | None:
|
||||
"""平永续张数:优先交易所持仓。
|
||||
"""平永续张数:以交易所持仓为准。
|
||||
|
||||
返回 >0 应下单;0=已确认空仓(仅 allow_db_fallback=False);
|
||||
None=查仓失败(调用方不得当空仓 finalize)。
|
||||
返回 >0 应下单;0=已确认空仓;None=查仓失败(调用方不得当空仓 finalize)。
|
||||
交易所已确认空仓时绝不回退 DB。allow_db_fallback 仅在查仓失败时可用。
|
||||
"""
|
||||
ps = "long" if perp_side == "long" else "short"
|
||||
ex_sz = client.get_perp_pos_sz(perp_inst, pos_side=ps)
|
||||
@@ -224,9 +224,7 @@ def perp_close_contracts_okx(
|
||||
return max(1, int(round(perp_qty_eth / ct_val)))
|
||||
if ex_sz > _PERP_EPS:
|
||||
return max(1, int(round(ex_sz)))
|
||||
if not allow_db_fallback:
|
||||
return 0
|
||||
return max(1, int(round(perp_qty_eth / ct_val)))
|
||||
return 0
|
||||
|
||||
|
||||
def perp_close_qty_eth_binance(
|
||||
@@ -235,9 +233,9 @@ def perp_close_qty_eth_binance(
|
||||
perp_inst: str,
|
||||
perp_side: str,
|
||||
perp_qty_eth: float,
|
||||
allow_db_fallback: bool = True,
|
||||
allow_db_fallback: bool = False,
|
||||
) -> float | None:
|
||||
"""平永续 ETH:>0 下单;0=已确认空;None=查仓失败。"""
|
||||
"""平永续 ETH:>0 下单;0=已确认空;None=查仓失败。已空绝不回退 DB。"""
|
||||
ps = "LONG" if perp_side == "long" else "SHORT"
|
||||
ex_sz = client.get_perp_pos_sz(perp_inst, position_side=ps)
|
||||
if ex_sz is None:
|
||||
@@ -246,9 +244,7 @@ def perp_close_qty_eth_binance(
|
||||
return float(perp_qty_eth)
|
||||
if ex_sz > _PERP_EPS:
|
||||
return float(ex_sz)
|
||||
if not allow_db_fallback:
|
||||
return 0.0
|
||||
return float(perp_qty_eth)
|
||||
return 0.0
|
||||
|
||||
|
||||
def perp_open_contracts_okx(*, perp_qty_eth: float, ct_val: float) -> int:
|
||||
@@ -327,17 +323,15 @@ def recover_stuck_opening(executor) -> CloseResult | None:
|
||||
if not callable(persist):
|
||||
return CloseResult(ok=False, detail="recover_opening: 无 half_open 落库")
|
||||
of_px = float(pos.get("option_entry_px") or 0) or 0.0
|
||||
# 数量以交易所为准
|
||||
opt_contracts = float(opt_sz)
|
||||
opt_qty = float(pos.get("option_qty_eth") or 0)
|
||||
opt_contracts = float(pos.get("option_qty_contracts") or 0)
|
||||
if opt_contracts <= 0 and hasattr(executor, "_ct_mult"):
|
||||
from ..sim.liquidity import contracts_for_eth
|
||||
|
||||
ct = executor._ct_mult(option_inst)
|
||||
opt_contracts = float(contracts_for_eth(opt_qty or opt_sz, ct)) if opt_qty else float(opt_sz)
|
||||
if opt_qty <= 0 and hasattr(executor, "_ct_mult"):
|
||||
if hasattr(executor, "_ct_mult"):
|
||||
from ..sim.liquidity import eth_from_contracts
|
||||
|
||||
opt_qty = eth_from_contracts(opt_contracts or opt_sz, executor._ct_mult(option_inst))
|
||||
opt_qty = eth_from_contracts(opt_contracts, executor._ct_mult(option_inst))
|
||||
elif opt_qty <= 0:
|
||||
opt_qty = float(opt_sz)
|
||||
persist(
|
||||
group_id=group_id or f"RCV-{option_inst[-12:]}",
|
||||
bias="recover",
|
||||
|
||||
@@ -1928,6 +1928,14 @@ class Matcher:
|
||||
booked["forced"] = force
|
||||
return booked
|
||||
|
||||
# LIVE:禁止用本地内在价值发明结算;等交易所空仓后再镜像
|
||||
if not get_settings().is_sim:
|
||||
logger.warning(
|
||||
"residual settle skip %s: LIVE exchange not flat, no local invent",
|
||||
group_id,
|
||||
)
|
||||
return None
|
||||
|
||||
sess = get_session()
|
||||
snap = sess.snapshot()
|
||||
spot = self._close_spot_px(snap)
|
||||
|
||||
@@ -1080,23 +1080,33 @@ class StrategyEngine:
|
||||
if close_full is not None and (
|
||||
expired.should_close or bypass or reason == "emergency"
|
||||
):
|
||||
# LIVE:先交易所卖两腿
|
||||
for sell_fn_name in (
|
||||
"_live_sell_oo_both",
|
||||
"live_sell_oo_both",
|
||||
):
|
||||
sell_both = getattr(self.matcher, sell_fn_name, None)
|
||||
if callable(sell_both):
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
sell_both, bypass_liquidity=bypass
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("live sell oo both failed")
|
||||
break
|
||||
oo_reason = (
|
||||
reason if reason != "liquidity_retry" else "expiry"
|
||||
)
|
||||
# LIVE:到期不卖(交易所自动结算);紧急等才卖两腿
|
||||
if oo_reason != "expiry":
|
||||
for sell_fn_name in (
|
||||
"_live_sell_oo_both",
|
||||
"live_sell_oo_both",
|
||||
):
|
||||
sell_both = getattr(self.matcher, sell_fn_name, None)
|
||||
if callable(sell_both):
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
sell_both,
|
||||
bypass_liquidity=bypass,
|
||||
reason=oo_reason,
|
||||
)
|
||||
except TypeError:
|
||||
await asyncio.to_thread(
|
||||
sell_both, bypass_liquidity=bypass
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("live sell oo both failed")
|
||||
break
|
||||
r = await asyncio.to_thread(
|
||||
close_full,
|
||||
reason=reason if reason != "liquidity_retry" else "expiry",
|
||||
reason=oo_reason,
|
||||
bypass_liquidity=True,
|
||||
)
|
||||
if r.ok:
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""LIVE 交易所 SoT:平仓数量与到期路径。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.live.reconcile import perp_close_contracts_okx, perp_close_qty_eth_binance
|
||||
|
||||
|
||||
class _FakeOkx:
|
||||
def __init__(self, sz) -> None:
|
||||
self._sz = sz
|
||||
|
||||
def get_perp_pos_sz(self, _inst, pos_side=None):
|
||||
return self._sz
|
||||
|
||||
|
||||
class _FakeBn:
|
||||
def __init__(self, sz) -> None:
|
||||
self._sz = sz
|
||||
|
||||
def get_perp_pos_sz(self, _inst, position_side=None):
|
||||
return self._sz
|
||||
|
||||
|
||||
def test_perp_close_okx_confirmed_flat_never_uses_db() -> None:
|
||||
# 交易所已空:即使 allow_db_fallback=True 也返回 0
|
||||
assert (
|
||||
perp_close_contracts_okx(
|
||||
_FakeOkx(0.0),
|
||||
perp_inst="ETH-USDT-SWAP",
|
||||
perp_side="short",
|
||||
perp_qty_eth=8.0,
|
||||
ct_val=0.01,
|
||||
allow_db_fallback=True,
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def test_perp_close_okx_unknown_fail_closed_by_default() -> None:
|
||||
assert (
|
||||
perp_close_contracts_okx(
|
||||
_FakeOkx(None),
|
||||
perp_inst="ETH-USDT-SWAP",
|
||||
perp_side="long",
|
||||
perp_qty_eth=8.0,
|
||||
ct_val=0.01,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_perp_close_okx_uses_exchange_size() -> None:
|
||||
assert (
|
||||
perp_close_contracts_okx(
|
||||
_FakeOkx(123.0),
|
||||
perp_inst="ETH-USDT-SWAP",
|
||||
perp_side="long",
|
||||
perp_qty_eth=1.0,
|
||||
ct_val=0.01,
|
||||
)
|
||||
== 123
|
||||
)
|
||||
|
||||
|
||||
def test_perp_close_bn_confirmed_flat_never_uses_db() -> None:
|
||||
assert (
|
||||
perp_close_qty_eth_binance(
|
||||
_FakeBn(0.0),
|
||||
perp_inst="ETHUSDT",
|
||||
perp_side="short",
|
||||
perp_qty_eth=8.0,
|
||||
allow_db_fallback=True,
|
||||
)
|
||||
== 0.0
|
||||
)
|
||||
|
||||
|
||||
def test_matcher_live_residual_no_local_invent(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("MODE", "LIVE")
|
||||
from app.models.db import Database
|
||||
from app.sim.matcher import Matcher
|
||||
|
||||
db = Database(tmp_path / "sot.db")
|
||||
m = Matcher(db)
|
||||
monkeypatch.setattr(m, "_try_exchange_flatten_residual", lambda *a, **k: None)
|
||||
row = {
|
||||
"group_id": "G1",
|
||||
"option_inst_id": "ETH-OPT",
|
||||
"option_side": "call",
|
||||
"option_qty_eth": 2.0,
|
||||
"option_qty_contracts": 200.0,
|
||||
"strike": 2000.0,
|
||||
"initial_premium": 10.0,
|
||||
}
|
||||
assert m._settle_one_residual(row, now_ms=1) is None
|
||||
db.close()
|
||||
|
||||
|
||||
def test_okx_expiry_skips_option_order(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("MODE", "LIVE")
|
||||
from app.live.executor import OkxLiveExecutor
|
||||
from app.models.db import Database
|
||||
|
||||
db = Database(tmp_path / "exp.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-exp",
|
||||
"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-exp", "open", "ETH-OPT", "ETH-USDT-SWAP", 1900.0, 1),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
placed = {"opt": 0, "perp": 0}
|
||||
|
||||
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):
|
||||
if "OPT" in inst_id or "-C" in inst_id or "-P" in inst_id:
|
||||
placed["opt"] += 1
|
||||
else:
|
||||
placed["perp"] += 1
|
||||
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: 0.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
|
||||
assert placed["opt"] == 0
|
||||
assert placed["perp"] == 1
|
||||
st = db.fetchone("SELECT status FROM positions WHERE id=1")
|
||||
assert str(st["status"]) == "flat"
|
||||
db.close()
|
||||
@@ -0,0 +1,26 @@
|
||||
# 审计说明 — 2026-08-08 实盘交易所 SoT
|
||||
|
||||
## 原则
|
||||
|
||||
实盘过程中,仓位数量、是否已平、成交回报以**交易所**为准。本地 DB/账本只做镜像,禁止发明成交、发明结算、在查仓失败或已空仓时用 DB 数量下单。
|
||||
|
||||
## 到期特例(产品确认)
|
||||
|
||||
到期后交易所自动结算期权 → **本地只处理永续,不管期权**(不卖、不本地 intrinsic 入账)。
|
||||
|
||||
## 已修
|
||||
|
||||
| 项 | 处理 |
|
||||
|----|------|
|
||||
| 到期本地 `option_expiry_settle` | 删除;OKX/BN `close_group(reason=expiry)` 跳过期权单 |
|
||||
| `allow_db_fallback` 已空仍用 DB | 已空恒返回 0;默认 fallback=False |
|
||||
| LIVE 残仓内在价值兜底 | `_settle_one_residual` LIVE 直接跳过 |
|
||||
| 到期残仓仍有仓 | 等待交易所空仓后零现金镜像 |
|
||||
| 期期到期 `live_sell_oo_both` | 跳过;`close_oo_full` 确认两腿空仓再镜像 |
|
||||
| recover 期权张数 | 以 `opt_sz` 回写 |
|
||||
|
||||
## 仍待(非本轮)
|
||||
|
||||
- 期期盈利腿仍可能用报价镜像 fill(非到期路径)
|
||||
- 半自动部分成交改永续、armed 改参等
|
||||
- 币安余额接线
|
||||
@@ -5,6 +5,21 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-08 — 实盘以交易所为 SoT(到期只平永续)
|
||||
|
||||
### 变更
|
||||
|
||||
1. **到期**:不再卖期权、不再本地 `option_expiry_settle` 发明现金;交易所自动结算期权,本地只平永续。
|
||||
2. **平永续数量**:以交易所仓位为准;已确认空仓绝不回退 DB;查仓失败 fail-closed。
|
||||
3. **残仓/期期**:LIVE 禁止本地内在价值发明结算;到期残仓等交易所空仓再镜像;期期到期不卖腿。
|
||||
4. **recover promote**:期权张数以交易所为准回写。
|
||||
|
||||
### 审计
|
||||
|
||||
对齐「实盘一律以交易所数据为准」:去掉到期本地结算与 DB 空仓仍下单等 P0 偏离。
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-08 — 半自动出场锚定行权价 + 左右布局
|
||||
|
||||
### 变更
|
||||
|
||||
Reference in New Issue
Block a user