From 101cb3c045b640ca6c5b5c10dcf9abb2883fd183 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 29 Jul 2026 20:47:29 +0800 Subject: [PATCH] Fix ghost finalize on unknown perp size and open rollback hazards. Distinguish exchange query None from flat zero; keep opening when size unknown; idempotent half_open cash; scan options while opening; continue manage after recover. Co-authored-by: Cursor --- backend/app/live/binance_executor.py | 43 ++++++++++++--- backend/app/live/executor.py | 56 +++++++++++++------ backend/app/live/reconcile.py | 37 ++++++++----- backend/app/strategy/engine.py | 21 ++++++-- docs/审计说明-2026-07-29-开平仓与实盘安全.md | 57 ++++++++------------ docs/更新说明.md | 15 ++++++ 6 files changed, 153 insertions(+), 76 deletions(-) diff --git a/backend/app/live/binance_executor.py b/backend/app/live/binance_executor.py index 83fb918..f4821af 100644 --- a/backend/app/live/binance_executor.py +++ b/backend/app/live/binance_executor.py @@ -197,11 +197,11 @@ class BinanceLiveExecutor(Matcher): ) except Exception: live_perp = None - if live_perp is not None and live_perp > 1e-8: + if live_perp is None or live_perp > 1e-8: return OpenResult( ok=False, detail=( - f"永续可能已成交但未确认成交明细(保留 opening): {e}; " + f"永续开仓未确认(保留 opening,禁止回滚期权): {e}; " f"ex_perp={live_perp}" ), ) @@ -397,13 +397,18 @@ class BinanceLiveExecutor(Matcher): """期权已成交、永续未开且回滚失败 → 落 half_open,禁止新开,待 repair。""" perp_inst = resolve_perp_inst_id(self.db, group_id=group_id) initial_premium = of_px * opt_qty - self.ledger.apply_cash( - -(of_px * opt_qty + of_fee), - kind="open_option", - group_id=group_id, - note=f"LIVE-BN half_open option {group_id}", - allow_negative=True, + prior_cash = self.db.fetchone( + "SELECT id FROM ledger_entries WHERE group_id=? AND kind='open_option' LIMIT 1", + (group_id,), ) + if prior_cash is None: + self.ledger.apply_cash( + -(of_px * opt_qty + of_fee), + kind="open_option", + group_id=group_id, + note=f"LIVE-BN half_open option {group_id}", + allow_negative=True, + ) now = int(time.time() * 1000) with self.db._lock: existing = self.db._conn.execute( @@ -598,6 +603,18 @@ class BinanceLiveExecutor(Matcher): pf_px = entry_index if entry_index > 0 else of_px initial_premium = of_px * opt_qty mgn = self._perp_margin_mode() + prior_cash = self.db.fetchone( + "SELECT id FROM ledger_entries WHERE group_id=? AND kind='open_option' LIMIT 1", + (group_id,), + ) + if prior_cash is None and of_px > 0 and opt_qty > 0: + self.ledger.apply_cash( + -(of_px * opt_qty), + kind="open_option", + group_id=group_id, + note=f"LIVE-BN recover promote open_option {group_id}", + allow_negative=True, + ) now = int(time.time() * 1000) with self.db._lock: existing = self.db._conn.execute( @@ -822,6 +839,11 @@ class BinanceLiveExecutor(Matcher): perp_qty_eth=perp_qty, allow_db_fallback=not pending_perp_only, ) + if perp_qty_close is None: + return CloseResult( + ok=False, + detail="期权已平,永续待平(无法核对交易所仓位,禁止空仓 finalize)", + ) if perp_qty_close <= 0: pf_px = float(pos.get("perp_entry_px") or 0) or 0.0 pf_fee = 0.0 @@ -1135,6 +1157,11 @@ class BinanceLiveExecutor(Matcher): perp_side=perp_side, perp_qty_eth=perp_qty, ) + if perp_qty_close is None or perp_qty_close <= 0: + return CloseResult( + ok=False, + detail="币安平永续失败: 无法取得有效永续仓位数量", + ) perp_live = client.place_perp_market( symbol=perp_inst, side=side, diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index 53f14b1..355502e 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -19,6 +19,7 @@ from .reconcile import ( claim_open_slot, exchange_option_abs_size, perp_close_contracts_okx, + perp_open_contracts_okx, recover_stuck_opening, release_open_slot_if_opening, stamp_opening_intent, @@ -189,13 +190,7 @@ class OkxLiveExecutor(Matcher): mgn = self._perp_margin_mode() try: ct_val = client.get_ct_val(perp_inst, inst_type="SWAP") - perp_sz = perp_close_contracts_okx( - client, - perp_inst=perp_inst, - perp_side=perp_side, - perp_qty_eth=perp_qty, - ct_val=ct_val, - ) + perp_sz = perp_open_contracts_okx(perp_qty_eth=perp_qty, ct_val=ct_val) if perp_side == "long": side, pos_side = "buy", "long" else: @@ -216,18 +211,18 @@ class OkxLiveExecutor(Matcher): ) except Exception as e: logger.exception("live open perp failed (likely margin); rollback option") - # 永续可能已成交:先查仓,有仓则不得回滚期权 + # 永续可能已成交:先查仓;查失败或有仓均不得回滚期权 try: live_perp = client.get_perp_pos_sz( perp_inst, pos_side=("long" if perp_side == "long" else "short") ) except Exception: live_perp = None - if live_perp is not None and live_perp > 1e-8: + if live_perp is None or live_perp > 1e-8: return OpenResult( ok=False, detail=( - f"永续可能已成交但未确认成交明细(保留 opening): {e}; " + f"永续开仓未确认(保留 opening,禁止回滚期权): {e}; " f"ex_perp={live_perp}" ), ) @@ -419,13 +414,19 @@ class OkxLiveExecutor(Matcher): """期权已成交、永续未开且回滚失败 → 落 half_open,禁止新开,待 repair。""" perp_inst = resolve_perp_inst_id(self.db, group_id=group_id) initial_premium = of_px * opt_qty - self.ledger.apply_cash( - -(of_px * opt_qty + of_fee), - kind="open_option", - group_id=group_id, - note=f"LIVE half_open option {group_id}", - allow_negative=True, + # 幂等:崩溃重入时勿二次扣权利金 + prior_cash = self.db.fetchone( + "SELECT id FROM ledger_entries WHERE group_id=? AND kind='open_option' LIMIT 1", + (group_id,), ) + if prior_cash is None: + self.ledger.apply_cash( + -(of_px * opt_qty + of_fee), + kind="open_option", + group_id=group_id, + note=f"LIVE half_open option {group_id}", + allow_negative=True, + ) now = int(time.time() * 1000) with self.db._lock: existing = self.db._conn.execute( @@ -629,6 +630,19 @@ class OkxLiveExecutor(Matcher): pf_px = entry_index if entry_index > 0 else of_px initial_premium = of_px * opt_qty mgn = self._perp_margin_mode() + # 幂等补记权利金(崩溃在成交后、账本前时) + prior_cash = self.db.fetchone( + "SELECT id FROM ledger_entries WHERE group_id=? AND kind='open_option' LIMIT 1", + (group_id,), + ) + if prior_cash is None and of_px > 0 and opt_qty > 0: + self.ledger.apply_cash( + -(of_px * opt_qty), + kind="open_option", + group_id=group_id, + note=f"LIVE recover promote open_option {group_id}", + allow_negative=True, + ) now = int(time.time() * 1000) with self.db._lock: existing = self.db._conn.execute( @@ -857,6 +871,11 @@ class OkxLiveExecutor(Matcher): ct_val=ct_val, allow_db_fallback=not pending_perp_only, ) + if perp_sz is None: + return CloseResult( + ok=False, + detail="期权已平,永续待平(无法核对交易所仓位,禁止空仓 finalize)", + ) if perp_sz <= 0: # 永续已在交易所平掉:用入场价近似 finalize(净盈亏由对账校正) pf_px = float(pos.get("perp_entry_px") or 0) or 0.0 @@ -1152,6 +1171,11 @@ class OkxLiveExecutor(Matcher): perp_qty_eth=perp_qty, ct_val=ct_val, ) + if perp_sz is None or perp_sz <= 0: + return CloseResult( + ok=False, + detail="弃期权平永续失败: 无法取得有效永续仓位数量", + ) if perp_side == "long": side, pos_side = "sell", "long" else: diff --git a/backend/app/live/reconcile.py b/backend/app/live/reconcile.py index bb4e20f..7236975 100644 --- a/backend/app/live/reconcile.py +++ b/backend/app/live/reconcile.py @@ -171,7 +171,8 @@ def assert_safe_to_open_live(executor) -> tuple[bool, str]: False, f"交易所有期权仓({opt_inst})但本地未确认持仓,禁止新开,请人工核对", ) - elif st in ("flat", ""): + elif st in ("flat", "", "opening"): + # opening 且尚未 stamp option_inst_id 时仍须扫任意期权残留 any_opt = exchange_any_option_abs(client) if any_opt is None: return False, "无法核对交易所期权持仓" @@ -209,18 +210,20 @@ def perp_close_contracts_okx( perp_qty_eth: float, ct_val: float, allow_db_fallback: bool = True, -) -> int: +) -> int | None: """平永续张数:优先交易所持仓。 - allow_db_fallback=False 且交易所已空仓时返回 0(勿用 DB 数量再下单,防反向开仓)。 + 返回 >0 应下单;0=已确认空仓(仅 allow_db_fallback=False); + None=查仓失败(调用方不得当空仓 finalize)。 """ ps = "long" if perp_side == "long" else "short" ex_sz = client.get_perp_pos_sz(perp_inst, pos_side=ps) - if ex_sz is not None and ex_sz > _PERP_EPS: - return max(1, int(round(ex_sz))) - if ex_sz is not None and ex_sz <= _PERP_EPS: + if ex_sz is None: if not allow_db_fallback: - return 0 + return None + 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))) @@ -233,20 +236,28 @@ def perp_close_qty_eth_binance( perp_side: str, perp_qty_eth: float, allow_db_fallback: bool = True, -) -> float: - """平永续 ETH 数量:优先交易所持仓。交易所空且不允许 fallback → 0。""" +) -> float | None: + """平永续 ETH:>0 下单;0=已确认空;None=查仓失败。""" ps = "LONG" if perp_side == "long" else "SHORT" ex_sz = client.get_perp_pos_sz(perp_inst, position_side=ps) - if ex_sz is not None and ex_sz > _PERP_EPS: - return float(ex_sz) - if ex_sz is not None and ex_sz <= _PERP_EPS: + if ex_sz is None: if not allow_db_fallback: - return 0.0 + return None + 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) +def perp_open_contracts_okx(*, perp_qty_eth: float, ct_val: float) -> int: + """开仓张数:仅按设置名义/面值,不跟交易所残留。""" + if ct_val <= 0: + raise RuntimeError("ct_val invalid") + return max(1, int(round(float(perp_qty_eth) / float(ct_val)))) + + def recover_stuck_opening(executor) -> CloseResult | None: """恢复本地 status=opening: diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 28a697e..5e37aa0 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -556,9 +556,6 @@ class StrategyEngine: r = await asyncio.to_thread(recover) self._note_retry_result("opening", ok=r.ok, detail=r.detail) if r.ok: - self._set_state(phase="idle", last_error=None) - if "half_open" in (r.detail or "") or "提升为 open" in (r.detail or ""): - pass # 继续本 tick 后续逻辑由下一轮处理 try: from ..notify import wecom @@ -569,6 +566,20 @@ class StrategyEngine: ) except Exception: pass + # 恢复后按新状态继续本 tick(half_open/open/flat) + pos = self.matcher.current_position() + st_pos = str(pos.get("status") or "flat") + if st_pos == "opening": + self._set_state(phase="opening_stuck", last_error=r.detail) + return + if st_pos == "flat": + self._set_state(phase="idle", last_error=None) + return + # half_open / open / pending:落入下方分支 + self._set_state( + phase="open" if st_pos == "open" else "closing", + last_error=None, + ) else: self._set_state(phase="opening_stuck", last_error=r.detail) try: @@ -581,7 +592,9 @@ class StrategyEngine: ) except Exception: pass - return + return + else: + return if st_pos == "half_open": allowed, left = self._retry_allowed("half_open") diff --git a/docs/审计说明-2026-07-29-开平仓与实盘安全.md b/docs/审计说明-2026-07-29-开平仓与实盘安全.md index 8efab00..d1e972d 100644 --- a/docs/审计说明-2026-07-29-开平仓与实盘安全.md +++ b/docs/审计说明-2026-07-29-开平仓与实盘安全.md @@ -1,49 +1,36 @@ # 审计说明 — 开/平仓逻辑与实盘安全(2026-07-29) -## 范围 +## 结论(四轮后) -- 开仓 / 平仓 / 到期双腿 / 弃期权 / stuck `opening` 恢复 -- LIVE OKX / 币安执行器与本地账本一致性 -- 交易所对账(永续 + 期权) +开平仓主路径已闭环:占槽 → 意图落库 → 期权 → 永续 → open;平仓先 mark pending 再平永续;崩溃可 `recover_opening`。第四轮修掉「查仓失败当空仓 finalize」等回归。 -## 结论摘要(三轮) +## 四轮关键项 -| 严重度 | 问题 | 处置 | -|--------|------|------| -| Critical | 到期双平期权二次入账 | **已修** | -| Critical | abandon 账本拒记 / 假 residual 双计 | **已修** | -| Critical | 币安 `orderId=` 保留 opening 实际不匹配 → 可能重复开 | **已修**:错误文案带 `orderId=` | -| Critical | `opening` 无元数据,崩溃后无法恢复 | **已修**:`stamp_opening_intent` + `recover_opening` | -| High | 对账只查永续 | **已修**:期权仓位 + flat 时任意期权残留 | -| High | pending 时永续已空仍用 DB 数量下单 | **已修**:`allow_db_fallback=False` → 直接 finalize | -| High | 期权已空仍再卖 / 未 mark | **已修**:查仓跳过再卖;有 close fill 不二次入账 | -| High | 永续开仓异常时可能已成交仍回滚期权 | **已修**:先查永续仓再决定 | -| High | `_wait_fill` 部分成交当全成 | **已修** | -| High | residual LIVE 无 `allow_negative` | **已修** | -| Med | 币安缺保证金模式 | **已修**:落库 + `set_margin_type` | +| 轮次 | 关键修复 | +|------|----------| +| 1 | 到期双入账;abandon `allow_negative`;保证金模式;引擎锁;AUTH;盈亏查询 | +| 2 | abandon 假 residual;部分成交不当全成;residual LIVE 账本 | +| 3 | stamp + recover_opening;期权对账;已空不重卖/不下单;币安 orderId= | +| 4 | 查仓 `None`≠`0`;回滚仅确认空仓;half_open/promote 现金幂等;opening any-option;开仓张数独立 | -## 开仓状态机(自检) +## 开仓 -1. `claim_open_slot` → `opening` -2. `stamp_opening_intent`(写入 group_id / option_inst_id / 数量) -3. 下期权 → 再 stamp 成交均价 -4. 下永续;失败则查永续仓:有仓保留 opening;无仓则回滚期权,回滚失败 → `half_open` -5. 成功 → `open`;启动/tick/`close_group` 遇 `opening` → `recover_opening` +1. `claim` → `assert`(永续+期权残留)→ `stamp` → 买期权 → `stamp` 均价 +2. 开永续用 `perp_open_contracts_okx`(不跟残留) +3. 永续失败:查仓 `None`/有仓 → 保留 opening;确认空 → 回滚期权 / half_open +4. tick/启动/紧急:`recover_opening`(空清槽 / 仅期权 half_open / 双边 promote) -## 平仓状态机(自检) +## 平仓 -1. 卖期权(或到期本地结算)→ `_mark_option_closed_perp_pending`(只一次) -2. 平永续;交易所已空则不下单,直接 finalize -3. abandon:若已 pending,只续平永续,不记 residual -4. finalize 在 pending 时 `skip_option_cash` +1. 卖期权(已空则跳过)→ mark pending 仅一次 +2. 查永续:`None` → 失败返回;`0` → finalize;`>0` → 下单 +3. abandon:已 pending 只续平;禁止假 residual -## 仍需人工场景(极少) +## 已知极少人工场景 -- `opening` 无 `option_inst_id` 且交易所有不明期权仓:禁止自动清槽,企微告警 -- 提升为 `open` 时永续入场价用指数近似(本地镜像;实盘盈亏仍可走交易所对账) +- opening 无 `option_inst_id` 且交易所有不明期权:禁止自动清槽 +- promote 入场价可能用指数近似(本地镜像;实盘盈亏可走交易所对账) ## 涉及文件 -- `backend/app/live/reconcile.py`(核心恢复/对账) -- `executor.py` / `binance_executor.py` / `okx_trade.py` / `binance_trade.py` -- `strategy/engine.py` / `sim/matcher.py` +`live/reconcile.py` · `executor.py` · `binance_executor.py` · `okx_trade.py` · `binance_trade.py` · `strategy/engine.py` · `sim/matcher.py` diff --git a/docs/更新说明.md b/docs/更新说明.md index 7e62f79..0a40bb3 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,21 @@ --- +## 2026-07-29 — 第四轮:查仓失败≠空仓;回滚/账本幂等 + +### 变更 + +1. 平仓查仓 `None` 与 `0` 分离:查失败禁止空仓 finalize(防幽灵平仓)。 +2. 永续开仓异常且查仓失败时禁止回滚期权(保留 opening)。 +3. `half_open` / promote 权利金入账幂等(按 group 已有 `open_option` 跳过)。 +4. `opening` 未 stamp 时仍扫任意期权残留;OKX 开仓改用 `perp_open_contracts_okx`;opening 恢复成功后同 tick 继续 manage。 + +### 审计 + +详见 [`docs/审计说明-2026-07-29-开平仓与实盘安全.md`](./审计说明-2026-07-29-开平仓与实盘安全.md)。 + +--- + ## 2026-07-29 — 第三轮:opening 恢复 / 期权对账 / 平仓兜底 ### 变更