Go-live gate: refuse unsafe live trading
Setting MODE=live alone no longer starts trading. check_live_ready requires an active risk limit, MAX_POSITION_PCT < 1.0, LIVE_CONFIRMATION exactly "I UNDERSTAND THE RISK", a minimum balance, and exchange credentials. Enforced in Runtime.start_many (both start paths) and surfaced at GET /api/live-check; the dashboard shows a banner and blocks the start buttons. Adds tests.
This commit is contained in:
1 parent
b60cddf13d
commit
0173e46cfb
10 files changed
+241
-1
No files matched your search
@@ -103,6 +103,12 @@ def multi_status(runtime: GuardedRuntimeDep) -> MultiStatusOut:
|
||||
return runtime.multi_status()
|
||||
|
||||
|
||||
@router.get("/live-check")
|
||||
def live_check(runtime: GuardedRuntimeDep) -> dict:
|
||||
"""Preflight for live trading: whether the config is safe enough."""
|
||||
return runtime.live_check()
|
||||
|
||||
|
||||
@router.post("/bot/start-many", response_model=MultiStatusOut)
|
||||
async def start_many(payload: StartManyRequest, runtime: GuardedRuntimeDep) -> MultiStatusOut:
|
||||
try:
|
||||
|
||||
@@ -39,6 +39,13 @@ class Settings(BaseSettings):
|
||||
exchange_api_secret: str = ""
|
||||
exchange_sandbox: bool = False
|
||||
|
||||
# --- Go-live gate ---
|
||||
# Live trading only starts when this is exactly "I UNDERSTAND THE RISK".
|
||||
# It is deliberately not a boolean: setting MODE=live alone is not enough.
|
||||
live_confirmation: str = ""
|
||||
# Refuse to start live below this account size (quote currency).
|
||||
min_live_balance: float = 100.0
|
||||
|
||||
# --- Risk controls (0 disables a limit) ---
|
||||
# Absolute loss in quote currency since the start of the UTC day.
|
||||
max_daily_loss: float = 0.0
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
# The exact phrase required in LIVE_CONFIRMATION before live trading starts.
|
||||
LIVE_CONFIRMATION_PHRASE = "I UNDERSTAND THE RISK"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LiveCheck:
|
||||
"""Result of the go-live preflight."""
|
||||
|
||||
ok: bool
|
||||
mode: str
|
||||
# Each failure is a human-readable reason live trading is blocked.
|
||||
blockers: list[str] = field(default_factory=list)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {"ok": self.ok, "mode": self.mode, "blockers": self.blockers}
|
||||
|
||||
|
||||
def check_live_ready(settings: Settings, starting_balance: float | None = None) -> LiveCheck:
|
||||
"""Validate that the configuration is safe enough to trade real money.
|
||||
|
||||
Paper mode always passes. Live mode must satisfy every guard; the point is
|
||||
that ``MODE=live`` alone is not sufficient to start live trading.
|
||||
"""
|
||||
if settings.mode != "live":
|
||||
return LiveCheck(ok=True, mode=settings.mode)
|
||||
|
||||
blockers: list[str] = []
|
||||
|
||||
has_limit = (
|
||||
settings.max_daily_loss > 0
|
||||
or settings.max_drawdown_pct > 0
|
||||
or settings.max_trades_per_day > 0
|
||||
)
|
||||
if not has_limit:
|
||||
blockers.append(
|
||||
"no active risk limit: set MAX_DAILY_LOSS, MAX_DRAWDOWN_PCT or MAX_TRADES_PER_DAY"
|
||||
)
|
||||
|
||||
if settings.max_position_pct >= 1.0:
|
||||
blockers.append(
|
||||
"MAX_POSITION_PCT must be below 1.0 so a single position cannot be the whole account"
|
||||
)
|
||||
|
||||
if settings.live_confirmation != LIVE_CONFIRMATION_PHRASE:
|
||||
blockers.append(f'LIVE_CONFIRMATION must be exactly "{LIVE_CONFIRMATION_PHRASE}"')
|
||||
|
||||
balance = starting_balance if starting_balance is not None else settings.starting_balance
|
||||
if balance < settings.min_live_balance:
|
||||
blockers.append(
|
||||
f"starting balance {balance:g} is below MIN_LIVE_BALANCE {settings.min_live_balance:g}"
|
||||
)
|
||||
|
||||
if not settings.exchange_api_key or not settings.exchange_api_secret:
|
||||
blockers.append("EXCHANGE_API_KEY and EXCHANGE_API_SECRET are required for live")
|
||||
|
||||
return LiveCheck(ok=not blockers, mode=settings.mode, blockers=blockers)
|
||||
@@ -11,6 +11,7 @@ from app.broker import build_broker
|
||||
from app.broker.base import Broker
|
||||
from app.config import Settings
|
||||
from app.engine.engine import TradingEngine
|
||||
from app.engine.live_guard import check_live_ready
|
||||
from app.engine.manager import MultiStrategyManager
|
||||
from app.engine.strategies import get_strategy
|
||||
from app.market import build_market_data
|
||||
@@ -129,8 +130,17 @@ class Runtime:
|
||||
return engine.status() if engine is not None else self._idle_status()
|
||||
|
||||
async def start_many(self, request: StartManyRequest) -> MultiStatusOut:
|
||||
check = check_live_ready(
|
||||
self.settings,
|
||||
request.starting_balance or self.settings.starting_balance,
|
||||
)
|
||||
if not check.ok:
|
||||
raise ValueError("live trading blocked: " + "; ".join(check.blockers))
|
||||
return await self.manager.start(request)
|
||||
|
||||
def live_check(self) -> dict:
|
||||
return check_live_ready(self.settings).as_dict()
|
||||
|
||||
async def stop(self) -> StatusOut:
|
||||
await self.manager.stop()
|
||||
engine = self.engine
|
||||
|
||||
Reference in new issue
Block a user