diff --git a/.env.example b/.env.example index 02d8ad8..b8c9b7e 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,13 @@ EXCHANGE_API_SECRET= # Set true for testnet/sandbox where supported 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) --- # Halt + flatten if the day's loss reaches this amount in quote currency MAX_DAILY_LOSS=0 diff --git a/AGENTS.md b/AGENTS.md index 7f1d9a0..0591946 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,6 +127,15 @@ candles you control and asserts the emitted intents. See - Portfolio invariant: `equity = cash ± qty * mark`, realized P&L accumulates on 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 - `app/engine/risk.py` (`RiskManager`) tracks daily loss, drawdown, position cap diff --git a/README.md b/README.md index c5810df..652afb3 100644 --- a/README.md +++ b/README.md @@ -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 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 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-many` | Start several strategies with per-strategy allocations | | `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/kill` | Kill switch: flatten + halt | | `POST` | `/api/backtest` | Backtest a strategy on historical candles | diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 2a1f936..0a5dcb5 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -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: diff --git a/backend/app/config.py b/backend/app/config.py index 6091870..3fc95f9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 diff --git a/backend/app/engine/live_guard.py b/backend/app/engine/live_guard.py new file mode 100644 index 0000000..c7d64ee --- /dev/null +++ b/backend/app/engine/live_guard.py @@ -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) diff --git a/backend/app/runtime.py b/backend/app/runtime.py index 55a1e08..6b1f039 100644 --- a/backend/app/runtime.py +++ b/backend/app/runtime.py @@ -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 diff --git a/backend/tests/test_live_guard.py b/backend/tests/test_live_guard.py new file mode 100644 index 0000000..c53e010 --- /dev/null +++ b/backend/tests/test_live_guard.py @@ -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" diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 89beb9f..9421c8c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -33,6 +33,7 @@ const strategies = ref([]) const strategiesLoading = ref(true) const presets = ref([]) const multiStatus = ref({ running: false, strategies: [], total_pnl: 0, equity: 0 }) +const liveCheck = ref({ ok: true, mode: 'paper', blockers: [] }) const positions = ref([]) const orders = ref([]) const events = ref([]) @@ -97,6 +98,13 @@ async function handleDeletePreset(id) { } 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 try { status.value = await api.startBot(payload) @@ -112,7 +120,18 @@ async function refreshMulti() { multiStatus.value = await api.getMultiStatus() } +async function refreshLiveCheck() { + liveCheck.value = await api.getLiveCheck() +} + 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 try { multiStatus.value = await api.startMany(payload) @@ -249,7 +268,8 @@ onMounted(() => { statusTimer = setInterval( () => { api.getStatus().then((s) => (status.value = s)).catch(() => {}) - refreshMulti().catch(() => {}) + refreshMulti().catch(() => {}) + refreshLiveCheck().catch(() => {}) }, 3000, ) @@ -307,6 +327,16 @@ onUnmounted(() => { flattened. Restart the bot to resume (limits reset). +
Live trading is blocked by the go-live checks:
+