Risk controls, alerts, API token, more exchanges, presets
- RiskManager halts + flattens on max daily loss / drawdown, caps position size and trades per day; adds a manual kill switch (POST /api/bot/kill). - Telegram and webhook notifications on trades, errors and halts. - Optional API bearer token on /api, injected server-side by the frontend nginx so the browser never sees it. - Kraken and Coinbase market data (public); Coinbase live broker. Live on an unsupported exchange now errors instead of silently falling back to paper. - Strategy presets backed by the StrategyConfig table (CRUD API + UI). - Idempotent additive-column migration so existing SQLite databases upgrade in place (fixes the new strategy_configs.name column).
This commit is contained in:
1 parent
9c3bee87ac
commit
03dfa369e5
35 files changed
+1409
-45
No files matched your search
+81
-12
@@ -6,7 +6,8 @@ from typing import Annotated
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.engine.strategies import list_strategies, strategy_spec
|
||||
from app.config import Settings
|
||||
from app.engine.strategies import get_strategy, list_strategies, strategy_spec
|
||||
from app.models import OrderSide, Position, PositionStatus
|
||||
from app.runtime import Runtime
|
||||
from app.schemas import (
|
||||
@@ -15,11 +16,14 @@ from app.schemas import (
|
||||
ClosePositionRequest,
|
||||
EquityPointOut,
|
||||
EventOut,
|
||||
KillRequest,
|
||||
ManualOrderRequest,
|
||||
OrderOut,
|
||||
PositionOut,
|
||||
StartRequest,
|
||||
StatusOut,
|
||||
StrategyConfigIn,
|
||||
StrategyConfigOut,
|
||||
StrategyOut,
|
||||
)
|
||||
|
||||
@@ -30,7 +34,26 @@ def get_runtime(request: Request) -> Runtime:
|
||||
return request.app.state.runtime
|
||||
|
||||
|
||||
RuntimeDep = Annotated[Runtime, Depends(get_runtime)]
|
||||
def get_settings(request: Request) -> Settings:
|
||||
return request.app.state.runtime.settings
|
||||
|
||||
|
||||
def get_guarded_runtime(request: Request) -> Runtime:
|
||||
"""Return the runtime, rejecting requests without the configured API token.
|
||||
|
||||
The check is a no-op when ``API_TOKEN`` is unset, so local dev and the
|
||||
Basic-auth-only deployment keep working unchanged.
|
||||
"""
|
||||
runtime: Runtime = request.app.state.runtime
|
||||
settings: Settings = runtime.settings
|
||||
if settings.api_token:
|
||||
provided = request.headers.get(settings.api_token_header, "")
|
||||
if provided != settings.api_token:
|
||||
raise HTTPException(status_code=401, detail="invalid or missing API token")
|
||||
return runtime
|
||||
|
||||
|
||||
GuardedRuntimeDep = Annotated[Runtime, Depends(get_guarded_runtime)]
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
@@ -44,12 +67,12 @@ def strategies() -> list[StrategyOut]:
|
||||
|
||||
|
||||
@router.get("/status", response_model=StatusOut)
|
||||
def status(runtime: RuntimeDep) -> StatusOut:
|
||||
def status(runtime: GuardedRuntimeDep) -> StatusOut:
|
||||
return runtime.status()
|
||||
|
||||
|
||||
@router.post("/bot/start", response_model=StatusOut)
|
||||
async def start_bot(payload: StartRequest, runtime: RuntimeDep) -> StatusOut:
|
||||
async def start_bot(payload: StartRequest, runtime: GuardedRuntimeDep) -> StatusOut:
|
||||
try:
|
||||
return await runtime.start(
|
||||
payload.strategy_key,
|
||||
@@ -65,12 +88,17 @@ async def start_bot(payload: StartRequest, runtime: RuntimeDep) -> StatusOut:
|
||||
|
||||
|
||||
@router.post("/bot/stop", response_model=StatusOut)
|
||||
async def stop_bot(runtime: RuntimeDep) -> StatusOut:
|
||||
async def stop_bot(runtime: GuardedRuntimeDep) -> StatusOut:
|
||||
return await runtime.stop()
|
||||
|
||||
|
||||
@router.post("/bot/kill", response_model=StatusOut)
|
||||
async def kill_bot(payload: KillRequest, runtime: GuardedRuntimeDep) -> StatusOut:
|
||||
return await runtime.kill(payload.reason)
|
||||
|
||||
|
||||
@router.post("/backtest", response_model=BacktestResult)
|
||||
async def run_backtest(payload: BacktestRequest, runtime: RuntimeDep) -> BacktestResult:
|
||||
async def run_backtest(payload: BacktestRequest, runtime: GuardedRuntimeDep) -> BacktestResult:
|
||||
try:
|
||||
return await runtime.run_backtest(payload)
|
||||
except KeyError as exc:
|
||||
@@ -81,6 +109,36 @@ async def run_backtest(payload: BacktestRequest, runtime: RuntimeDep) -> Backtes
|
||||
raise HTTPException(status_code=502, detail=f"market data error: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/presets", response_model=list[StrategyConfigOut])
|
||||
def list_presets(runtime: GuardedRuntimeDep) -> list[StrategyConfigOut]:
|
||||
return [
|
||||
StrategyConfigOut.model_validate(config) for config in runtime.store.list_strategy_configs()
|
||||
]
|
||||
|
||||
|
||||
@router.post("/presets", response_model=StrategyConfigOut)
|
||||
def create_preset(payload: StrategyConfigIn, runtime: GuardedRuntimeDep) -> StrategyConfigOut:
|
||||
try:
|
||||
get_strategy(payload.strategy_key)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
config = runtime.store.save_strategy_config(
|
||||
name=payload.name,
|
||||
strategy_key=payload.strategy_key,
|
||||
symbol=payload.symbol,
|
||||
timeframe=payload.timeframe,
|
||||
params=payload.params,
|
||||
)
|
||||
return StrategyConfigOut.model_validate(config)
|
||||
|
||||
|
||||
@router.delete("/presets/{config_id}")
|
||||
def delete_preset(config_id: int, runtime: GuardedRuntimeDep) -> dict[str, bool]:
|
||||
if not runtime.store.delete_strategy_config(config_id):
|
||||
raise HTTPException(status_code=404, detail="preset not found")
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
def _position_out(position: Position, price: float | None) -> PositionOut:
|
||||
out = PositionOut.model_validate(position)
|
||||
if position.status == PositionStatus.OPEN and price is not None:
|
||||
@@ -90,28 +148,28 @@ def _position_out(position: Position, price: float | None) -> PositionOut:
|
||||
|
||||
|
||||
@router.get("/positions", response_model=list[PositionOut])
|
||||
def positions(runtime: RuntimeDep, limit: int = 50) -> list[PositionOut]:
|
||||
def positions(runtime: GuardedRuntimeDep, limit: int = 50) -> list[PositionOut]:
|
||||
price = runtime.engine.last_price if runtime.engine else None
|
||||
return [_position_out(position, price) for position in runtime.store.list_positions(limit)]
|
||||
|
||||
|
||||
@router.get("/orders", response_model=list[OrderOut])
|
||||
def orders(runtime: RuntimeDep, limit: int = 50) -> list[OrderOut]:
|
||||
def orders(runtime: GuardedRuntimeDep, limit: int = 50) -> list[OrderOut]:
|
||||
return [OrderOut.model_validate(order) for order in runtime.store.list_orders(limit)]
|
||||
|
||||
|
||||
@router.get("/events", response_model=list[EventOut])
|
||||
def events(runtime: RuntimeDep, limit: int = 50) -> list[EventOut]:
|
||||
def events(runtime: GuardedRuntimeDep, limit: int = 50) -> list[EventOut]:
|
||||
return [EventOut.model_validate(event) for event in runtime.store.list_events(limit)]
|
||||
|
||||
|
||||
@router.get("/equity", response_model=list[EquityPointOut])
|
||||
def equity(runtime: RuntimeDep, limit: int = 500) -> list[EquityPointOut]:
|
||||
def equity(runtime: GuardedRuntimeDep, limit: int = 500) -> list[EquityPointOut]:
|
||||
return [EquityPointOut.model_validate(point) for point in runtime.store.list_equity(limit)]
|
||||
|
||||
|
||||
@router.post("/orders", response_model=StatusOut)
|
||||
async def manual_order(payload: ManualOrderRequest, runtime: RuntimeDep) -> StatusOut:
|
||||
async def manual_order(payload: ManualOrderRequest, runtime: GuardedRuntimeDep) -> StatusOut:
|
||||
engine = runtime.engine
|
||||
if engine is None or not engine.running:
|
||||
raise HTTPException(status_code=409, detail="engine is not running")
|
||||
@@ -122,7 +180,7 @@ async def manual_order(payload: ManualOrderRequest, runtime: RuntimeDep) -> Stat
|
||||
|
||||
@router.post("/positions/{position_id}/close", response_model=StatusOut)
|
||||
async def close_position(
|
||||
position_id: int, payload: ClosePositionRequest, runtime: RuntimeDep
|
||||
position_id: int, payload: ClosePositionRequest, runtime: GuardedRuntimeDep
|
||||
) -> StatusOut:
|
||||
engine = runtime.engine
|
||||
if engine is None or not engine.running:
|
||||
@@ -136,6 +194,17 @@ async def close_position(
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket) -> None:
|
||||
runtime: Runtime = websocket.app.state.runtime
|
||||
settings: Settings = runtime.settings
|
||||
if settings.api_token:
|
||||
# The frontend nginx injects the token as a header on the upgrade request
|
||||
# (browsers can't set WebSocket headers themselves); accept a query param
|
||||
# too for direct/manual clients.
|
||||
provided = websocket.headers.get(settings.api_token_header) or websocket.query_params.get(
|
||||
"token", ""
|
||||
)
|
||||
if provided != settings.api_token:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
await websocket.accept()
|
||||
queue = runtime.subscribe()
|
||||
try:
|
||||
|
||||
Reference in new issue
Block a user