- 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).
225 lines
8.0 KiB
Python
225 lines
8.0 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import Annotated
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
|
|
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 (
|
|
BacktestRequest,
|
|
BacktestResult,
|
|
ClosePositionRequest,
|
|
EquityPointOut,
|
|
EventOut,
|
|
KillRequest,
|
|
ManualOrderRequest,
|
|
OrderOut,
|
|
PositionOut,
|
|
StartRequest,
|
|
StatusOut,
|
|
StrategyConfigIn,
|
|
StrategyConfigOut,
|
|
StrategyOut,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api")
|
|
|
|
|
|
def get_runtime(request: Request) -> Runtime:
|
|
return request.app.state.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")
|
|
def health() -> dict[str, str]:
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/strategies", response_model=list[StrategyOut])
|
|
def strategies() -> list[StrategyOut]:
|
|
return [strategy_spec(cls) for cls in list_strategies()]
|
|
|
|
|
|
@router.get("/status", response_model=StatusOut)
|
|
def status(runtime: GuardedRuntimeDep) -> StatusOut:
|
|
return runtime.status()
|
|
|
|
|
|
@router.post("/bot/start", response_model=StatusOut)
|
|
async def start_bot(payload: StartRequest, runtime: GuardedRuntimeDep) -> StatusOut:
|
|
try:
|
|
return await runtime.start(
|
|
payload.strategy_key,
|
|
payload.params,
|
|
symbol=payload.symbol,
|
|
timeframe=payload.timeframe,
|
|
starting_balance=payload.starting_balance,
|
|
)
|
|
except KeyError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
except (ValueError, RuntimeError) as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
@router.post("/bot/stop", response_model=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: GuardedRuntimeDep) -> BacktestResult:
|
|
try:
|
|
return await runtime.run_backtest(payload)
|
|
except KeyError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
except httpx.HTTPError as exc:
|
|
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:
|
|
sign = 1 if position.side.value == "long" else -1
|
|
out.unrealized_pnl = sign * (price - position.entry_price) * position.qty
|
|
return out
|
|
|
|
|
|
@router.get("/positions", response_model=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: 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: 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: 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: GuardedRuntimeDep) -> StatusOut:
|
|
engine = runtime.engine
|
|
if engine is None or not engine.running:
|
|
raise HTTPException(status_code=409, detail="engine is not running")
|
|
side = OrderSide.BUY if payload.side == "buy" else OrderSide.SELL
|
|
await engine.manual_order(side, payload.qty, payload.quote_amount, payload.reason)
|
|
return engine.status()
|
|
|
|
|
|
@router.post("/positions/{position_id}/close", response_model=StatusOut)
|
|
async def close_position(
|
|
position_id: int, payload: ClosePositionRequest, runtime: GuardedRuntimeDep
|
|
) -> StatusOut:
|
|
engine = runtime.engine
|
|
if engine is None or not engine.running:
|
|
raise HTTPException(status_code=409, detail="engine is not running")
|
|
if engine.position is None or engine.position.id != position_id:
|
|
raise HTTPException(status_code=404, detail="no open position with that id")
|
|
await engine.close_current_position(payload.reason)
|
|
return engine.status()
|
|
|
|
|
|
@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:
|
|
await websocket.send_json(
|
|
{"type": "status", "data": runtime.status().model_dump(mode="json")}
|
|
)
|
|
while True:
|
|
try:
|
|
payload = await asyncio.wait_for(queue.get(), timeout=15.0)
|
|
except TimeoutError:
|
|
await websocket.send_json({"type": "ping"})
|
|
continue
|
|
await websocket.send_json(payload)
|
|
except WebSocketDisconnect:
|
|
pass
|
|
finally:
|
|
runtime.unsubscribe(queue)
|