diff --git a/backend/app/live/binance_executor.py b/backend/app/live/binance_executor.py index 1f9ba36..fa87737 100644 --- a/backend/app/live/binance_executor.py +++ b/backend/app/live/binance_executor.py @@ -115,7 +115,8 @@ class BinanceLiveExecutor(Matcher): except Exception as e: logger.exception("binance live open option failed") msg = str(e) - if "orderId=" in msg or "orderId" in msg.lower(): + # 仅当错误带明确 orderId= 时保留 opening(避免校验文案误卡槽) + if "orderId=" in msg: return OpenResult( ok=False, detail=f"币安开期权未确认成交(保留 opening 防重复开,请核对交易所): {e}", diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index 8c5cb52..6737cf6 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -924,6 +924,11 @@ class OkxLiveExecutor(Matcher): if dual.ok: return dual + # close_group 可能已卖掉期权并落 option_closed_perp_pending;勿再记 residual,只续平永续 + pos_after = self.current_position() + if str(pos_after.get("status") or "") == "option_closed_perp_pending": + return self.close_group(reason=reason, bypass_liquidity=True) + if require_deep_otm and not self.option_is_deep_otm(): return CloseResult( ok=False, diff --git a/backend/app/live/okx_trade.py b/backend/app/live/okx_trade.py index 51c1344..a7c52a1 100644 --- a/backend/app/live/okx_trade.py +++ b/backend/app/live/okx_trade.py @@ -191,15 +191,13 @@ class OkxTradeClient: if state in ("canceled", "failed"): raise RuntimeError(f"OKX 订单失败 state={state} {last}") time.sleep(0.3) - # 超时兜底:已 filled 或已有成交量+均价则回填,避免「有仓无账」 + # 超时兜底:仅接受完全成交;部分成交不得当全成记账(会错张数/对冲) state = str(last.get("state") or "") avg = safe_float(last.get("avgPx")) - acc = safe_float(last.get("accFillSz")) or 0.0 - if state == "filled" or (acc > 0 and avg and avg > 0): + if state == "filled" and avg and avg > 0: logger.warning( - "OKX fill wait timeout but using last fill data ordId=%s state=%s", + "OKX fill wait timeout but order filled ordId=%s", ord_id, - state, ) return self._fill_from_order_row(inst_id, ord_id, last) raise RuntimeError(f"OKX 订单未完全成交 ordId={ord_id} last={last}") diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 14f6568..e9b6281 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -854,11 +854,15 @@ class Matcher: opt_qty = float(row["option_qty_eth"]) opt_pnl = (of.fill_px - opt_entry) * opt_qty opt_cash = of.notional - of.fee + from ..config import get_settings + self.ledger.apply_cash( opt_cash, kind="close_option", group_id=group_id, note=f"residual option expiry settle{' force' if force else ''}", + # LIVE 本地账本仅镜像;拒记会导致 residual 永久 pending + allow_negative=not get_settings().is_sim, ) with self.db._lock: diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 88a147f..898b386 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -241,6 +241,24 @@ class StrategyEngine: close_data = r.data if r.ok: self.enter_rest_after_close() + elif st == "opening": + # 开仓未确认:不自动清槽(可能期权已成交);紧急全平仅告警,防重复开 + ok = False + detail = ( + "stuck opening:请核对交易所期权/永续后人工处理" + "(已占槽防重复开,紧急全平不会自动释放)" + ) + close_data = {"status": "opening"} + try: + from ..notify import wecom + + wecom.notify_fault( + title="紧急全平遇到 stuck opening", + detail=detail, + dedupe_key="emergency:opening", + ) + except Exception: + pass elif st in ("open", "option_closed_perp_pending"): # A:双腿(或续平永续) r = self.matcher.close_group(reason="emergency", bypass_liquidity=True) diff --git a/docs/审计说明-2026-07-29-开平仓与实盘安全.md b/docs/审计说明-2026-07-29-开平仓与实盘安全.md index 2cc271d..4b1e07d 100644 --- a/docs/审计说明-2026-07-29-开平仓与实盘安全.md +++ b/docs/审计说明-2026-07-29-开平仓与实盘安全.md @@ -7,51 +7,37 @@ - 手动开平仓 API 与策略引擎并发 - 鉴权密钥、资金可开判定、平仓盈亏查询 -## 结论摘要 +## 结论摘要(含第二轮复审) | 严重度 | 问题 | 处置 | |--------|------|------| -| Critical | 到期双平:`_mark_option_closed_perp_pending` 后仍因 `not is_expiry` 二次入账期权现金/fill | **已修**:`skip_option_cash` / `option_fill_already_written` = `pending_perp_only` | -| Critical | 弃期权路径:交易所已平永续,`apply_cash` 无 `allow_negative` → 本地可拒记卡仓 | **已修**:OKX/币安 abandon 均 `allow_negative=True` | -| High | 平仓用「当前」永续保证金模式,持仓中可改设置导致模式错配 | **已修**:开仓写入 `groups.perp_margin_mode`;平仓读组记录;持仓中禁止改设置 | -| High | 手动 `POST /close-group`(及手动开仓)未占引擎锁,可与自动 tick 竞态 | **已修**:`async with engine._lock` | -| High | 期权成交等待超时 → 释放 opening,可能重复开 | **已修**:等成交通道加长+超时有成交则回填;含 `ordId` 失败保留 opening | -| Med | `get_closed_perp_pnl_usdt` 构造了时间过滤 URL 却请求未过滤路径 | **已修**:优先带 after/before 查询,失败再兜底 | -| Med | LIVE 默认可使用 `change-me-…` 鉴权密钥 | **已修**:LIVE 启动拒绝默认 `AUTH_SECRET` | -| Med | 币安 LIVE 可开资金仍走 OKX 客户端 | **已修**:币安不再误调 OKX,余额视为未知 | +| Critical | 到期双平:`_mark` 后仍二次入账期权 | **已修**(第一轮) | +| Critical | 弃期权 `apply_cash` 无 `allow_negative` | **已修**(第一轮) | +| Critical | OKX abandon:双腿已平期权落 pending 后仍记 residual → 到期再结期权双计 | **已修**(第二轮):pending 则续 `close_group` | +| High | 平仓用当前保证金模式 | **已修**(第一轮,OKX) | +| High | 手动开平无引擎锁 | **已修**(第一轮) | +| High | `_wait_fill` 超时把部分成交当全成 | **已修**(第二轮):超时仅接受 `filled` | +| High | residual 结算无 `allow_negative`(LIVE) | **已修**(第二轮) | +| High | `opening` 卡住无恢复 | **部分**:紧急全平明确告警;不自动清槽(防裸仓清槽) | +| Med | positions-history / AUTH / 币安可开误调 OKX | **已修**(第一轮) | +| Med | 币安 `orderId` 误匹配卡 opening | **已修**(第二轮):仅 `orderId=` | -## 开仓路径(自检) +## 第二轮仍残留(已知) -1. `claim_open_slot` → `opening` 占槽,防并发双开。 -2. 先期权后永续;永续失败则卖回期权,卖回失败 → `half_open`。 -3. 期权下单已返回 `ordId` 但查单未确认:不释放 `opening`,禁止再开,须核对交易所。 -4. LIVE 成交后本地账本一律 `allow_negative`,避免「交易所有仓、本地拒记」。 +- 开仓两腿成交后、写 DB 前进程崩溃 → 交易所满仓、本地 `opening`(需人工对账)。 +- 启动对账主要看永续,不查期权裸仓。 +- 币安未持久化/使用 `perp_margin_mode`;可开资金未接币安余额。 +- LIVE 仅拒绝默认 `AUTH_SECRET`,弱自定义密钥不拦截。 -## 平仓路径(自检) +## 开/平仓自检要点 -1. 先平期权并 `_mark_option_closed_perp_pending`(写 fill + 入账),再平永续。 -2. 永续失败时状态已是 `option_closed_perp_pending`,重试只平永续,不再卖期权。 -3. `_finalize_dual_close` 在 `pending_perp_only` 时跳过期权二次入账(**含 expiry**)。 -4. 弃期权:交易所平永续后本地必须入账成功(`allow_negative`),再归档 residual。 -5. OKX 平永续 `tdMode` 使用开仓时组上记录的 `perp_margin_mode`。 - -## 安全 - -- Web 登录依赖 `AUTH_SECRET`;LIVE 禁止默认密钥启动。 -- 策略/资金 API 均需登录;手动交易另需开关。 -- 不在本文档记录任何真实密钥或口令。 - -## 残留 / 后续 - -- 币安 LIVE 交易账户余额接入后,再恢复精确「可开」判定。 -- `opening` 残留需人工或后续对账任务清槽(现策略:宁卡不开重复仓)。 -- 期权腿交易所对账仍弱于永续(启动 reconcile 主要看永续)。 +1. 期权先平并 `_mark_option_closed_perp_pending`,再平永续;finalize 跳过期权二次入账。 +2. abandon:若已 pending,只续平永续,**禁止**再插 residual。 +3. LIVE 成交后本地账本一律允许透支镜像。 +4. 手动开平与引擎共用 `_lock`。 ## 涉及文件 -- `backend/app/live/executor.py` / `binance_executor.py` -- `backend/app/live/okx_trade.py` -- `backend/app/api/sim.py` / `settings.py` -- `backend/app/main.py` -- `backend/app/models/db.py` -- `backend/app/strategy/open_capacity.py` +- `backend/app/live/executor.py` / `binance_executor.py` / `okx_trade.py` +- `backend/app/sim/matcher.py` / `strategy/engine.py` +- `backend/app/api/sim.py` / `settings.py` / `main.py` diff --git a/docs/更新说明.md b/docs/更新说明.md index 7276c59..69f5b39 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,21 @@ --- +## 2026-07-29 — 第二轮审计:abandon residual / 部分成交 / residual 账本 + +### 变更 + +1. OKX 弃期权:双腿已卖掉期权并落 `option_closed_perp_pending` 时,续平永续,不再误记 residual(防期权双计)。 +2. OKX 等成交超时仅接受 `filled`,不再把部分成交当全成。 +3. LIVE residual 到期结算 `allow_negative`,避免本地拒记卡 pending。 +4. 币安开期权保留 opening 仅匹配 `orderId=`;紧急全平对 stuck `opening` 明确告警。 + +### 审计 + +详见 [`docs/审计说明-2026-07-29-开平仓与实盘安全.md`](./审计说明-2026-07-29-开平仓与实盘安全.md)。 + +--- + ## 2026-07-29 — 开平仓/实盘安全审计修复 ### 变更