Crypto trading bot: engine, brokers, backtester, dashboard, deploy
FastAPI backend with a single-strategy async engine, paper and Binance brokers, pure-python indicators, a backtester that shares the engine's portfolio math, and a Vue dashboard with a live WebSocket feed. Adds the Gitea Actions CI/CD workflow that deploys to Traefik via docker_default.
This commit is contained in:
1 parent
98028248c2
commit
9c3bee87ac
73 files changed
+9759
-1
No files matched your search
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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.models import OrderSide, Position, PositionStatus
|
||||
from app.runtime import Runtime
|
||||
from app.schemas import (
|
||||
BacktestRequest,
|
||||
BacktestResult,
|
||||
ClosePositionRequest,
|
||||
EquityPointOut,
|
||||
EventOut,
|
||||
ManualOrderRequest,
|
||||
OrderOut,
|
||||
PositionOut,
|
||||
StartRequest,
|
||||
StatusOut,
|
||||
StrategyOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
def get_runtime(request: Request) -> Runtime:
|
||||
return request.app.state.runtime
|
||||
|
||||
|
||||
RuntimeDep = Annotated[Runtime, Depends(get_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: RuntimeDep) -> StatusOut:
|
||||
return runtime.status()
|
||||
|
||||
|
||||
@router.post("/bot/start", response_model=StatusOut)
|
||||
async def start_bot(payload: StartRequest, runtime: RuntimeDep) -> 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: RuntimeDep) -> StatusOut:
|
||||
return await runtime.stop()
|
||||
|
||||
|
||||
@router.post("/backtest", response_model=BacktestResult)
|
||||
async def run_backtest(payload: BacktestRequest, runtime: RuntimeDep) -> 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
|
||||
|
||||
|
||||
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: RuntimeDep, 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]:
|
||||
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]:
|
||||
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]:
|
||||
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:
|
||||
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: RuntimeDep
|
||||
) -> 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
|
||||
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)
|
||||
Reference in new issue
Block a user