Go-live gate: refuse unsafe live trading
This commit is contained in:
commit
ef3e6f346c
10 files changed
+241
-1
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user