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:
matitos committed 2026-09-22 12:55:12 +00:00
1 parent b60cddf13d
commit 0173e46cfb
10 files changed
+241 -1

No files matched your search

+7
View File
@@ -33,6 +33,13 @@ EXCHANGE_API_SECRET=
# Set true for testnet/sandbox where supported # Set true for testnet/sandbox where supported
EXCHANGE_SANDBOX=false EXCHANGE_SANDBOX=false
# --- Go-live gate (required for MODE=live) ---
# Live trading only starts when this is exactly "I UNDERSTAND THE RISK".
# Setting MODE=live alone is intentionally not enough.
LIVE_CONFIRMATION=
# Refuse to start live below this account size (quote currency).
MIN_LIVE_BALANCE=100
# --- Risk controls (0 disables a limit) --- # --- Risk controls (0 disables a limit) ---
# Halt + flatten if the day's loss reaches this amount in quote currency # Halt + flatten if the day's loss reaches this amount in quote currency
MAX_DAILY_LOSS=0 MAX_DAILY_LOSS=0
+9
View File
@@ -127,6 +127,15 @@ candles you control and asserts the emitted intents. See
- Portfolio invariant: `equity = cash ± qty * mark`, realized P&L accumulates on - Portfolio invariant: `equity = cash ± qty * mark`, realized P&L accumulates on
close, and every order/equity point/event is persisted. close, and every order/equity point/event is persisted.
## Go-live gate
`app/engine/live_guard.py` (`check_live_ready`) is the single place that decides
whether live trading may start. It is enforced in `Runtime.start_many` (so both
the single and multi start paths are covered) and surfaced via
`GET /api/live-check`. Never bypass it: if you add a new way to start trading,
call `check_live_ready` first. `LIVE_CONFIRMATION` must equal
`"I UNDERSTAND THE RISK"` — keep it a phrase, not a boolean.
## Risk controls & notifications ## Risk controls & notifications
- `app/engine/risk.py` (`RiskManager`) tracks daily loss, drawdown, position cap - `app/engine/risk.py` (`RiskManager`) tracks daily loss, drawdown, position cap
+17
View File
@@ -144,6 +144,22 @@ injects it on the handshake). If `API_TOKEN` is unset, `/api/ws` is as open as
the rest of `/api`, which is fine for a trusted/private deployment but is the the rest of `/api`, which is fine for a trusted/private deployment but is the
reason to set a token if the host is exposed. reason to set a token if the host is exposed.
## Going live
Setting `MODE=live` alone is **not** enough. `GET /api/live-check` (and the
dashboard banner) validate a preflight before any live order can be placed; live
trading is blocked unless *all* of these hold:
- at least one risk limit is active (`MAX_DAILY_LOSS`, `MAX_DRAWDOWN_PCT` or
`MAX_TRADES_PER_DAY`);
- `MAX_POSITION_PCT < 1.0` (a single position can never be the whole account);
- `LIVE_CONFIRMATION` is exactly `I UNDERSTAND THE RISK`;
- the starting balance is at least `MIN_LIVE_BALANCE`;
- exchange API credentials are set.
A blocked live start returns `400` with the reasons, and the dashboard shows a
banner listing them. Practise the kill switch in paper mode first.
## Multi-strategy ## Multi-strategy
The **Strategies (multi)** panel runs several strategies at once, each with its The **Strategies (multi)** panel runs several strategies at once, each with its
@@ -193,6 +209,7 @@ Bundled strategies: `sma_cross`, `ema_cross`, `macd_trend`,
| `POST` | `/api/bot/start` | Start a strategy | | `POST` | `/api/bot/start` | Start a strategy |
| `POST` | `/api/bot/start-many` | Start several strategies with per-strategy allocations | | `POST` | `/api/bot/start-many` | Start several strategies with per-strategy allocations |
| `GET` | `/api/bot/status` | Per-strategy status for a multi-strategy run | | `GET` | `/api/bot/status` | Per-strategy status for a multi-strategy run |
| `GET` | `/api/live-check` | Go-live preflight (blockers for live trading) |
| `POST` | `/api/bot/stop` | Stop the bot | | `POST` | `/api/bot/stop` | Stop the bot |
| `POST` | `/api/bot/kill` | Kill switch: flatten + halt | | `POST` | `/api/bot/kill` | Kill switch: flatten + halt |
| `POST` | `/api/backtest` | Backtest a strategy on historical candles | | `POST` | `/api/backtest` | Backtest a strategy on historical candles |
+6
View File
@@ -103,6 +103,12 @@ def multi_status(runtime: GuardedRuntimeDep) -> MultiStatusOut:
return runtime.multi_status() 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) @router.post("/bot/start-many", response_model=MultiStatusOut)
async def start_many(payload: StartManyRequest, runtime: GuardedRuntimeDep) -> MultiStatusOut: async def start_many(payload: StartManyRequest, runtime: GuardedRuntimeDep) -> MultiStatusOut:
try: try:
+7
View File
@@ -39,6 +39,13 @@ class Settings(BaseSettings):
exchange_api_secret: str = "" exchange_api_secret: str = ""
exchange_sandbox: bool = False 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) --- # --- Risk controls (0 disables a limit) ---
# Absolute loss in quote currency since the start of the UTC day. # Absolute loss in quote currency since the start of the UTC day.
max_daily_loss: float = 0.0 max_daily_loss: float = 0.0
+62
View File
@@ -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)
+10
View File
@@ -11,6 +11,7 @@ from app.broker import build_broker
from app.broker.base import Broker from app.broker.base import Broker
from app.config import Settings from app.config import Settings
from app.engine.engine import TradingEngine from app.engine.engine import TradingEngine
from app.engine.live_guard import check_live_ready
from app.engine.manager import MultiStrategyManager from app.engine.manager import MultiStrategyManager
from app.engine.strategies import get_strategy from app.engine.strategies import get_strategy
from app.market import build_market_data 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() return engine.status() if engine is not None else self._idle_status()
async def start_many(self, request: StartManyRequest) -> MultiStatusOut: 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) return await self.manager.start(request)
def live_check(self) -> dict:
return check_live_ready(self.settings).as_dict()
async def stop(self) -> StatusOut: async def stop(self) -> StatusOut:
await self.manager.stop() await self.manager.stop()
engine = self.engine engine = self.engine
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
import pytest
from app.config import Settings
from app.engine.live_guard import LIVE_CONFIRMATION_PHRASE, check_live_ready
def live_settings(**overrides) -> Settings:
base = {
"mode": "live",
"max_daily_loss": 100.0,
"max_position_pct": 0.5,
"live_confirmation": LIVE_CONFIRMATION_PHRASE,
"exchange_api_key": "k",
"exchange_api_secret": "s",
"starting_balance": 1000.0,
"min_live_balance": 100.0,
}
base.update(overrides)
return Settings(**base)
def test_paper_mode_always_ok() -> None:
check = check_live_ready(Settings(mode="paper", max_position_pct=1.0))
assert check.ok is True
assert check.blockers == []
def test_live_ok_when_fully_configured() -> None:
check = check_live_ready(live_settings())
assert check.ok is True
def test_live_blocked_without_any_risk_limit() -> None:
check = check_live_ready(
live_settings(max_daily_loss=0, max_drawdown_pct=0, max_trades_per_day=0)
)
assert check.ok is False
assert any("risk limit" in b for b in check.blockers)
def test_live_blocked_when_position_cap_is_full_account() -> None:
check = check_live_ready(live_settings(max_position_pct=1.0))
assert check.ok is False
assert any("MAX_POSITION_PCT" in b for b in check.blockers)
def test_live_blocked_without_confirmation_phrase() -> None:
check = check_live_ready(live_settings(live_confirmation=""))
assert check.ok is False
assert any("LIVE_CONFIRMATION" in b for b in check.blockers)
def test_live_blocked_below_min_balance() -> None:
check = check_live_ready(live_settings(starting_balance=50.0))
assert check.ok is False
assert any("MIN_LIVE_BALANCE" in b for b in check.blockers)
def test_live_blocked_without_credentials() -> None:
check = check_live_ready(live_settings(exchange_api_key="", exchange_api_secret=""))
assert check.ok is False
assert any("EXCHANGE_API_KEY" in b for b in check.blockers)
async def test_start_many_refuses_live_without_confirmation(settings) -> None:
from app.runtime import Runtime
from app.schemas import StartManyRequest, StrategySpec
settings.mode = "live"
settings.exchange_api_key = "k"
settings.exchange_api_secret = "s"
settings.max_position_pct = 1.0 # unsafe
runtime = Runtime(settings)
with pytest.raises(ValueError, match="live trading blocked"):
await runtime.start_many(
StartManyRequest(strategies=[StrategySpec(strategy_key="sma_cross")])
)
def test_live_check_endpoint(runtime) -> None:
from fastapi.testclient import TestClient
from app.main import create_app
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
body = client.get("/api/live-check").json()
assert body["ok"] is True # paper mode
assert body["mode"] == "paper"
+31 -1
View File
@@ -33,6 +33,7 @@ const strategies = ref([])
const strategiesLoading = ref(true) const strategiesLoading = ref(true)
const presets = ref([]) const presets = ref([])
const multiStatus = ref({ running: false, strategies: [], total_pnl: 0, equity: 0 }) const multiStatus = ref({ running: false, strategies: [], total_pnl: 0, equity: 0 })
const liveCheck = ref({ ok: true, mode: 'paper', blockers: [] })
const positions = ref([]) const positions = ref([])
const orders = ref([]) const orders = ref([])
const events = ref([]) const events = ref([])
@@ -97,6 +98,13 @@ async function handleDeletePreset(id) {
} }
async function handleStart(payload) { async function handleStart(payload) {
if (liveCheck.value.mode === 'live' && !liveCheck.value.ok) {
status.value = {
...status.value,
error: 'Live trading blocked: ' + liveCheck.value.blockers.join('; '),
}
return
}
busy.value = true busy.value = true
try { try {
status.value = await api.startBot(payload) status.value = await api.startBot(payload)
@@ -112,7 +120,18 @@ async function refreshMulti() {
multiStatus.value = await api.getMultiStatus() multiStatus.value = await api.getMultiStatus()
} }
async function refreshLiveCheck() {
liveCheck.value = await api.getLiveCheck()
}
async function handleStartMany(payload) { async function handleStartMany(payload) {
if (liveCheck.value.mode === 'live' && !liveCheck.value.ok) {
status.value = {
...status.value,
error: 'Live trading blocked: ' + liveCheck.value.blockers.join('; '),
}
return
}
busy.value = true busy.value = true
try { try {
multiStatus.value = await api.startMany(payload) multiStatus.value = await api.startMany(payload)
@@ -249,7 +268,8 @@ onMounted(() => {
statusTimer = setInterval( statusTimer = setInterval(
() => { () => {
api.getStatus().then((s) => (status.value = s)).catch(() => {}) api.getStatus().then((s) => (status.value = s)).catch(() => {})
refreshMulti().catch(() => {}) refreshMulti().catch(() => {})
refreshLiveCheck().catch(() => {})
}, },
3000, 3000,
) )
@@ -307,6 +327,16 @@ onUnmounted(() => {
flattened. Restart the bot to resume (limits reset). flattened. Restart the bot to resume (limits reset).
</div> </div>
<div
v-if="liveCheck.mode === 'live' && !liveCheck.ok"
class="rounded-lg border border-amber-500 bg-amber-500/15 p-3 text-sm text-amber-100"
>
<p class="font-semibold">Live trading is blocked by the go-live checks:</p>
<ul class="mt-1 list-disc pl-5">
<li v-for="(b, i) in liveCheck.blockers" :key="i">{{ b }}</li>
</ul>
</div>
<div v-if="status.error" class="rounded-lg border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200"> <div v-if="status.error" class="rounded-lg border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">
{{ status.error }} {{ status.error }}
</div> </div>
+1
View File
@@ -5,6 +5,7 @@ const http = axios.create({ baseURL: '/api', timeout: 10000 })
export const getVersion = () => http.get('/version').then((r) => r.data) export const getVersion = () => http.get('/version').then((r) => r.data)
export const getStatus = () => http.get('/status').then((r) => r.data) export const getStatus = () => http.get('/status').then((r) => r.data)
export const getMultiStatus = () => http.get('/bot/status').then((r) => r.data) export const getMultiStatus = () => http.get('/bot/status').then((r) => r.data)
export const getLiveCheck = () => http.get('/live-check').then((r) => r.data)
export const startMany = (payload) => http.post('/bot/start-many', payload).then((r) => r.data) export const startMany = (payload) => http.post('/bot/start-many', payload).then((r) => r.data)
export const getStrategies = () => http.get('/strategies').then((r) => r.data) export const getStrategies = () => http.get('/strategies').then((r) => r.data)
export const getPositions = () => http.get('/positions').then((r) => r.data) export const getPositions = () => http.get('/positions').then((r) => r.data)