From ec87cf2104bd03d36c572e0449ac2eabcf35e2ef Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 29 Jul 2026 20:11:29 +0800 Subject: [PATCH] Fix LIVE open/close double-book and security audit findings. Prevent expiry dual-close from re-booking option cash, abandon ledger rejection, margin-mode mismatch, and manual close races; harden fill wait and refuse default AUTH_SECRET on LIVE. Co-authored-by: Cursor --- backend/app/api/settings.py | 11 +++ backend/app/api/sim.py | 57 +++++++++------- backend/app/live/binance_executor.py | 19 +++--- backend/app/live/executor.py | 43 ++++++++---- backend/app/live/okx_trade.py | 70 +++++++++++++------- backend/app/main.py | 8 +++ backend/app/models/db.py | 4 +- backend/app/strategy/open_capacity.py | 23 ++++--- docs/审计说明-2026-07-29-开平仓与实盘安全.md | 57 ++++++++++++++++ docs/更新说明.md | 17 +++++ 10 files changed, 229 insertions(+), 80 deletions(-) create mode 100644 docs/审计说明-2026-07-29-开平仓与实盘安全.md diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 2363587..6e2ea5e 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -233,6 +233,17 @@ async def put_strategy_settings( ) equity_to_apply = new_eq + if "perp_margin_mode" in data: + new_mm = str(data["perp_margin_mode"]).strip().lower() + old_mm = str( + db.get_setting("perp_margin_mode", s.perp_margin_mode) or s.perp_margin_mode + ).strip().lower() + if new_mm != old_mm and Matcher(db).has_open_position(): + raise HTTPException( + status_code=409, + detail="有未平仓,无法切换永续保证金模式;请先平仓后再改", + ) + for k, v in data.items(): if k in KEYS: db.set_setting(k, str(v)) diff --git a/backend/app/api/sim.py b/backend/app/api/sim.py index d51e107..c3486b6 100644 --- a/backend/app/api/sim.py +++ b/backend/app/api/sim.py @@ -103,16 +103,20 @@ async def sim_open_group( db.fetchall("SELECT group_id FROM groups WHERE group_id LIKE ?", (f"G-{wkey}-%",)) ) gid = next_group_id(count) - r = ex.open_group( - group_id=gid, - bias=bias, - option_side=option_side, - perp_side=perp_side, - option_inst_id=option_inst, - entry_index_px=float(pick.underlying_px), - strike=pick.pair.strike, - expiry_ymd=pick.pair.expiry_ymd, - ) + engine = get_engine() + async with engine._lock: + if ex.has_open_position(): + raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组") + r = ex.open_group( + group_id=gid, + bias=bias, + option_side=option_side, + perp_side=perp_side, + option_inst_id=option_inst, + entry_index_px=float(pick.underlying_px), + strike=pick.pair.strike, + expiry_ymd=pick.pair.expiry_ymd, + ) if not r.ok: raise HTTPException(status_code=400, detail=r.detail) try: @@ -151,22 +155,25 @@ async def sim_close_group(_user: Annotated[str, Depends(require_user)]) -> dict: ) from ..strategy import get_engine - r = get_executor().close_group(reason="manual") - if not r.ok and not r.liquidity_wait: - raise HTTPException(status_code=400, detail=r.detail) - if r.ok: - # 与自动/紧急全平一致:成功全平后进入组间休息 - get_engine().enter_rest_after_close() - try: - from ..notify import wecom + engine = get_engine() + # 与策略引擎共用锁,避免与自动平仓/开仓竞态 + async with engine._lock: + r = get_executor().close_group(reason="manual") + if not r.ok and not r.liquidity_wait: + raise HTTPException(status_code=400, detail=r.detail) + if r.ok: + # 与自动/紧急全平一致:成功全平后进入组间休息 + engine.enter_rest_after_close() + try: + from ..notify import wecom - wecom.notify_close( - reason="manual", - detail=r.detail, - data=r.data or {}, - ) - except Exception: - pass + wecom.notify_close( + reason="manual", + detail=r.detail, + data=r.data or {}, + ) + except Exception: + pass return { "ok": r.ok, "liquidity_wait": r.liquidity_wait, diff --git a/backend/app/live/binance_executor.py b/backend/app/live/binance_executor.py index e0dca69..1f9ba36 100644 --- a/backend/app/live/binance_executor.py +++ b/backend/app/live/binance_executor.py @@ -114,6 +114,12 @@ 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(): + return OpenResult( + ok=False, + detail=f"币安开期权未确认成交(保留 opening 防重复开,请核对交易所): {e}", + ) release_open_slot_if_opening(self.db) return OpenResult(ok=False, detail=f"币安开期权失败: {e}") @@ -634,6 +640,8 @@ class BinanceLiveExecutor(Matcher): 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, @@ -647,14 +655,8 @@ class BinanceLiveExecutor(Matcher): pf_px=pf_px, pf_fee=pf_fee, reason=reason, - option_fill_already_written=( - st == "option_closed_perp_pending" - or (pending_perp_only and not is_expiry) - ), - skip_option_cash=( - st == "option_closed_perp_pending" - or (pending_perp_only and not is_expiry) - ), + option_fill_already_written=bool(pending_perp_only), + skip_option_cash=bool(pending_perp_only), ) def _mark_option_closed_perp_pending( @@ -950,6 +952,7 @@ class BinanceLiveExecutor(Matcher): 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) diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index 598d879..8c5cb52 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -47,6 +47,18 @@ class OkxLiveExecutor(Matcher): ).strip().lower() return "isolated" if raw == "isolated" else "cross" + def _perp_margin_mode_for_group(self, group_id: str | None) -> str: + """平仓用开仓时写入的保证金模式;缺省回退当前设置。""" + if group_id: + g = self.db.fetchone( + "SELECT perp_margin_mode FROM groups WHERE group_id=?", (group_id,) + ) + if g is not None: + m = str(g["perp_margin_mode"] or "").strip().lower() + if m in ("cross", "isolated"): + return m + return self._perp_margin_mode() + def _guard_live(self) -> str | None: ok, reason = live_ready() if not ok: @@ -130,6 +142,13 @@ class OkxLiveExecutor(Matcher): ) except Exception as e: logger.exception("live open option failed") + msg = str(e) + # 已拿到 ordId:可能已成交,禁止释放 opening 以免重复开仓 + if "ordId=" in msg: + return OpenResult( + ok=False, + detail=f"实盘开期权未确认成交(保留 opening 防重复开,请核对交易所): {e}", + ) release_open_slot_if_opening(self.db) return OpenResult(ok=False, detail=f"实盘开期权失败: {e}") @@ -141,6 +160,7 @@ class OkxLiveExecutor(Matcher): opt_qty = eth_from_contracts(opt_contracts, ct_mult) # 永续市价:按产品假设,失败原因实质为保证金不足 → 必须回滚期权 + mgn = self._perp_margin_mode() try: ct_val = client.get_ct_val(perp_inst, inst_type="SWAP") perp_sz = perp_close_contracts_okx( @@ -155,7 +175,6 @@ class OkxLiveExecutor(Matcher): else: side, pos_side = "sell", "short" leverage = self.ledger.get_setting_float("leverage", s.leverage) - mgn = self._perp_margin_mode() try: client.set_leverage( perp_inst, leverage, mgn_mode=mgn, pos_side=pos_side @@ -239,8 +258,8 @@ class OkxLiveExecutor(Matcher): """INSERT INTO groups( group_id, status, bias, option_side, perp_side, option_inst_id, perp_inst_id, strike, expiry_ymd, entry_index_px, initial_premium, open_at_ms, fees, slip_cost, - exec_mode - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + exec_mode, perp_margin_mode + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( group_id, "open", @@ -257,6 +276,7 @@ class OkxLiveExecutor(Matcher): of_fee + pf_fee, 0.0, "LIVE", + mgn, ), ) self.db._conn.execute( @@ -653,7 +673,7 @@ class OkxLiveExecutor(Matcher): inst_id=perp_inst, side=side, sz=str(perp_sz), - td_mode=self._perp_margin_mode(), + td_mode=self._perp_margin_mode_for_group(group_id), pos_side=pos_side, reduce_only=True, ) @@ -665,6 +685,8 @@ class OkxLiveExecutor(Matcher): 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, @@ -678,14 +700,8 @@ class OkxLiveExecutor(Matcher): pf_px=pf_px, pf_fee=pf_fee, reason=reason, - option_fill_already_written=( - st == "option_closed_perp_pending" - or (pending_perp_only and not is_expiry) - ), - skip_option_cash=( - st == "option_closed_perp_pending" - or (pending_perp_only and not is_expiry) - ), + option_fill_already_written=bool(pending_perp_only), + skip_option_cash=bool(pending_perp_only), ) def _mark_option_closed_perp_pending( @@ -938,7 +954,7 @@ class OkxLiveExecutor(Matcher): inst_id=perp_inst, side=side, sz=str(perp_sz), - td_mode=self._perp_margin_mode(), + td_mode=self._perp_margin_mode_for_group(group_id), pos_side=pos_side, reduce_only=True, ) @@ -957,6 +973,7 @@ class OkxLiveExecutor(Matcher): kind="close_perp", group_id=group_id, note=f"LIVE close perp abandon option {reason}", + allow_negative=True, ) # 复用父类归档写入:临时改 fill 路径太重,直接调用父类会再平一次本地假价。 diff --git a/backend/app/live/okx_trade.py b/backend/app/live/okx_trade.py index 313578e..51c1344 100644 --- a/backend/app/live/okx_trade.py +++ b/backend/app/live/okx_trade.py @@ -157,7 +157,26 @@ class OkxTradeClient: fill = self._wait_fill(inst_id, ord_id) return fill - def _wait_fill(self, inst_id: str, ord_id: str, *, tries: int = 20) -> LiveFill: + def _fill_from_order_row(self, inst_id: str, ord_id: str, row: dict[str, Any]) -> LiveFill: + avg = safe_float(row.get("avgPx")) or 0.0 + sz = safe_float(row.get("accFillSz")) or safe_float(row.get("sz")) or 0.0 + fee = abs(safe_float(row.get("fee")) or 0.0) + fee_ccy = str(row.get("feeCcy") or "USDT") + if fee <= 0 and ord_id: + fee, fee_ccy = self.sum_fill_fees(inst_id, ord_id) + from .money import abs_fee_usdt + + return LiveFill( + inst_id=inst_id, + side=str(row.get("side") or ""), + avg_px=float(avg), + sz=float(sz), + fee=abs_fee_usdt(fee, fee_ccy), + ord_id=ord_id, + raw=row, + ) + + def _wait_fill(self, inst_id: str, ord_id: str, *, tries: int = 40) -> LiveFill: path = f"/api/v5/trade/order?instId={inst_id}&ordId={ord_id}" last: dict[str, Any] = {} for _ in range(tries): @@ -168,25 +187,21 @@ class OkxTradeClient: avg = safe_float(last.get("avgPx")) # 仅完全成交;部分成交继续等,避免账本张数与交易所不一致 if state == "filled" and avg and avg > 0: - sz = safe_float(last.get("accFillSz")) or safe_float(last.get("sz")) or 0.0 - fee = abs(safe_float(last.get("fee")) or 0.0) - fee_ccy = str(last.get("feeCcy") or "USDT") - if fee <= 0 and ord_id: - fee, fee_ccy = self.sum_fill_fees(inst_id, ord_id) - from .money import abs_fee_usdt - - return LiveFill( - inst_id=inst_id, - side=str(last.get("side") or ""), - avg_px=float(avg), - sz=float(sz), - fee=abs_fee_usdt(fee, fee_ccy), - ord_id=ord_id, - raw=last, - ) + return self._fill_from_order_row(inst_id, ord_id, last) 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): + logger.warning( + "OKX fill wait timeout but using last fill data ordId=%s state=%s", + ord_id, + state, + ) + return self._fill_from_order_row(inst_id, ord_id, last) raise RuntimeError(f"OKX 订单未完全成交 ordId={ord_id} last={last}") def sum_fill_fees(self, inst_id: str, ord_id: str) -> tuple[float, str]: @@ -305,24 +320,29 @@ class OkxTradeClient: from .money import to_usdt end = int(end_ms or int(time.time() * 1000)) + begin = int(begin_ms) + # OKX:after=更早时间戳边界,before=更晚;再本地按 uTime 过滤兜底 path = ( f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}" - f"&before={end}&after={int(begin_ms)}" + f"&after={begin}&before={end}" ) try: - # positions-history 用 GET query;部分环境用 before/after 语义相反,失败则返回 None - rows = self._request( - "GET", - f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}", - ) + rows = self._request("GET", path) except Exception as e: logger.warning("okx positions-history failed: %s", e) - return None + try: + rows = self._request( + "GET", + f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}", + ) + except Exception as e2: + logger.warning("okx positions-history fallback failed: %s", e2) + return None total = 0.0 hit = False for row in rows: u_time = int(safe_float(row.get("uTime")) or safe_float(row.get("cTime")) or 0) - if u_time and (u_time < int(begin_ms) - 60_000 or u_time > end + 60_000): + if u_time and (u_time < begin - 60_000 or u_time > end + 60_000): continue rpnl = safe_float(row.get("realizedPnl")) if rpnl is None: diff --git a/backend/app/main.py b/backend/app/main.py index 35eef23..65111e0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -36,6 +36,14 @@ async def lifespan(app: FastAPI): # 而 health 显示 DB 里的 binance → 合约号/盘口错乱(期权一直 -/-) from .exchange.runtime import load_runtime_settings + app_settings = get_settings() + if not app_settings.is_sim: + secret = (app_settings.auth_secret or "").strip() + if not secret or secret == "change-me-eth-hedge-sim-secret": + raise RuntimeError( + "LIVE 拒绝启动:请在 .env 设置非默认 AUTH_SECRET(勿用 change-me-eth-hedge-sim-secret)" + ) + settings = load_runtime_settings() engine = StrategyEngine() set_engine(engine) diff --git a/backend/app/models/db.py b/backend/app/models/db.py index df2a735..621e650 100644 --- a/backend/app/models/db.py +++ b/backend/app/models/db.py @@ -43,7 +43,8 @@ CREATE TABLE IF NOT EXISTS groups ( note TEXT, exec_mode TEXT, funding_usdt REAL, - settle_index_px REAL + settle_index_px REAL, + perp_margin_mode TEXT ); CREATE TABLE IF NOT EXISTS fills ( @@ -164,6 +165,7 @@ class Database: ("groups", "exec_mode", "TEXT"), ("groups", "funding_usdt", "REAL"), ("groups", "settle_index_px", "REAL"), + ("groups", "perp_margin_mode", "TEXT"), ("fills", "exec_mode", "TEXT"), ("fills", "fee_ccy", "TEXT"), ): diff --git a/backend/app/strategy/open_capacity.py b/backend/app/strategy/open_capacity.py index 7800bb9..1c8d7d8 100644 --- a/backend/app/strategy/open_capacity.py +++ b/backend/app/strategy/open_capacity.py @@ -60,15 +60,22 @@ def _live_balances() -> dict[str, float | None]: "trading_usdc": None, } try: - from ..live.okx_funds import OkxFundsClient + from ..exchange.runtime import load_runtime_settings - client = OkxFundsClient() - try: - bal = client.fetch_balances() - out["trading_usdt"] = _f(bal.get("trading_usdt")) - out["trading_usdc"] = _f(bal.get("trading_usdc")) - finally: - client.close() + 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") + else: + from ..live.okx_funds import OkxFundsClient + + client = OkxFundsClient() + try: + bal = client.fetch_balances() + out["trading_usdt"] = _f(bal.get("trading_usdt")) + out["trading_usdc"] = _f(bal.get("trading_usdc")) + finally: + client.close() except Exception as e: logger.warning("open_capacity live balance failed: %s", e) _live_bal_cache["ts"] = now diff --git a/docs/审计说明-2026-07-29-开平仓与实盘安全.md b/docs/审计说明-2026-07-29-开平仓与实盘安全.md new file mode 100644 index 0000000..2cc271d --- /dev/null +++ b/docs/审计说明-2026-07-29-开平仓与实盘安全.md @@ -0,0 +1,57 @@ +# 审计说明 — 开/平仓逻辑与实盘安全(2026-07-29) + +## 范围 + +- 开仓 / 平仓 / 到期双腿平仓 / 弃期权只平永续 +- LIVE 执行器(OKX / 币安)本地账本与交易所一致性 +- 手动开平仓 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,余额视为未知 | + +## 开仓路径(自检) + +1. `claim_open_slot` → `opening` 占槽,防并发双开。 +2. 先期权后永续;永续失败则卖回期权,卖回失败 → `half_open`。 +3. 期权下单已返回 `ordId` 但查单未确认:不释放 `opening`,禁止再开,须核对交易所。 +4. LIVE 成交后本地账本一律 `allow_negative`,避免「交易所有仓、本地拒记」。 + +## 平仓路径(自检) + +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 主要看永续)。 + +## 涉及文件 + +- `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` diff --git a/docs/更新说明.md b/docs/更新说明.md index e590415..7276c59 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,23 @@ --- +## 2026-07-29 — 开平仓/实盘安全审计修复 + +### 变更 + +1. **Critical**:到期双平不再二次入账期权(`skip_option_cash` 跟 `pending_perp_only`)。 +2. **Critical**:弃期权平永续后本地账本强制 `allow_negative`,避免交易所已平、本地卡仓。 +3. 开仓写入 `groups.perp_margin_mode`;平仓用开仓时模式;持仓中禁止改保证金模式。 +4. 手动开/平仓走策略引擎锁,避免与自动 tick 竞态。 +5. OKX 等成交加长并超时有成交则回填;含 `ordId` 的未确认失败保留 `opening`。 +6. 修复 positions-history 盈亏查询未带时间过滤;LIVE 拒绝默认 `AUTH_SECRET`;币安可开不再误调 OKX 资金。 + +### 审计 + +详见 [`docs/审计说明-2026-07-29-开平仓与实盘安全.md`](./审计说明-2026-07-29-开平仓与实盘安全.md)。 + +--- + ## 2026-07-29 — 实盘资金读交易所;永续全仓/逐仓 ### 变更