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