Harden strategy SoT: fail-closed funds gate, shared open pipeline, LIVE switch guards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-30 01:06:32 +08:00
parent 4c537e7520
commit 200702d066
14 changed files with 471 additions and 139 deletions
+9 -2
View File
@@ -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)),
)
+6
View File
@@ -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
+50
View File
@@ -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
+4 -30
View File
@@ -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}-%",))
+25 -4
View File
@@ -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)
# 机内备份默认可含 .envBACKUP_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"):
+5
View File
@@ -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="已有持仓组,请先平仓")
+33 -44
View File
@@ -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 跳过
- 否则**交易账户**市价 USDTUSDC尽量补到 ×2预留永续保证金
- 否则交易账户市价 USDTUSDC尽量补到 ×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_usdcamount = 花费的 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)
+33 -55
View File
@@ -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()
+39 -4
View File
@@ -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
+102
View File
@@ -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,
)
+21
View File
@@ -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"]
+47
View File
@@ -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
@@ -0,0 +1,80 @@
# 审计说明 — 策略 SoT / 资金门 / 自动兑 USDC2026-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**
+17
View File
@@ -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 不够即兑
### 变更