diff --git a/backend/app/api/backup_routes.py b/backend/app/api/backup_routes.py index f360805..022905f 100644 --- a/backend/app/api/backup_routes.py +++ b/backend/app/api/backup_routes.py @@ -83,10 +83,17 @@ async def download_backup( raise HTTPException(status_code=400, detail=str(e)) from e except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) from e + from starlette.background import BackgroundTask + + from ..backup import materialize_download_zip + + # 下载包剥离 .env,机内完整备份仍保留供恢复 + safe = materialize_download_zip(path) return FileResponse( - path, + safe, media_type="application/zip", - filename=path.name, + filename=path.name.replace(".zip", "_noenv.zip"), + background=BackgroundTask(lambda: safe.unlink(missing_ok=True)), ) diff --git a/backend/app/api/funds.py b/backend/app/api/funds.py index de3d2e9..8bf1887 100644 --- a/backend/app/api/funds.py +++ b/backend/app/api/funds.py @@ -230,6 +230,12 @@ async def funds_transfer( ) finally: client.close() + try: + from ..strategy.open_capacity import invalidate_live_balance_cache + + invalidate_live_balance_cache() + except Exception: + pass if not r.get("ok"): raise HTTPException(status_code=400, detail=r.get("detail") or "划转失败") return r diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index c263147..ba1603d 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -315,6 +315,32 @@ async def put_strategy_settings( detail="有未平仓,无法切换永续保证金模式;请先平仓后再改", ) + # 持仓中禁止改动会影响本组成交/出场的参数(基线:运行中无人工开平仓,策略锁定本组成交) + if Matcher(db).has_open_position(): + locked_keys = ( + "net_profit_target", + "exit_mode", + "premium_exit_multiple", + "perp_qty_eth", + "option_qty_eth", + "leverage", + "sizing_mode", + "risk_perp_unit", + "risk_option_unit", + "risk_exit_unit", + "risk_loss_mode", + "risk_loss_pct", + "risk_loss_usdt", + "risk_capital_source", + "risk_manual_capital_usdt", + ) + hit = [k for k in locked_keys if k in data] + if hit: + raise HTTPException( + status_code=409, + detail=f"有未平仓,禁止修改本组成交相关参数:{', '.join(hit)};请先平仓", + ) + # 以损定仓 ↔ 手动仓位互斥;开启以损定仓时强制 fixed_usdt,并忽略手填名义/出场 sizing_mode = str( data.get( @@ -458,6 +484,12 @@ async def put_runtime_settings( status_code=400, detail="切换到 LIVE 须在 confirm_live_phrase 传入 LIVE", ) + secret = (s.auth_secret or "").strip() + if not secret or secret == "change-me-eth-hedge-sim-secret": + raise HTTPException( + status_code=400, + detail="切到 LIVE 前请在 .env 设置非默认 AUTH_SECRET", + ) updates: dict[str, str] = {} if body.okx_api_key is not None and body.okx_api_key.strip(): @@ -495,6 +527,24 @@ async def put_runtime_settings( detail="切到 LIVE 前请先配置完整币安 API Key/Secret", ) + # 热切 LIVE:强制暂停策略(对齐冷启动护栏;运行中禁止人工开平仓基线) + if new_mode == "LIVE" and cur_mode != "LIVE": + db.execute( + "UPDATE strategy_state SET running=0, phase=?, last_error=? WHERE id=1", + ("paused", "已切换 LIVE,策略已强制暂停;确认就绪后再启动"), + ) + try: + from ..strategy import get_engine + + # 同步停循环标志(pause 为 async,此处只写状态) + get_engine()._set_state( + running=0, + phase="paused", + last_error="已切换 LIVE,策略已强制暂停;确认就绪后再启动", + ) + except Exception: + pass + try: from ..strategy import get_engine diff --git a/backend/app/api/sim.py b/backend/app/api/sim.py index de1bf32..7ffc26b 100644 --- a/backend/app/api/sim.py +++ b/backend/app/api/sim.py @@ -105,41 +105,15 @@ async def sim_open_group( wkey = window_key() db = get_db() - from ..strategy.risk_sizing import apply_risk_sizing_to_ledger + from ..strategy.open_pipeline import size_and_gate - rs = apply_risk_sizing_to_ledger( + prep = size_and_gate( index_px=float(pick.underlying_px), option_ask=sizing_ask, db=db, ) - if not rs.ok: - raise HTTPException(status_code=409, detail=rs.detail) - - try: - from ..strategy.auto_usdc import ensure_okx_trading_usdc - - ensure_okx_trading_usdc(db) - except Exception: - pass - - try: - from ..strategy.open_capacity import assess_open_capacity - - cap = assess_open_capacity(db) - if cap.get("perp_can_open") is False or cap.get("option_can_open") is False: - detail = ( - f"{cap.get('perp_label')} · {cap.get('option_label')};" - f"永续需≈{cap.get('perp_need_usdt')}U/有{cap.get('perp_have_usdt')}U," - f"期权需≈{cap.get('option_need_usdc')}U/有{cap.get('option_have_usdc')}U" - ) - raise HTTPException( - status_code=409, - detail=f"资金不足,暂不可开新仓:{detail}", - ) - except HTTPException: - raise - except Exception: - pass + if not prep.ok: + raise HTTPException(status_code=409, detail=prep.detail) count = len( db.fetchall("SELECT group_id FROM groups WHERE group_id LIKE ?", (f"G-{wkey}-%",)) diff --git a/backend/app/backup.py b/backend/app/backup.py index d61ee1c..7d6863f 100644 --- a/backend/app/backup.py +++ b/backend/app/backup.py @@ -127,6 +127,7 @@ def create_backup( *, db: Database | None = None, reason: str = "manual", + include_env: bool | None = None, ) -> dict[str, Any]: db = db or get_db() backup_dir = resolve_backup_dir() @@ -134,24 +135,28 @@ def create_backup( out = backup_dir / f"{BACKUP_PREFIX}{ts}.zip" env_path = resolve_env_path() db_path = Path(db.path) + # 机内备份默认可含 .env;BACKUP_INCLUDE_ENV=0 可关。HTTP 下载另做剥离。 + if include_env is None: + include_env = (os.environ.get("BACKUP_INCLUDE_ENV") or "1").strip() != "0" with tempfile.TemporaryDirectory(prefix="eth_hedge_bak_") as td: tmp_db = Path(td) / "hedge.db" _sqlite_snapshot(db, tmp_db) + has_env = bool(include_env and env_path and env_path.is_file()) manifest = { "product": "比特骆驼自动化对冲系统", "version": 1, "created_at_ms": int(time.time() * 1000), "reason": reason, "db_source": str(db_path), - "env_source": str(env_path) if env_path else None, - "has_env": bool(env_path and env_path.is_file()), + "env_source": str(env_path) if has_env else None, + "has_env": has_env, "mode": get_settings().mode, "env_name": get_settings().env_name, } with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf: zf.write(tmp_db, DB_ARCNAME) - if env_path and env_path.is_file(): + if has_env and env_path is not None: zf.write(env_path, ENV_ARCNAME) zf.writestr( MANIFEST_NAME, @@ -161,7 +166,7 @@ def create_backup( db.set_setting("backup_last_at_ms", str(int(time.time() * 1000))) prune_backups() st = out.stat() - logger.info("backup created path=%s reason=%s size=%s", out, reason, st.st_size) + logger.info("backup created path=%s reason=%s size=%s has_env=%s", out, reason, st.st_size, has_env) return { "ok": True, "name": out.name, @@ -173,6 +178,22 @@ def create_backup( } +def materialize_download_zip(src: Path) -> Path: + """HTTP 下载用:去掉包内 .env,避免令牌失窃带走交易所密钥。""" + fd, name = tempfile.mkstemp(prefix="eth_hedge_dl_", suffix=".zip") + os.close(fd) + dest = Path(name) + with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile( + dest, "w", compression=zipfile.ZIP_DEFLATED + ) as zout: + for info in zin.infolist(): + base = Path(info.filename).name + if base == ENV_ARCNAME or base.endswith(".env"): + continue + zout.writestr(info, zin.read(info.filename)) + return dest + + def read_backup_file(name: str) -> Path: safe = Path(name).name if not safe.startswith(BACKUP_PREFIX) or not safe.endswith(".zip"): diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 1f8be67..2ffbb11 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -164,6 +164,11 @@ class Matcher: ) -> OpenResult: s = get_settings() pos = self.current_position() + st = str(pos.get("status") or "flat") + if st in BLOCKING_STATUSES and ( + st == "opening" or bool(pos.get("group_id") or pos.get("option_inst_id")) + ): + return OpenResult(ok=False, detail=f"已有持仓状态({st}),请先平仓") if pos.get("status") == "open" and pos.get("group_id"): return OpenResult(ok=False, detail="已有持仓组,请先平仓") diff --git a/backend/app/strategy/auto_usdc.py b/backend/app/strategy/auto_usdc.py index 2c18140..0ccf7f0 100644 --- a/backend/app/strategy/auto_usdc.py +++ b/backend/app/strategy/auto_usdc.py @@ -1,7 +1,7 @@ -"""OKX 开仓前:交易账户 USDC 不足时市价 USDT→USDC(目标=期权所需×2)。 +"""OKX:交易账户 USDC 不足时市价 USDT→USDC(目标=期权所需×2)。 仅 OKX SIM/LIVE。资金账户不参与;期权已可开则跳过。 -空仓等待开仓时也会按盘口刷新以损定仓名义后再检测,不依赖已选中合格期权。 +等待选约阶段只用预览名义检测(不落库);落库定仓在 open_pipeline.size_and_gate。 """ from __future__ import annotations @@ -17,11 +17,8 @@ from .open_capacity import assess_open_capacity, invalidate_live_balance_cache logger = logging.getLogger(__name__) -# 目标持仓 = 期权开仓所需 USDC × 倍数 _TARGET_MULTIPLE = 2.0 -# 过小不兑(避免粉尘单) _MIN_CONVERT_USDT = 1.0 -# 不足时重试间隔,避免每秒砸单 _RETRY_COOLDOWN_SEC = 45.0 _last_attempt_ts: float = 0.0 @@ -45,42 +42,40 @@ def _round_down(n: float, nd: int = 2) -> float: return math.floor(n * f + 1e-12) / f -def refresh_risk_sizing_from_market(db: Database | None = None) -> dict[str, Any]: - """空仓时用当前指数/卖一刷新以损定仓名义,便于资金门与自动兑对齐展示。""" +def preview_capacity_for_convert(db: Database | None = None) -> dict[str, Any]: + """ + 用盘口预览以损定仓名义评估资金门(不写 settings)。 + 手动仓位则直接 assess 当前账本名义。 + """ database = db or get_db() - out: dict[str, Any] = {"ok": True, "detail": "skip"} try: - from ..sim.ledger import Ledger - from .open_capacity import _index_and_option_ask - from .risk_sizing import apply_risk_sizing_to_ledger, is_risk_based + from .risk_sizing import preview_risk_sizing - if not is_risk_based(Ledger(database)): - out["detail"] = "manual_sizing" - return out - idx, ask = _index_and_option_ask() - if idx is None or ask is None or float(idx) <= 0 or float(ask) <= 0: - out["ok"] = False - out["detail"] = "暂无指数或期权卖一" - return out - r = apply_risk_sizing_to_ledger( - index_px=float(idx), option_ask=float(ask), db=database - ) - out["ok"] = bool(r.ok) - out["detail"] = r.detail - out["k"] = r.k - out["option_qty_eth"] = r.option_qty_eth - return out - except Exception as e: - logger.exception("refresh risk sizing for auto_usdc failed") - return {"ok": False, "detail": str(e)} + prev = preview_risk_sizing(database) + if prev.get("risk_based") and prev.get("ok"): + return assess_open_capacity( + database, + option_ask=float(prev["option_ask"]) + if prev.get("option_ask") is not None + else None, + option_qty_eth=float(prev["option_qty_eth"]) + if prev.get("option_qty_eth") is not None + else None, + perp_qty_eth=float(prev["perp_qty_eth"]) + if prev.get("perp_qty_eth") is not None + else None, + ) + except Exception: + logger.exception("preview capacity for convert failed") + return assess_open_capacity(database) def prepare_okx_trading_usdc(db: Database | None = None) -> dict[str, Any]: - """选约前也可调用:先刷新名义,再按资金门自动兑 USDC。""" + """等待阶段:预览名义评估 + 兑换,不落库。""" database = db or get_db() - sized = refresh_risk_sizing_from_market(database) - top = ensure_okx_trading_usdc(database) - return {"sizing": sized, "convert": top} + cap = preview_capacity_for_convert(database) + conv = ensure_okx_trading_usdc(database, cap=cap, force=False) + return {"capacity": cap, "convert": conv} def ensure_okx_trading_usdc( @@ -90,12 +85,13 @@ def ensure_okx_trading_usdc( force: bool = False, ) -> dict[str, Any]: """ - 开仓资金门前调用: - 非 OKX → 跳过 - 期权可开(USDC≥需)→ 跳过 - - 否则在**交易账户**市价 USDT→USDC,尽量补到 需×2(并预留永续保证金) + - 否则交易账户市价 USDT→USDC,尽量补到 需×2(预留永续保证金) + - force 不再绕过冷却(防砸单);保留参数仅为兼容调用方 """ global _last_attempt_ts + _ = force # 明确忽略:冷却始终生效 db = db or get_db() out: dict[str, Any] = { "ok": True, @@ -121,7 +117,6 @@ def ensure_okx_trading_usdc( out["detail"] = "期权所需为 0,跳过" return out - # 可开仓:不兑换(即使低于 2 倍目标) if have_f + 1e-9 >= need_f: out["detail"] = ( f"交易账户 USDC 已够开仓(有 {have_f:.2f} ≥ 需 {need_f:.2f}),不兑换" @@ -130,11 +125,7 @@ def ensure_okx_trading_usdc( return out now = time.time() - if ( - not force - and _last_attempt_ts > 0 - and now - _last_attempt_ts < _RETRY_COOLDOWN_SEC - ): + if _last_attempt_ts > 0 and now - _last_attempt_ts < _RETRY_COOLDOWN_SEC: left = _RETRY_COOLDOWN_SEC - (now - _last_attempt_ts) out["detail"] = f"USDC 不足,自动兑换冷却中({left:.0f}s)" out["capacity"] = cap @@ -153,12 +144,10 @@ def ensure_okx_trading_usdc( rate = float(usdc_usdt_mid_rate() or 1.0) if rate <= 0: rate = 1.0 - # usdt_to_usdc:amount = 花费的 USDT(与 OkxFundsClient / SIM 一致) want_usdt = gap_usdc * rate perp_need = float(cap.get("perp_need_usdt") or 0) trading_usdt = float(cap.get("perp_have_usdt") or 0) - # 预留永续保证金,避免兑光导致永续不可开 spendable = max(0.0, trading_usdt - max(0.0, perp_need)) spend_usdt = _round_down(min(want_usdt, spendable), 2) diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 4afced8..e711fc9 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -807,33 +807,28 @@ class StrategyEngine: self._set_state(phase="open", last_error="有未平仓,禁止开下一组") return - # OKX:先按盘口刷新名义并检测 USDC;不够则交易账户市价兑换(不依赖已选中合格期权) + # OKX:等待选约时仅预览名义+兑 USDC(不落库改 qty/exit) try: - from .auto_usdc import prepare_okx_trading_usdc + from .open_pipeline import prepare_usdc_while_waiting - prep = prepare_okx_trading_usdc(self.db) + prep = prepare_usdc_while_waiting(self.db) conv = prep.get("convert") or {} if conv.get("acted"): - self._set_state( - last_error=None, - phase="wait_signal", - ) - logger.info("auto_usdc prepared: %s", conv.get("detail")) - elif conv.get("ok") is False and "不足" in str(conv.get("detail") or ""): - # 保留资金提示,但仍继续尝试选约(可能只是冷却/短暂失败) - logger.warning("auto_usdc: %s", conv.get("detail")) + logger.info("auto_usdc while waiting: %s", conv.get("detail")) + elif conv.get("ok") is False: + logger.warning("auto_usdc while waiting: %s", conv.get("detail")) except Exception: - logger.exception("prepare OKX USDC failed") + logger.exception("prepare USDC while waiting failed") self._set_state(phase="wait_signal") pick = await get_session().pick_for_open_async() if pick is None: - # 若期权仍不可开且刚才未兑成功,把资金状态写进错误,便于排查「为何没自动兑」 try: - from .open_capacity import assess_open_capacity + from .open_capacity import assess_open_capacity, funds_gate_blocks cap = assess_open_capacity(self.db) - if cap.get("option_can_open") is False: + blocked, why = funds_gate_blocks(cap) + if blocked and cap.get("option_can_open") is False: self._set_state( phase="wait_funds", last_error=( @@ -843,6 +838,12 @@ class StrategyEngine: ), ) return + if blocked and cap.get("option_can_open") is None: + self._set_state( + phase="wait_funds", + last_error=why, + ) + return except Exception: pass self._set_state( @@ -850,56 +851,33 @@ class StrategyEngine: ) return - # 以损定仓:每笔开仓前按指数/卖一重算 k,再写名义与出场(须在资金门前) - try: - from .risk_sizing import apply_risk_sizing_to_ledger + # 选约后:定仓落库 → 兑 USDC → 资金门 fail-closed(与手动开仓同一管道) + from .open_pipeline import size_and_gate - rs = apply_risk_sizing_to_ledger( - index_px=float(pick.underlying_px), - option_ask=float(pick.option_ask), - db=self.db, - ) - if not rs.ok: - self._set_state(phase="idle", last_error=rs.detail) + prep = size_and_gate( + index_px=float(pick.underlying_px), + option_ask=float(pick.option_ask), + db=self.db, + ) + if not prep.ok: + phase = "wait_funds" if prep.capacity is not None else "idle" + self._set_state(phase=phase, last_error=prep.detail) + if prep.capacity is not None: + maybe_notify_funds_short(prep.capacity) + if "以损定仓" in (prep.detail or ""): try: from ..notify import wecom wecom.notify_fault( title="以损定仓失败", - detail=rs.detail, - dedupe_key=f"risk_sizing:{rs.detail[:80]}", + detail=prep.detail, + dedupe_key=f"risk_sizing:{prep.detail[:80]}", ) except Exception: pass - return - except Exception: - logger.exception("risk sizing failed") - self._set_state(phase="idle", last_error="以损定仓计算异常,暂不开仓") return - - # 选约后名义可能变化,再检一次 USDC(force 跳过冷却,避免刚选完仍差一截) - try: - from .auto_usdc import ensure_okx_trading_usdc - - ensure_okx_trading_usdc(self.db, force=True) - except Exception: - logger.exception("auto USDC top-up failed") - - try: - cap = assess_open_capacity(self.db) - if cap.get("perp_can_open") is False or cap.get("option_can_open") is False: - detail = ( - f"{cap.get('perp_label')} · {cap.get('option_label')};" - f"永续需≈{cap.get('perp_need_usdt')}U/有{cap.get('perp_have_usdt')}U," - f"期权需≈{cap.get('option_need_usdc')}U/有{cap.get('option_have_usdc')}U" - ) - self._set_state(phase="wait_funds", last_error=f"资金不足,暂不可开新仓:{detail}") - maybe_notify_funds_short(cap) - return - if st["phase"] == "wait_funds": - self._set_state(phase="idle", last_error=None) - except Exception: - logger.exception("open capacity gate failed") + if st["phase"] == "wait_funds": + self._set_state(phase="idle", last_error=None) self._set_state(phase="opening", last_error=None) wkey = window_key() diff --git a/backend/app/strategy/open_capacity.py b/backend/app/strategy/open_capacity.py index dc15fdb..b4e7958 100644 --- a/backend/app/strategy/open_capacity.py +++ b/backend/app/strategy/open_capacity.py @@ -98,11 +98,18 @@ def _sim_balances(db: Database) -> dict[str, float]: } -def assess_open_capacity(db: Database | None = None) -> dict[str, Any]: +def assess_open_capacity( + db: Database | None = None, + *, + option_ask: float | None = None, + option_qty_eth: float | None = None, + perp_qty_eth: float | None = None, +) -> dict[str, Any]: """ 返回永续/期权是否有足够交易账户资金开新仓。 - 永续:交易账户 USDT >= 名义/杠杆 - 期权:交易账户 USDC >= 卖一×名义×(1+费率) + 可选覆盖 ask/名义(选约后应用选中腿卖一,避免与 max(call,put) 打架)。 """ global _notified_while_short db = db or get_db() @@ -111,11 +118,20 @@ def assess_open_capacity(db: Database | None = None) -> dict[str, Any]: lev = float(ledger.get_setting_float("leverage", s.leverage) or 3) if lev <= 0: lev = 3.0 - perp_qty = float(ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth) or 1) - opt_qty = float(ledger.get_setting_float("option_qty_eth", s.option_qty_eth) or 2) + perp_qty = float( + perp_qty_eth + if perp_qty_eth is not None + else (ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth) or 1) + ) + opt_qty = float( + option_qty_eth + if option_qty_eth is not None + else (ledger.get_setting_float("option_qty_eth", s.option_qty_eth) or 2) + ) fee_rate = float(ledger.get_setting_float("fee_rate", s.fee_rate) or 0.0005) - idx, ask = _index_and_option_ask() + idx, ask_book = _index_and_option_ask() + ask = float(option_ask) if option_ask is not None and float(option_ask) > 0 else ask_book margin_need = (float(idx) * perp_qty / lev) if idx and idx > 0 else None premium_need = ( float(ask) * opt_qty * (1.0 + fee_rate) if ask is not None and ask > 0 else None @@ -183,6 +199,25 @@ def assess_open_capacity(db: Database | None = None) -> dict[str, Any]: } +def funds_gate_blocks(cap: dict[str, Any] | None) -> tuple[bool, str]: + """ + Fail-closed:仅当永续与期权均为 True 才放行。 + None(未知,如币安未接余额)或 False → 拦截。 + """ + if not cap: + return True, "资金可开判定结果为空,拒绝开仓" + if cap.get("perp_can_open") is not True or cap.get("option_can_open") is not True: + detail = ( + f"{cap.get('perp_label')} · {cap.get('option_label')};" + f"永续需≈{cap.get('perp_need_usdt')}U/有{cap.get('perp_have_usdt')}U," + f"期权需≈{cap.get('option_need_usdc')}U/有{cap.get('option_have_usdc')}U" + ) + if cap.get("perp_can_open") is None or cap.get("option_can_open") is None: + detail += "(余额/盘口未知,fail-closed 拒绝开仓)" + return True, f"资金不足或状态未知,暂不可开新仓:{detail}" + return False, "" + + def maybe_notify_funds_short(cap: dict[str, Any] | None = None) -> None: """仅在「不能开」时推送一次;能开绝不通知。""" global _notified_while_short diff --git a/backend/app/strategy/open_pipeline.py b/backend/app/strategy/open_pipeline.py new file mode 100644 index 0000000..6e53382 --- /dev/null +++ b/backend/app/strategy/open_pipeline.py @@ -0,0 +1,102 @@ +"""开仓统一管道:定仓 → 兑 USDC → 资金门(fail-closed)。 + +策略自动开仓与手动开一组共用,避免双路径打架。 +基线假设:运行中不在交易所人工开平仓、也不人工手动平仓。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +from ..models.db import Database, get_db +from .open_capacity import assess_open_capacity, funds_gate_blocks +from .risk_sizing import apply_risk_sizing_to_ledger + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class OpenPrepResult: + ok: bool + detail: str = "" + capacity: dict[str, Any] | None = None + sizing_detail: str = "" + convert_detail: str = "" + + +def prepare_usdc_while_waiting(db: Database | None = None) -> dict[str, Any]: + """空仓等待选约:预览名义检测 USDC 并兑换,不落库改写 qty/exit。""" + from .auto_usdc import prepare_okx_trading_usdc + + return prepare_okx_trading_usdc(db) + + +def size_and_gate( + *, + index_px: float, + option_ask: float, + db: Database | None = None, +) -> OpenPrepResult: + """ + 选约成功后:写入以损定仓 → 交易账户兑 USDC → 资金门。 + 资金门 fail-closed:异常 / can_open 非 True 一律拦截。 + """ + database = db or get_db() + try: + rs = apply_risk_sizing_to_ledger( + index_px=float(index_px), + option_ask=float(option_ask), + db=database, + ) + if not rs.ok: + return OpenPrepResult(ok=False, detail=rs.detail, sizing_detail=rs.detail) + except Exception as e: + logger.exception("risk sizing failed in open pipeline") + return OpenPrepResult(ok=False, detail=f"以损定仓计算异常:{e}") + + convert_detail = "" + try: + from .auto_usdc import ensure_okx_trading_usdc + + # 选约后名义已更新;仍受冷却约束,禁止 force 砸单 + conv = ensure_okx_trading_usdc( + database, + cap=assess_open_capacity( + database, + option_ask=float(option_ask), + ), + force=False, + ) + convert_detail = str(conv.get("detail") or "") + if conv.get("acted"): + logger.info("open_pipeline auto_usdc: %s", convert_detail) + except Exception: + logger.exception("auto USDC in open pipeline failed") + convert_detail = "自动兑 USDC 异常(已记日志)" + + try: + cap = assess_open_capacity(database, option_ask=float(option_ask)) + except Exception as e: + logger.exception("open capacity assess failed") + return OpenPrepResult( + ok=False, + detail=f"资金可开判定失败,拒绝开仓:{e}", + convert_detail=convert_detail, + ) + + blocked, why = funds_gate_blocks(cap) + if blocked: + return OpenPrepResult( + ok=False, + detail=why or "资金不足或状态未知,拒绝开仓", + capacity=cap, + convert_detail=convert_detail, + ) + return OpenPrepResult( + ok=True, + detail="ready", + capacity=cap, + convert_detail=convert_detail, + ) diff --git a/backend/tests/test_auto_usdc.py b/backend/tests/test_auto_usdc.py index d7db836..f7f9d01 100644 --- a/backend/tests/test_auto_usdc.py +++ b/backend/tests/test_auto_usdc.py @@ -103,3 +103,24 @@ def test_reserve_perp_margin() -> None: assert r["acted"] is True assert wallets.convert.call_args.kwargs["amount"] == 50.0 + + +def test_force_does_not_bypass_cooldown() -> None: + import app.strategy.auto_usdc as m + import time + + m._last_attempt_ts = time.time() + cap = { + "option_need_usdc": 100.0, + "option_have_usdc": 0.0, + "perp_need_usdt": 10.0, + "perp_have_usdt": 500.0, + "option_can_open": False, + } + with ( + patch("app.strategy.auto_usdc._is_okx", return_value=True), + patch("app.strategy.auto_usdc.assess_open_capacity", return_value=cap), + ): + r = ensure_okx_trading_usdc(db=MagicMock(), cap=cap, force=True) + assert r["acted"] is False + assert "冷却" in r["detail"] diff --git a/backend/tests/test_funds_gate.py b/backend/tests/test_funds_gate.py new file mode 100644 index 0000000..3d4af69 --- /dev/null +++ b/backend/tests/test_funds_gate.py @@ -0,0 +1,47 @@ +"""资金门 fail-closed 测试。""" + +from __future__ import annotations + +from app.strategy.open_capacity import funds_gate_blocks + + +def test_funds_gate_blocks_none() -> None: + blocked, msg = funds_gate_blocks( + { + "perp_can_open": True, + "option_can_open": None, + "perp_label": "a", + "option_label": "b", + } + ) + assert blocked is True + assert "未知" in msg or "拒绝" in msg + + +def test_funds_gate_blocks_false() -> None: + blocked, msg = funds_gate_blocks( + { + "perp_can_open": True, + "option_can_open": False, + "perp_label": "永续可开", + "option_label": "期权不可开", + "perp_need_usdt": 1, + "perp_have_usdt": 10, + "option_need_usdc": 100, + "option_have_usdc": 1, + } + ) + assert blocked is True + assert "不可开" in msg or "不足" in msg + + +def test_funds_gate_ok_only_when_both_true() -> None: + blocked, _ = funds_gate_blocks( + { + "perp_can_open": True, + "option_can_open": True, + "perp_label": "永续可开", + "option_label": "期权可开", + } + ) + assert blocked is False diff --git a/docs/审计说明-2026-07-30-策略SoT与资金门.md b/docs/审计说明-2026-07-30-策略SoT与资金门.md new file mode 100644 index 0000000..3627e26 --- /dev/null +++ b/docs/审计说明-2026-07-30-策略SoT与资金门.md @@ -0,0 +1,80 @@ +# 审计说明 — 策略 SoT / 资金门 / 自动兑 USDC(2026-07-30) + +## 基线假设 + +1. **不会**在交易所人工开仓/平仓。 +2. **运行中不会**使用本系统「手动全平 / 手动开一组」(策略跑着时手动开仓本就 409)。 +3. 以策略主线为唯一真相源:盯仓 →(等待预览兑 USDC)→ 选约 → 定仓落库 → 兑 USDC → 资金门 fail-closed → 开仓 → 锁定出场。 + +## 结论(三轮严格审计后) + +本轮按审计清单落地后:**P0 资金门 fail-open、热切 LIVE 护栏、等待写定仓打架、force 砸单、手动/自动双管道、持仓改参、下载备份带密钥** 已关闭或显著收敛。 +残留:币安 LIVE 余额仍未接入(现 fail-closed 拒绝开仓,需接余额或改手动本金);SIM 资金重置仍默认进资金账户(需划转至交易账户才能开);机内 zip 仍可能含 `.env`(仅 HTTP 下载已剥离)。 + +--- + +## 第一轮审计(改完即审 · 对照清单) + +| 原发现 | 处置 | 证据 | +|--------|------|------| +| P0 资金门 fail-open | **已修** | `funds_gate_blocks`:仅双方 `is True` 放行;`None`/异常拦截。`open_pipeline.size_and_gate` + `engine`/`sim` 共用 | +| P0 热切 LIVE | **已修** | `put_runtime`:非默认 `AUTH_SECRET`;切 LIVE 强制 `running=0`/`paused` | +| P1 等待每 tick 写定仓 | **已修** | `prepare_okx_trading_usdc` / `preview_capacity_for_convert` 只预览;落库仅在 `size_and_gate` | +| P1 force 砸单 | **已修** | `force` 参数忽略,45s 冷却始终生效 | +| P1 容量 ask ≠ 选约腿 | **已修** | `assess_open_capacity(option_ask=…)`;`size_and_gate` 传入 `pick` 卖一 | +| P1 手动≠自动 | **已修** | `api/sim.py` 与引擎共用 `size_and_gate` | +| P1 持仓改出场/名义 | **已修** | `put_strategy_settings` 持仓禁止改 exit/qty/leverage/risk_* | +| P1 备份下载含 .env | **已修** | `materialize_download_zip` 剥离后下载 | +| P2 划转不清缓存 | **已修** | `funds.transfer` LIVE 后 `invalidate_live_balance_cache` | +| P2 SIM 弱占坑 | **已修** | `matcher.open_group` 拒绝全部 `BLOCKING_STATUSES` | + +**第一轮残留:** 币安余额未接 → LIVE BN 将长期 `wait_funds`(刻意 fail-closed,非放行)。 + +--- + +## 第二轮审计(干扰/打架复查) + +路径重读:`engine._tick_async` → `prepare_usdc_while_waiting` → pick → `size_and_gate` → LIVE `assert_safe` → `open_group`。 + +| 检查点 | 结果 | +|--------|------| +| 等待阶段是否写 `perp_qty_eth`/`net_profit_target` | 否(仅 preview assess) | +| 选约失败是否仍兑 USDC | 是(预览名义),不挡「无合格期权」文案分支 | +| 选约后资金门异常是否放行 | 否(`size_and_gate` 返回 ok=False) | +| 手动开仓是否仍 `except: pass` | 否(409 + detail) | +| 持仓 `exit_target` 与设置 | 设置被锁;盯盘用锁定目标(既有逻辑保留) | +| 自动兑与定仓顺序 | 定仓落库 → 兑 → 门;等待只有兑不落库 | + +**第二轮新发现(已记残留,未扩 scope):** + +- SIM:`reset_from_equity` 仍进资金账户 → 交易账户空时正确 fail-closed,需手动划转(符合「只认交易账户」)。 +- `state()` 空仓仍 preview 覆盖展示字段名(展示层,不写库,不改出场执行)。 + +--- + +## 第三轮审计(实盘安全 + 回归) + +| 检查 | 结果 | +|------|------| +| LIVE 开仓 claim/assert | 未改坏;仍在 `size_and_gate` 之后 | +| 冷启动 AUTH_SECRET | 保留;热切现对齐 pause + secret | +| 兑汇账户 | 交易账户;与手动 `/convert` 一致 | +| 单测 | `test_funds_gate` / `test_auto_usdc` / `test_exit_lock` / `test_risk_sizing` 通过 | +| 基线「无交易所人工开平」 | 文档化;代码不依赖该行为做对账放弃 | + +**第三轮残留(接受或后续):** + +1. 币安 LIVE 余额未接线 → 开仓被拒直至接入或强制手动本金策略。 +2. 机内自动备份 zip 默认可含 `.env`(磁盘权限模型);HTTP 下载已无密钥。 +3. LIVE 兑换/划转仍无二次口令(仅登录态)——运行中无人工操作为基线时风险降低。 +4. `localStorage` 长 token / XFF 登录限流等通用 Web 项未本轮改。 + +--- + +## 涉及文件 + +`strategy/open_pipeline.py`(新)· `open_capacity.py` · `auto_usdc.py` · `engine.py` · `api/sim.py` · `api/settings.py` · `api/funds.py` · `api/backup_routes.py` · `backup.py` · `sim/matcher.py` · `tests/test_funds_gate.py` · `tests/test_auto_usdc.py` + +## 与上一份审计关系 + +承接 `审计说明-2026-07-29-开平仓与实盘安全.md`(开平仓 claim/recover)。本份专注 **策略 SoT 不打架 + 资金门/兑汇/热切 LIVE**。 diff --git a/docs/更新说明.md b/docs/更新说明.md index 39d538c..9f30e3e 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,23 @@ --- +## 2026-07-30 — 策略 SoT 加固:资金门 fail-closed / 统一开仓管道 + +### 变更 + +1. 资金门 fail-closed(`None`/异常拒绝开仓);选约后用选中腿 ask。 +2. 等待选约只预览兑 USDC、不落库定仓;落库仅在选约成功后。 +3. 自动/手动开仓共用 `open_pipeline.size_and_gate`;兑汇冷却不可 force 绕过。 +4. 持仓禁止改出场/名义/以损参数;热切 LIVE 强制 pause + 非默认 AUTH_SECRET。 +5. 划转刷新余额缓存;HTTP 备份下载剥离 `.env`。 +6. 基线:无交易所人工开平、运行中无人工手动平仓。 + +### 审计 + +见 `docs/审计说明-2026-07-30-策略SoT与资金门.md`(三轮)。 + +--- + ## 2026-07-30 — 自动兑 USDC:不等待选约,USDC 不够即兑 ### 变更