Compare commits
2
Commits
b60cddf13d
...
ef3e6f346c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef3e6f346c | ||
|
|
0173e46cfb |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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).
|
||||
</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">
|
||||
{{ status.error }}
|
||||
</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 getStatus = () => http.get('/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 getStrategies = () => http.get('/strategies').then((r) => r.data)
|
||||
export const getPositions = () => http.get('/positions').then((r) => r.data)
|
||||
|
||||
Reference in new issue
Block a user