diff --git a/backend/app/live/binance_executor.py b/backend/app/live/binance_executor.py index c5d35bd..32a2574 100644 --- a/backend/app/live/binance_executor.py +++ b/backend/app/live/binance_executor.py @@ -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( diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index f7749b0..d17e3d0 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -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( diff --git a/backend/app/live/reconcile.py b/backend/app/live/reconcile.py index 7236975..0172831 100644 --- a/backend/app/live/reconcile.py +++ b/backend/app/live/reconcile.py @@ -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", diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index e77e180..dda369a 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -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) diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 3d00ccd..7c36809 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -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: diff --git a/backend/tests/test_exchange_sot_close.py b/backend/tests/test_exchange_sot_close.py new file mode 100644 index 0000000..ab92ac9 --- /dev/null +++ b/backend/tests/test_exchange_sot_close.py @@ -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() diff --git a/docs/审计说明-2026-08-08-交易所SoT.md b/docs/审计说明-2026-08-08-交易所SoT.md new file mode 100644 index 0000000..0b2b5d4 --- /dev/null +++ b/docs/审计说明-2026-08-08-交易所SoT.md @@ -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 改参等 +- 币安余额接线 diff --git a/docs/更新说明.md b/docs/更新说明.md index ce10a1c..056640a 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -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 — 半自动出场锚定行权价 + 左右布局 ### 变更