Crypto trading bot: engine, brokers, backtester, dashboard, deploy
CI/CD / backend (push) Successful in 5m18s
CI/CD / frontend (push) Successful in 5m2s
CI/CD / build (push) Successful in 34s
CI/CD / cleanup (push) Successful in 6s
CI/CD / deploy (push) Successful in 22s

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:
matitos committed 2026-09-18 17:02:33 +00:00
1 parent 98028248c2
commit 9c3bee87ac
73 files changed
+9759 -1

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
.venv/
tests/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
data/
*.db
*.sqlite3
.env
.git/
+17
View File
@@ -0,0 +1,17 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
RUN mkdir -p /data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+155
View File
@@ -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)
+6
View File
@@ -0,0 +1,6 @@
from __future__ import annotations
from app.backtest.backtester import Backtester
from app.backtest.metrics import compute_metrics, max_drawdown_pct, sharpe_ratio
__all__ = ["Backtester", "compute_metrics", "max_drawdown_pct", "sharpe_ratio"]
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
from collections.abc import Sequence
from app.backtest.metrics import compute_metrics
from app.broker.paper import PaperBroker
from app.engine.portfolio import (
OpenPosition,
account_equity,
apply_close,
apply_open,
order_qty,
realized_pnl,
risk_exit,
)
from app.engine.strategies.base import Strategy, StrategyContext
from app.models import OrderSide, PositionSide
from app.schemas import BacktestPointOut, BacktestResult, BacktestTradeOut, Candle
def _open_side(side: PositionSide) -> OrderSide:
return OrderSide.BUY if side == PositionSide.LONG else OrderSide.SELL
def _close_side(side: PositionSide) -> OrderSide:
return OrderSide.SELL if side == PositionSide.LONG else OrderSide.BUY
class Backtester:
"""Runs a strategy bar-by-bar over historical candles.
Reuses the very same ``Strategy``/``StrategyContext`` interface and the same
portfolio math as the live engine, so results carry over to paper/live.
"""
def __init__(
self,
*,
fee_bps: float = 10.0,
slippage_bps: float = 5.0,
starting_balance: float = 10_000.0,
) -> None:
self.broker = PaperBroker(fee_bps=fee_bps, slippage_bps=slippage_bps)
self.starting_balance = starting_balance
def run(
self,
candles: Sequence[Candle],
strategy: Strategy,
symbol: str,
timeframe: str,
) -> BacktestResult:
starting = self.starting_balance
cash = starting
position: OpenPosition | None = None
trades: list[BacktestTradeOut] = []
curve: list[BacktestPointOut] = []
error: str | None = None
first_close = candles[0].close if candles else 0.0
def close_at(level: float, ts: int, reason: str) -> None:
nonlocal cash, position
assert position is not None
fill = self.broker.fill(symbol, _close_side(position.side), position.qty, level)
cash = apply_close(cash, position.side, fill.qty, fill.price, fill.fee)
pnl = realized_pnl(
position.side,
position.entry_price,
fill.price,
position.qty,
position.entry_fee,
fill.fee,
)
cost = position.entry_price * position.qty
trades.append(
BacktestTradeOut(
side=position.side.value,
entry_ts=position.entry_ts,
exit_ts=ts,
entry_price=position.entry_price,
exit_price=fill.price,
qty=position.qty,
pnl=pnl,
return_pct=(pnl / cost * 100) if cost else 0.0,
reason=reason,
)
)
position = None
try:
for i, candle in enumerate(candles):
mark = candle.close
if position is not None:
hit = risk_exit(
position.side,
position.stop_loss,
position.take_profit,
candle.high,
candle.low,
)
if hit is not None:
close_at(hit[1], candle.ts, hit[0])
equity = (
account_equity(cash, position.side, position.qty, mark) if position else cash
)
ctx = StrategyContext(candles[: i + 1], position, equity, cash)
strategy.on_candle(ctx)
for intent in ctx.take_intents():
if intent.action == "close":
if position is not None:
close_at(mark, candle.ts, intent.reason or "strategy close")
elif intent.action in {"enter_long", "enter_short"} and position is None:
side = (
PositionSide.LONG
if intent.action == "enter_long"
else PositionSide.SHORT
)
size = intent.size if intent.size is not None else 1.0
qty = order_qty(cash, size, side, cash, mark)
if qty <= 0:
continue
fill = self.broker.fill(symbol, _open_side(side), qty, mark)
cash = apply_open(cash, side, fill.qty, fill.price, fill.fee)
position = OpenPosition(
side=side,
qty=fill.qty,
entry_price=fill.price,
entry_fee=fill.fee,
stop_loss=intent.stop_loss,
take_profit=intent.take_profit,
entry_ts=candle.ts,
)
equity = (
account_equity(cash, position.side, position.qty, mark) if position else cash
)
curve.append(
BacktestPointOut(
ts=candle.ts,
equity=equity,
buy_hold=starting * (mark / first_close) if first_close else starting,
price=mark,
)
)
except Exception as exc: # surface strategy/config errors in the result
error = f"{type(exc).__name__}: {exc}"
# Force-close any open position at the last close so trades are realized.
if position is not None and candles and error is None:
last = candles[-1]
close_at(last.close, last.ts, "end of data")
if curve:
curve[-1] = BacktestPointOut(
ts=last.ts,
equity=cash,
buy_hold=curve[-1].buy_hold,
price=last.close,
)
return BacktestResult(
strategy_key=strategy.key,
strategy_name=strategy.name or strategy.key,
symbol=symbol,
timeframe=timeframe,
starting_balance=starting,
metrics=compute_metrics(curve, trades, starting, timeframe),
equity_curve=curve,
trades=trades,
error=error,
)
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from collections.abc import Sequence
from app.schemas import BacktestMetricsOut, BacktestPointOut, BacktestTradeOut
# Bars per year for annualising the Sharpe ratio. Unknown timeframes -> None.
BARS_PER_YEAR: dict[str, float] = {
"1m": 525_600,
"3m": 175_200,
"5m": 105_120,
"15m": 35_040,
"30m": 17_520,
"1h": 8_760,
"2h": 4_380,
"4h": 2_190,
"6h": 1_460,
"12h": 730,
"1d": 365,
"3d": 121,
"1w": 52,
}
def max_drawdown_pct(equities: Sequence[float]) -> float:
peak = equities[0] if equities else 0.0
worst = 0.0
for value in equities:
peak = max(peak, value)
if peak > 0:
worst = max(worst, (peak - value) / peak)
return worst * 100
def sharpe_ratio(equities: Sequence[float], timeframe: str) -> float | None:
if len(equities) < 3:
return None
returns = [
equities[i] / equities[i - 1] - 1 for i in range(1, len(equities)) if equities[i - 1] > 0
]
if len(returns) < 2:
return None
mean = sum(returns) / len(returns)
variance = sum((r - mean) ** 2 for r in returns) / (len(returns) - 1)
std = variance**0.5
periods = BARS_PER_YEAR.get(timeframe)
if std == 0 or not periods:
return None
return mean / std * (periods**0.5)
def compute_metrics(
curve: Sequence[BacktestPointOut],
trades: Sequence[BacktestTradeOut],
starting_balance: float,
timeframe: str,
) -> BacktestMetricsOut:
equities = [point.equity for point in curve] or [starting_balance]
final_equity = equities[-1]
total_return = (final_equity / starting_balance - 1) * 100 if starting_balance else 0.0
buy_hold = (
(curve[-1].buy_hold / starting_balance - 1) * 100 if curve and starting_balance else 0.0
)
returns = [trade.return_pct for trade in trades]
gross_profit = sum(trade.pnl for trade in trades if trade.pnl > 0)
gross_loss = -sum(trade.pnl for trade in trades if trade.pnl < 0)
wins = sum(1 for trade in trades if trade.pnl > 0)
return BacktestMetricsOut(
initial_equity=starting_balance,
final_equity=final_equity,
total_return_pct=total_return,
buy_hold_return_pct=buy_hold,
max_drawdown_pct=max_drawdown_pct(equities),
num_trades=len(trades),
win_rate_pct=(wins / len(trades) * 100) if trades else 0.0,
profit_factor=(gross_profit / gross_loss) if gross_loss > 0 else None,
avg_trade_pct=(sum(returns) / len(returns)) if returns else 0.0,
best_trade_pct=max(returns) if returns else 0.0,
worst_trade_pct=min(returns) if returns else 0.0,
sharpe=sharpe_ratio(equities, timeframe),
)
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from app.broker.base import Broker, Fill
from app.broker.binance import TESTNET_URL, BinanceBroker
from app.broker.paper import PaperBroker
from app.config import Settings
from app.market.binance import BASE_URL
def build_broker(settings: Settings) -> Broker:
if settings.mode == "live":
return BinanceBroker(
api_key=settings.exchange_api_key,
api_secret=settings.exchange_api_secret,
base_url=TESTNET_URL if settings.exchange_sandbox else BASE_URL,
)
return PaperBroker(fee_bps=settings.fee_bps, slippage_bps=settings.slippage_bps)
__all__ = ["Broker", "Fill", "PaperBroker", "build_broker"]
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from app.models import OrderSide
@dataclass(slots=True)
class Fill:
symbol: str
side: OrderSide
qty: float
price: float
fee: float
ts: datetime
@property
def notional(self) -> float:
return self.qty * self.price
class Broker(ABC):
"""Executes market orders. Paper and live brokers share this interface."""
@abstractmethod
async def buy(self, symbol: str, qty: float, ref_price: float) -> Fill:
raise NotImplementedError
@abstractmethod
async def sell(self, symbol: str, qty: float, ref_price: float) -> Fill:
raise NotImplementedError
async def close(self) -> None:
return None
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
import hashlib
import hmac
import time
from urllib.parse import urlencode
import httpx
from app.broker.base import Broker, Fill
from app.market.binance import BASE_URL, to_exchange_symbol
from app.models import OrderSide, utcnow
TESTNET_URL = "https://testnet.binance.vision"
class BinanceBroker(Broker):
"""Live Binance spot market-order execution (requires API credentials)."""
def __init__(
self,
api_key: str,
api_secret: str,
base_url: str = BASE_URL,
timeout: float = 10.0,
) -> None:
if not api_key or not api_secret:
raise ValueError("Binance broker requires EXCHANGE_API_KEY and EXCHANGE_API_SECRET")
self.api_key = api_key
self.api_secret = api_secret.encode()
self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout)
def _sign(self, params: dict) -> dict:
params = {**params, "timestamp": int(time.time() * 1000), "recvWindow": 5000}
query = urlencode(params)
signature = hmac.new(self.api_secret, query.encode(), hashlib.sha256).hexdigest()
return {**params, "signature": signature}
async def _market_order(self, symbol: str, side: OrderSide, qty: float) -> Fill:
params = self._sign(
{
"symbol": to_exchange_symbol(symbol),
"side": side.value.upper(),
"type": "MARKET",
"quantity": f"{qty:.8f}".rstrip("0").rstrip("."),
}
)
response = await self._client.post(
"/api/v3/order",
params=params,
headers={"X-MBX-APIKEY": self.api_key},
)
response.raise_for_status()
data = response.json()
executed = float(data.get("executedQty", qty))
quote = float(data.get("cummulativeQuoteQty", 0.0))
price = quote / executed if executed else 0.0
fee = sum(float(fill.get("commission", 0.0)) for fill in data.get("fills", []))
return Fill(
symbol=symbol,
side=side,
qty=executed,
price=price,
fee=fee,
ts=utcnow(),
)
async def buy(self, symbol: str, qty: float, ref_price: float) -> Fill:
return await self._market_order(symbol, OrderSide.BUY, qty)
async def sell(self, symbol: str, qty: float, ref_price: float) -> Fill:
return await self._market_order(symbol, OrderSide.SELL, qty)
async def close(self) -> None:
await self._client.aclose()
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from app.broker.base import Broker, Fill
from app.models import OrderSide, utcnow
class PaperBroker(Broker):
"""Simulated fills against a reference price, with fees and slippage.
Buys fill slightly above and sells slightly below the reference price to
approximate adverse slippage.
"""
def __init__(self, fee_bps: float = 10.0, slippage_bps: float = 5.0) -> None:
self.fee_bps = fee_bps
self.slippage_bps = slippage_bps
def _fee(self, notional: float) -> float:
return abs(notional) * self.fee_bps / 10_000
def fill(self, symbol: str, side: OrderSide, qty: float, ref_price: float) -> Fill:
"""Synchronous fill used by the backtester (same model as buy/sell)."""
direction = 1 if side == OrderSide.BUY else -1
price = ref_price * (1 + direction * self.slippage_bps / 10_000)
return Fill(
symbol=symbol,
side=side,
qty=qty,
price=price,
fee=self._fee(qty * price),
ts=utcnow(),
)
async def buy(self, symbol: str, qty: float, ref_price: float) -> Fill:
return self.fill(symbol, OrderSide.BUY, qty, ref_price)
async def sell(self, symbol: str, qty: float, ref_price: float) -> Fill:
return self.fill(symbol, OrderSide.SELL, qty, ref_price)
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
REPO_ROOT = Path(__file__).resolve().parents[2]
DATA_DIR = REPO_ROOT / "data"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(REPO_ROOT / ".env", ".env"),
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = "Crypto Trader"
env: str = "dev"
host: str = "0.0.0.0"
port: int = 8000
database_url: str = "sqlite:///./data/trader.db"
mode: str = "paper"
exchange: str = "binance"
symbol: str = "BTC/USDT"
timeframe: str = "1m"
poll_interval: float = 5.0
candle_limit: int = 200
starting_balance: float = 10_000.0
fee_bps: float = 10.0
slippage_bps: float = 5.0
exchange_api_key: str = ""
exchange_api_secret: str = ""
exchange_sandbox: bool = False
cors_origins: str = "*"
@field_validator("mode")
@classmethod
def _validate_mode(cls, value: str) -> str:
value = value.lower().strip()
if value not in {"paper", "live"}:
raise ValueError("mode must be 'paper' or 'live'")
return value
@property
def cors_origin_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@property
def is_live(self) -> bool:
return self.mode == "live"
@lru_cache
def get_settings() -> Settings:
settings = Settings()
DATA_DIR.mkdir(parents=True, exist_ok=True)
return settings
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from collections.abc import Iterator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.config import REPO_ROOT, get_settings
from app.models import Base
_settings = get_settings()
def _normalize_url(url: str) -> tuple[str, dict]:
connect_args: dict = {}
if url.startswith("sqlite"):
prefix, _, path = url.partition("sqlite:///")
if path and not path.startswith("/"):
url = f"{prefix}sqlite:///{REPO_ROOT / path}"
connect_args = {"check_same_thread": False}
return url, connect_args
_url, _connect_args = _normalize_url(_settings.database_url)
engine = create_engine(_url, connect_args=_connect_args, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def init_db() -> None:
Base.metadata.create_all(bind=engine)
def get_session() -> Iterator[Session]:
session = SessionLocal()
try:
yield session
finally:
session.close()
View File
Whitespace-only changes.
+390
View File
@@ -0,0 +1,390 @@
from __future__ import annotations
import asyncio
import contextlib
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
from app.broker.base import Broker
from app.config import Settings
from app.engine.portfolio import (
account_equity,
apply_close,
apply_open,
order_qty,
realized_pnl,
risk_exit,
unrealized_pnl,
)
from app.engine.strategies import Strategy, get_strategy
from app.engine.strategies.base import StrategyContext
from app.market.base import MarketData
from app.models import OrderSide, Position, PositionSide
from app.schemas import Candle, EventOut, StatusOut
from app.store import Store
Publish = Callable[[dict[str, Any]], None]
class TradingEngine:
"""Single-strategy trading loop.
The engine owns portfolio state (cash, open position), calls the strategy
once per candle, turns emitted intents into orders, and enforces stop-loss /
take-profit levels.
"""
def __init__(
self,
*,
feed: MarketData,
broker: Broker,
store: Store,
settings: Settings,
) -> None:
self.feed = feed
self.broker = broker
self.store = store
self.settings = settings
self.running = False
self.error: str | None = None
self.last_tick: datetime | None = None
self.last_price: float | None = None
self.cash: float = settings.starting_balance
self.starting_balance: float = settings.starting_balance
self.realized_pnl: float = 0.0
self.position: Position | None = None
self.strategy: Strategy | None = None
self.strategy_key: str | None = None
self.symbol: str = settings.symbol
self.timeframe: str = settings.timeframe
self._task: asyncio.Task | None = None
self._publish_cb: Publish | None = None
# --- lifecycle ----------------------------------------------------
def set_publisher(self, publish: Publish | None) -> None:
self._publish_cb = publish
async def start(
self,
strategy_key: str,
params: dict[str, Any] | None = None,
*,
symbol: str | None = None,
timeframe: str | None = None,
starting_balance: float | None = None,
) -> None:
if self.running:
raise RuntimeError("engine already running")
strategy_cls = get_strategy(strategy_key)
self.strategy = strategy_cls(params)
self.strategy_key = strategy_key
self.symbol = symbol or self.settings.symbol
self.timeframe = timeframe or self.settings.timeframe
self.starting_balance = float(starting_balance or self.settings.starting_balance)
self.store.abandon_open_positions(strategy_key, reason="reset on engine start")
self.position = None
self.cash = self.starting_balance
self.realized_pnl = 0.0
self.last_price = None
self.error = None
self.last_tick = None
self.running = True
self._emit("info", f"Started {strategy_cls.name or strategy_key} on {self.symbol}")
self._broadcast_status()
self._task = asyncio.create_task(self._run())
async def stop(self) -> None:
if not self.running:
return
self.running = False
if self._task:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
self._emit("info", "Engine stopped")
self._broadcast_status()
async def shutdown(self) -> None:
await self.stop()
await self.feed.close()
await self.broker.close()
# --- loop ---------------------------------------------------------
async def _run(self) -> None:
while self.running:
try:
await self.tick()
except asyncio.CancelledError:
raise
except Exception as exc: # keep the loop alive on transient errors
self.error = f"{type(exc).__name__}: {exc}"
self._emit("error", f"tick failed: {self.error}")
await asyncio.sleep(self.settings.poll_interval)
async def tick(self) -> None:
candles = await self.feed.fetch_candles(
self.symbol, self.timeframe, self.settings.candle_limit
)
if not candles:
return
self.last_price = candles[-1].close
self.last_tick = datetime.now(UTC)
self.error = None
if self.position is not None:
await self._check_risk(candles[-1])
if self.strategy is not None:
ctx = StrategyContext(
candles=candles,
position=self.position,
equity=self.equity,
cash=self.cash,
)
try:
self.strategy.on_candle(ctx)
except Exception as exc:
self.error = f"strategy error: {type(exc).__name__}: {exc}"
self._emit("error", self.error)
await self._process_intents(ctx)
self.store.record_equity(self.equity, self.cash, self.last_price)
self._broadcast_status()
# --- portfolio helpers -------------------------------------------
@property
def equity(self) -> float:
if self.position is None:
return self.cash
return account_equity(
self.cash, self.position.side, self.position.qty, self.last_price or 0.0
)
@property
def unrealized_pnl(self) -> float:
if self.position is None or self.last_price is None:
return 0.0
return unrealized_pnl(
self.position.side, self.position.entry_price, self.last_price, self.position.qty
)
async def _process_intents(self, ctx: StrategyContext) -> None:
for intent in ctx.take_intents():
if intent.action == "close":
if self.position is not None:
await self._close_position(intent.reason or "strategy close")
elif intent.action in {"enter_long", "enter_short"}:
if self.position is not None:
continue
side = PositionSide.LONG if intent.action == "enter_long" else PositionSide.SHORT
await self._open_position(
side,
size=intent.size if intent.size is not None else 1.0,
stop_loss=intent.stop_loss,
take_profit=intent.take_profit,
reason=intent.reason or intent.action,
)
async def _open_position(
self,
side: PositionSide,
*,
size: float,
stop_loss: float | None,
take_profit: float | None,
reason: str,
qty_override: float | None = None,
) -> None:
price = self.last_price
if not price or price <= 0:
return
if qty_override is not None:
qty = qty_override
else:
qty = order_qty(self.equity, size, side, self.cash, price)
if qty <= 0:
return
fill = (
await self.broker.buy(self.symbol, qty, price)
if side == PositionSide.LONG
else await self.broker.sell(self.symbol, qty, price)
)
self.cash = apply_open(self.cash, side, fill.qty, fill.price, fill.fee)
self.position = self.store.create_position(
strategy_key=self.strategy_key or "manual",
symbol=self.symbol,
side=side,
qty=fill.qty,
entry_price=fill.price,
entry_fee=fill.fee,
stop_loss=stop_loss,
take_profit=take_profit,
)
self.store.record_order(
position_id=self.position.id,
strategy_key=self.position.strategy_key,
symbol=self.symbol,
side=fill.side,
qty=fill.qty,
price=fill.price,
fee=fill.fee,
reason=reason,
)
self._emit(
"trade",
f"Opened {side.value} {fill.qty:.6f} {self.symbol} @ {fill.price:.4f} ({reason})",
)
async def _close_position(self, reason: str) -> None:
position = self.position
price = self.last_price
if position is None or not price:
return
fill = (
await self.broker.sell(self.symbol, position.qty, price)
if position.side == PositionSide.LONG
else await self.broker.buy(self.symbol, position.qty, price)
)
self.cash = apply_close(self.cash, position.side, fill.qty, fill.price, fill.fee)
realized = realized_pnl(
position.side,
position.entry_price,
fill.price,
position.qty,
position.entry_fee,
fill.fee,
)
self.realized_pnl += realized
self.store.close_position(
position,
exit_price=fill.price,
exit_fee=fill.fee,
realized_pnl=realized,
)
self.store.record_order(
position_id=position.id,
strategy_key=position.strategy_key,
symbol=self.symbol,
side=fill.side,
qty=fill.qty,
price=fill.price,
fee=fill.fee,
reason=reason,
)
self.position = None
self._emit(
"trade",
f"Closed {position.side.value} {position.qty:.6f} {self.symbol} "
f"@ {fill.price:.4f} pnl={realized:.2f} ({reason})",
)
async def _check_risk(self, candle: Candle) -> None:
position = self.position
if position is None:
return
hit = risk_exit(
position.side,
position.stop_loss,
position.take_profit,
candle.high,
candle.low,
)
if hit is not None:
reason, level = hit
self.last_price = level
await self._close_position(reason)
# --- manual controls ----------------------------------------------
async def manual_order(
self, side: OrderSide, qty: float | None, quote_amount: float | None, reason: str
) -> None:
price = self.last_price or await self.feed.fetch_price(self.symbol)
self.last_price = price
if qty is None:
notional = quote_amount if quote_amount is not None else self.cash * 0.99
qty = notional / price
if qty <= 0:
return
if side == OrderSide.BUY:
if self.position is not None:
return
await self._open_position(
PositionSide.LONG,
size=0.0,
stop_loss=None,
take_profit=None,
reason=reason,
qty_override=qty,
)
else:
if self.position is not None:
await self._close_position(reason or "manual close")
else:
await self._open_position(
PositionSide.SHORT,
size=0.0,
stop_loss=None,
take_profit=None,
reason=reason,
qty_override=qty,
)
async def close_current_position(self, reason: str = "manual") -> None:
if self.position is not None:
if self.last_price is None:
self.last_price = await self.feed.fetch_price(self.symbol)
await self._close_position(reason)
# --- status / events ----------------------------------------------
def status(self) -> StatusOut:
strategy_name = None
if self.strategy is not None:
strategy_name = self.strategy.name or self.strategy.key
return StatusOut(
running=self.running,
mode=self.settings.mode,
exchange=self.settings.exchange,
symbol=self.symbol,
timeframe=self.timeframe,
strategy_key=self.strategy_key,
strategy_name=strategy_name,
params=dict(self.strategy.params) if self.strategy else {},
price=self.last_price,
cash=self.cash,
equity=self.equity,
starting_balance=self.starting_balance,
realized_pnl=self.realized_pnl,
unrealized_pnl=self.unrealized_pnl,
total_pnl=self.equity - self.starting_balance,
open_positions=1 if self.position else 0,
last_tick=self.last_tick,
error=self.error,
)
def _emit(self, level: str, message: str) -> None:
event = self.store.record_event(level, message, self.strategy_key)
self._publish(
{"type": "event", "data": EventOut.model_validate(event).model_dump(mode="json")}
)
def _broadcast_status(self) -> None:
self._publish({"type": "status", "data": self.status().model_dump(mode="json")})
def _publish(self, payload: dict[str, Any]) -> None:
if self._publish_cb is not None:
self._publish_cb(payload)
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
from collections.abc import Sequence
from app.schemas import Candle
Number = float | None
def sma(values: Sequence[float], period: int) -> list[Number]:
if period <= 0:
raise ValueError("period must be positive")
out: list[Number] = [None] * len(values)
window_sum = 0.0
for i, value in enumerate(values):
window_sum += value
if i >= period:
window_sum -= values[i - period]
if i >= period - 1:
out[i] = window_sum / period
return out
def ema(values: Sequence[float], period: int) -> list[Number]:
if period <= 0:
raise ValueError("period must be positive")
out: list[Number] = [None] * len(values)
if len(values) < period:
return out
multiplier = 2 / (period + 1)
seed = sum(values[:period]) / period
out[period - 1] = seed
prev = seed
for i in range(period, len(values)):
prev = (values[i] - prev) * multiplier + prev
out[i] = prev
return out
def rsi(values: Sequence[float], period: int = 14) -> list[Number]:
if period <= 0:
raise ValueError("period must be positive")
out: list[Number] = [None] * len(values)
if len(values) <= period:
return out
gains = 0.0
losses = 0.0
for i in range(1, period + 1):
change = values[i] - values[i - 1]
gains += max(change, 0.0)
losses += max(-change, 0.0)
avg_gain = gains / period
avg_loss = losses / period
out[period] = _rsi_value(avg_gain, avg_loss)
for i in range(period + 1, len(values)):
change = values[i] - values[i - 1]
gain = max(change, 0.0)
loss = max(-change, 0.0)
avg_gain = (avg_gain * (period - 1) + gain) / period
avg_loss = (avg_loss * (period - 1) + loss) / period
out[i] = _rsi_value(avg_gain, avg_loss)
return out
def _rsi_value(avg_gain: float, avg_loss: float) -> float:
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss
return 100 - (100 / (1 + rs))
def true_range(candles: Sequence[Candle]) -> list[float]:
out: list[float] = []
for i, candle in enumerate(candles):
if i == 0:
out.append(candle.high - candle.low)
continue
prev_close = candles[i - 1].close
out.append(
max(
candle.high - candle.low,
abs(candle.high - prev_close),
abs(candle.low - prev_close),
)
)
return out
def atr(candles: Sequence[Candle], period: int = 14) -> list[Number]:
if period <= 0:
raise ValueError("period must be positive")
tr = true_range(candles)
out: list[Number] = [None] * len(tr)
if len(tr) < period:
return out
prev = sum(tr[:period]) / period
out[period - 1] = prev
for i in range(period, len(tr)):
prev = (prev * (period - 1) + tr[i]) / period
out[i] = prev
return out
def rolling_std(values: Sequence[float], period: int) -> list[Number]:
if period <= 0:
raise ValueError("period must be positive")
out: list[Number] = [None] * len(values)
for i in range(period - 1, len(values)):
window = values[i - period + 1 : i + 1]
mean = sum(window) / period
variance = sum((x - mean) ** 2 for x in window) / period
out[i] = variance**0.5
return out
def last(series: Sequence[Number]) -> Number:
for value in reversed(series):
if value is not None:
return value
return None
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
from dataclasses import dataclass
from app.models import PositionSide
# All functions here are pure and shared by the live engine and the backtester
# so both compute cash, P&L and risk exits identically.
@dataclass
class OpenPosition:
"""Lightweight position used by the backtester (the engine uses a DB row)."""
side: PositionSide
qty: float
entry_price: float
entry_fee: float = 0.0
stop_loss: float | None = None
take_profit: float | None = None
entry_ts: int = 0
def apply_open(cash: float, side: PositionSide, qty: float, price: float, fee: float) -> float:
"""Cash after opening a position (buying long / selling short)."""
notional = qty * price
if side == PositionSide.LONG:
return cash - notional - fee
return cash + notional - fee
def apply_close(cash: float, side: PositionSide, qty: float, price: float, fee: float) -> float:
"""Cash after closing a position (selling long / buying back short)."""
notional = qty * price
if side == PositionSide.LONG:
return cash + notional - fee
return cash - notional - fee
def realized_pnl(
side: PositionSide,
entry_price: float,
exit_price: float,
qty: float,
entry_fee: float,
exit_fee: float,
) -> float:
gross = (
(exit_price - entry_price) * qty
if side == PositionSide.LONG
else (entry_price - exit_price) * qty
)
return gross - entry_fee - exit_fee
def unrealized_pnl(side: PositionSide, entry_price: float, mark: float, qty: float) -> float:
if side == PositionSide.LONG:
return (mark - entry_price) * qty
return (entry_price - mark) * qty
def position_value(side: PositionSide, qty: float, mark: float) -> float:
"""Mark-to-market value of the position as an asset (negative for shorts)."""
return qty * mark if side == PositionSide.LONG else -qty * mark
def account_equity(cash: float, side: PositionSide, qty: float, mark: float) -> float:
return cash + position_value(side, qty, mark)
def order_qty(equity: float, size: float, side: PositionSide, cash: float, price: float) -> float:
"""Position size as a fraction of equity, capped by available cash for longs."""
if price <= 0:
return 0.0
size = max(0.0, min(size, 1.0))
notional = equity * size
if side == PositionSide.LONG:
notional = min(notional, cash * 0.99)
return notional / price
def risk_exit(
side: PositionSide,
stop_loss: float | None,
take_profit: float | None,
high: float,
low: float,
) -> tuple[str, float] | None:
"""Return (reason, exit_price) if a stop/take level was hit within the bar."""
if side == PositionSide.LONG:
if stop_loss is not None and low <= stop_loss:
return "stop loss", stop_loss
if take_profit is not None and high >= take_profit:
return "take profit", take_profit
else:
if stop_loss is not None and high >= stop_loss:
return "stop loss", stop_loss
if take_profit is not None and low <= take_profit:
return "take profit", take_profit
return None
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
import importlib
import pkgutil
from app.engine.strategies.base import (
Strategy,
StrategyContext,
get_strategy,
list_strategies,
param_specs,
register,
strategy_spec,
)
def discover() -> None:
"""Import every module in this package so its strategies self-register."""
for module_info in pkgutil.iter_modules(__path__):
if module_info.name.startswith("_") or module_info.name in {"base"}:
continue
importlib.import_module(f"{__name__}.{module_info.name}")
discover()
__all__ = [
"Strategy",
"StrategyContext",
"get_strategy",
"list_strategies",
"param_specs",
"register",
"strategy_spec",
]
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, ClassVar, Literal, Protocol
from app.engine.indicators import atr, ema, rolling_std, rsi, sma
from app.models import PositionSide
from app.schemas import Candle, ParamSpec, StrategyOut
class PositionView(Protocol):
"""Minimal position shape a strategy can see (DB row or backtest position)."""
side: PositionSide
qty: float
entry_price: float
@dataclass(slots=True)
class Intent:
"""An order request emitted by a strategy. Executed by the engine."""
action: Literal["enter_long", "enter_short", "close"]
size: float | None = None
stop_loss: float | None = None
take_profit: float | None = None
reason: str = ""
class StrategyContext:
"""Read-only market/portfolio view plus order intent helpers.
A fresh context is created for every candle. Strategy code stays synchronous
and side-effect free: it calls ``enter_long`` / ``enter_short`` / ``close``
and the engine executes the resulting intent after ``on_candle`` returns.
"""
def __init__(
self,
candles: Sequence[Candle],
position: PositionView | None,
equity: float,
cash: float,
) -> None:
self._candles = list(candles)
self._position = position
self._equity = equity
self._cash = cash
self._intents: list[Intent] = []
# --- market state -------------------------------------------------
@property
def candles(self) -> list[Candle]:
return self._candles
@property
def price(self) -> float:
return self._candles[-1].close
@property
def closes(self) -> list[float]:
return [c.close for c in self._candles]
def high(self, lookback: int) -> float:
return max(c.high for c in self._candles[-lookback:])
def low(self, lookback: int) -> float:
return min(c.low for c in self._candles[-lookback:])
# --- portfolio state ----------------------------------------------
@property
def position(self) -> PositionView | None:
return self._position
@property
def is_flat(self) -> bool:
return self._position is None
@property
def side(self) -> PositionSide | None:
return self._position.side if self._position else None
@property
def equity(self) -> float:
return self._equity
@property
def cash(self) -> float:
return self._cash
# --- indicators ----------------------------------------------------
def sma(self, period: int) -> float | None:
return sma(self.closes, period)[-1]
def ema(self, period: int) -> float | None:
return ema(self.closes, period)[-1]
def rsi(self, period: int = 14) -> float | None:
return rsi(self.closes, period)[-1]
def atr(self, period: int = 14) -> float | None:
return atr(self._candles, period)[-1]
def std(self, period: int = 20) -> float | None:
return rolling_std(self.closes, period)[-1]
# --- order intents ------------------------------------------------
def enter_long(
self,
size: float | None = None,
stop_loss: float | None = None,
take_profit: float | None = None,
reason: str = "",
) -> None:
self._intents.append(Intent("enter_long", size, stop_loss, take_profit, reason))
def enter_short(
self,
size: float | None = None,
stop_loss: float | None = None,
take_profit: float | None = None,
reason: str = "",
) -> None:
self._intents.append(Intent("enter_short", size, stop_loss, take_profit, reason))
def close(self, reason: str = "") -> None:
self._intents.append(Intent("close", reason=reason))
def take_intents(self) -> list[Intent]:
intents, self._intents = self._intents, []
return intents
class Strategy(ABC):
"""Base class for trading strategies.
Subclass it, set ``key``/``name``/``default_params`` and implement
``on_candle``. Save the file under ``app/engine/strategies/`` and it is
auto-discovered.
"""
key: ClassVar[str] = ""
name: ClassVar[str] = ""
description: ClassVar[str] = ""
default_params: ClassVar[dict[str, Any]] = {}
param_help: ClassVar[dict[str, str]] = {}
param_bounds: ClassVar[dict[str, tuple[float, float]]] = {}
def __init__(self, params: dict[str, Any] | None = None) -> None:
unknown = set(params or {}) - set(self.default_params)
if unknown:
raise ValueError(f"unknown params for {self.key}: {sorted(unknown)}")
self.params: dict[str, Any] = {**self.default_params, **(params or {})}
@abstractmethod
def on_candle(self, ctx: StrategyContext) -> None:
raise NotImplementedError
def _infer_type(value: Any) -> Literal["int", "float", "bool", "str"]:
if isinstance(value, bool):
return "bool"
if isinstance(value, int):
return "int"
if isinstance(value, float):
return "float"
return "str"
def param_specs(strategy_cls: type[Strategy]) -> list[ParamSpec]:
specs: list[ParamSpec] = []
for name, default in strategy_cls.default_params.items():
bounds = strategy_cls.param_bounds.get(name)
specs.append(
ParamSpec(
name=name,
type=_infer_type(default),
default=default,
description=strategy_cls.param_help.get(name, ""),
min=bounds[0] if bounds else None,
max=bounds[1] if bounds else None,
)
)
return specs
_REGISTRY: dict[str, type[Strategy]] = {}
def register(strategy_cls: type[Strategy]) -> type[Strategy]:
"""Class decorator that makes a strategy discoverable by key."""
if not strategy_cls.key:
raise ValueError(f"{strategy_cls.__name__} must define a non-empty 'key'")
_REGISTRY[strategy_cls.key] = strategy_cls
return strategy_cls
def get_strategy(key: str) -> type[Strategy]:
try:
return _REGISTRY[key]
except KeyError:
raise KeyError(f"unknown strategy '{key}'. available: {sorted(_REGISTRY)}") from None
def list_strategies() -> list[type[Strategy]]:
return sorted(_REGISTRY.values(), key=lambda cls: cls.name or cls.key)
def strategy_spec(strategy_cls: type[Strategy]) -> StrategyOut:
return StrategyOut(
key=strategy_cls.key,
name=strategy_cls.name or strategy_cls.key,
description=strategy_cls.description,
params=param_specs(strategy_cls),
)
@@ -0,0 +1,65 @@
from __future__ import annotations
from typing import ClassVar
from app.engine.strategies.base import Strategy, StrategyContext, register
from app.models import PositionSide
@register
class RsiReversion(Strategy):
"""Buy oversold RSI, exit on overbought, with an ATR-based stop."""
key: ClassVar[str] = "rsi_reversion"
name: ClassVar[str] = "RSI Mean Reversion"
description: ClassVar[str] = (
"Enter long when RSI drops below the oversold level and exit when it rises "
"above the overbought level. Places an ATR-based stop loss and take profit."
)
default_params: ClassVar[dict] = {
"period": 14,
"oversold": 30.0,
"overbought": 70.0,
"atr_period": 14,
"atr_stop_mult": 2.0,
"atr_target_mult": 3.0,
"size": 1.0,
}
param_help: ClassVar[dict] = {
"period": "RSI lookback period",
"oversold": "RSI level below which to buy",
"overbought": "RSI level above which to exit",
"atr_period": "ATR lookback for stop sizing",
"atr_stop_mult": "Stop distance as a multiple of ATR",
"atr_target_mult": "Take-profit distance as a multiple of ATR",
"size": "Fraction of equity to allocate per trade",
}
param_bounds: ClassVar[dict] = {
"period": (2, 100),
"oversold": (1, 50),
"overbought": (50, 99),
"atr_stop_mult": (0.5, 10.0),
"atr_target_mult": (0.5, 20.0),
"size": (0.01, 1.0),
}
def on_candle(self, ctx: StrategyContext) -> None:
period = int(self.params["period"])
current = ctx.rsi(period)
if current is None:
return
if ctx.is_flat and current < float(self.params["oversold"]):
stop = target = None
atr_value = ctx.atr(int(self.params["atr_period"]))
if atr_value:
stop = ctx.price - float(self.params["atr_stop_mult"]) * atr_value
target = ctx.price + float(self.params["atr_target_mult"]) * atr_value
ctx.enter_long(
size=float(self.params["size"]),
stop_loss=stop,
take_profit=target,
reason=f"RSI {current:.1f} below oversold",
)
elif ctx.side == PositionSide.LONG and current > float(self.params["overbought"]):
ctx.close(reason=f"RSI {current:.1f} above overbought")
@@ -0,0 +1,48 @@
from __future__ import annotations
from typing import ClassVar
from app.engine.indicators import sma
from app.engine.strategies.base import Strategy, StrategyContext, register
from app.models import PositionSide
@register
class SmaCross(Strategy):
"""Classic moving-average crossover, long-only."""
key: ClassVar[str] = "sma_cross"
name: ClassVar[str] = "SMA Crossover"
description: ClassVar[str] = (
"Open a long position when the fast SMA crosses above the slow SMA, "
"close it when the fast SMA crosses back below."
)
default_params: ClassVar[dict] = {"fast": 10, "slow": 30, "size": 1.0}
param_help: ClassVar[dict] = {
"fast": "Fast SMA period",
"slow": "Slow SMA period",
"size": "Fraction of equity to allocate per trade",
}
param_bounds: ClassVar[dict] = {"fast": (2, 200), "slow": (3, 400), "size": (0.01, 1.0)}
def on_candle(self, ctx: StrategyContext) -> None:
fast_period = int(self.params["fast"])
slow_period = int(self.params["slow"])
closes = ctx.closes
if len(closes) < slow_period + 2:
return
fast = sma(closes, fast_period)
slow = sma(closes, slow_period)
f_prev, f_now = fast[-2], fast[-1]
s_prev, s_now = slow[-2], slow[-1]
if None in (f_prev, f_now, s_prev, s_now):
return
crossed_up = f_prev <= s_prev and f_now > s_now
crossed_down = f_prev >= s_prev and f_now < s_now
if ctx.is_flat and crossed_up:
ctx.enter_long(size=float(self.params["size"]), reason="fast SMA crossed above slow")
elif ctx.side == PositionSide.LONG and crossed_down:
ctx.close(reason="fast SMA crossed below slow")
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import router
from app.config import Settings, get_settings
from app.db import init_db
from app.runtime import Runtime
def create_app(
settings: Settings | None = None,
runtime: Runtime | None = None,
) -> FastAPI:
settings = settings or get_settings()
runtime = runtime or Runtime(settings)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
init_db()
app.state.runtime = runtime
yield
await runtime.shutdown()
app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router)
@app.get("/")
def root() -> dict[str, str]:
return {"name": settings.app_name, "docs": "/docs"}
return app
app = create_app()
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from app.config import Settings
from app.market.base import MarketData
from app.market.binance import BinanceMarketData
from app.market.fake import FakeMarketData
def build_market_data(settings: Settings) -> MarketData:
exchange = settings.exchange.lower()
if exchange in {"binance", "binanceus"}:
return BinanceMarketData()
raise ValueError(f"unsupported exchange: {settings.exchange}")
__all__ = ["FakeMarketData", "MarketData", "build_market_data"]
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from app.schemas import Candle
class MarketData(ABC):
"""Read-only market data source (candles + last price)."""
@abstractmethod
async def fetch_candles(self, symbol: str, timeframe: str, limit: int) -> list[Candle]:
raise NotImplementedError
@abstractmethod
async def fetch_price(self, symbol: str) -> float:
raise NotImplementedError
async def close(self) -> None:
return None
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import httpx
from app.market.base import MarketData
from app.schemas import Candle
BASE_URL = "https://api.binance.com"
def to_exchange_symbol(symbol: str) -> str:
return symbol.replace("/", "").upper()
class BinanceMarketData(MarketData):
"""Public Binance spot market data over REST (no API key required)."""
def __init__(self, base_url: str = BASE_URL, timeout: float = 10.0) -> None:
self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout)
async def fetch_candles(self, symbol: str, timeframe: str, limit: int) -> list[Candle]:
response = await self._client.get(
"/api/v3/klines",
params={
"symbol": to_exchange_symbol(symbol),
"interval": timeframe,
"limit": limit,
},
)
response.raise_for_status()
return [
Candle(
ts=int(row[0]),
open=float(row[1]),
high=float(row[2]),
low=float(row[3]),
close=float(row[4]),
volume=float(row[5]),
)
for row in response.json()
]
async def fetch_price(self, symbol: str) -> float:
response = await self._client.get(
"/api/v3/ticker/price",
params={"symbol": to_exchange_symbol(symbol)},
)
response.raise_for_status()
return float(response.json()["price"])
async def close(self) -> None:
await self._client.aclose()
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import math
from collections.abc import Sequence
from app.market.base import MarketData
from app.schemas import Candle
class FakeMarketData(MarketData):
"""In-memory market data for tests and offline demos."""
def __init__(self, candles: Sequence[Candle] | None = None) -> None:
self.candles: list[Candle] = list(candles or [])
@classmethod
def from_closes(cls, closes: Sequence[float]) -> FakeMarketData:
candles = [
Candle(
ts=1_700_000_000_000 + i * 60_000,
open=close,
high=close,
low=close,
close=close,
volume=1.0,
)
for i, close in enumerate(closes)
]
return cls(candles)
@classmethod
def sine_wave(cls, n: int = 200, amplitude: float = 100.0, period: int = 20) -> FakeMarketData:
base = 1000.0
closes = [base + amplitude * math.sin(2 * math.pi * i / period) for i in range(n)]
return cls.from_closes(closes)
def append(self, candle: Candle) -> None:
self.candles.append(candle)
async def fetch_candles(self, symbol: str, timeframe: str, limit: int) -> list[Candle]:
return self.candles[-limit:]
async def fetch_price(self, symbol: str) -> float:
if not self.candles:
raise ValueError("no candles available")
return self.candles[-1].close
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any
from sqlalchemy import (
JSON,
DateTime,
Float,
ForeignKey,
Integer,
String,
)
from sqlalchemy import (
Enum as SAEnum,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
def utcnow() -> datetime:
return datetime.now(UTC)
class Base(DeclarativeBase):
pass
class PositionSide(StrEnum):
LONG = "long"
SHORT = "short"
class PositionStatus(StrEnum):
OPEN = "open"
CLOSED = "closed"
class OrderSide(StrEnum):
BUY = "buy"
SELL = "sell"
class StrategyConfig(Base):
__tablename__ = "strategy_configs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
strategy_key: Mapped[str] = mapped_column(String(64), index=True)
symbol: Mapped[str] = mapped_column(String(32))
timeframe: Mapped[str] = mapped_column(String(16))
params: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class Position(Base):
__tablename__ = "positions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
strategy_key: Mapped[str] = mapped_column(String(64), index=True)
symbol: Mapped[str] = mapped_column(String(32), index=True)
side: Mapped[PositionSide] = mapped_column(SAEnum(PositionSide))
qty: Mapped[float] = mapped_column(Float)
entry_price: Mapped[float] = mapped_column(Float)
status: Mapped[PositionStatus] = mapped_column(
SAEnum(PositionStatus), default=PositionStatus.OPEN, index=True
)
stop_loss: Mapped[float | None] = mapped_column(Float, nullable=True)
take_profit: Mapped[float | None] = mapped_column(Float, nullable=True)
entry_fee: Mapped[float] = mapped_column(Float, default=0.0)
exit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
exit_fee: Mapped[float] = mapped_column(Float, default=0.0)
realized_pnl: Mapped[float | None] = mapped_column(Float, nullable=True)
opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
orders: Mapped[list[Order]] = relationship(back_populates="position")
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
position_id: Mapped[int | None] = mapped_column(
ForeignKey("positions.id"), nullable=True, index=True
)
strategy_key: Mapped[str] = mapped_column(String(64), index=True)
symbol: Mapped[str] = mapped_column(String(32))
side: Mapped[OrderSide] = mapped_column(SAEnum(OrderSide))
qty: Mapped[float] = mapped_column(Float)
price: Mapped[float] = mapped_column(Float)
fee: Mapped[float] = mapped_column(Float, default=0.0)
reason: Mapped[str] = mapped_column(String(255), default="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, index=True
)
position: Mapped[Position | None] = relationship(back_populates="orders")
class EquitySnapshot(Base):
__tablename__ = "equity_snapshots"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
equity: Mapped[float] = mapped_column(Float)
cash: Mapped[float] = mapped_column(Float)
price: Mapped[float] = mapped_column(Float)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, index=True
)
class Event(Base):
__tablename__ = "events"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
level: Mapped[str] = mapped_column(String(16), default="info")
message: Mapped[str] = mapped_column(String(512))
strategy_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, index=True
)
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from contextlib import suppress
from typing import Any
from app.backtest import Backtester
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.strategies import get_strategy
from app.market import build_market_data
from app.market.base import MarketData
from app.schemas import BacktestRequest, BacktestResult, StatusOut
from app.store import Store
FeedFactory = Callable[[Settings], MarketData]
BrokerFactory = Callable[[Settings], Broker]
class Runtime:
"""Owns the active engine and fans out live updates to WebSocket clients."""
def __init__(
self,
settings: Settings,
store: Store | None = None,
*,
feed_factory: FeedFactory = build_market_data,
broker_factory: BrokerFactory = build_broker,
) -> None:
self.settings = settings
self.store = store or Store()
self._feed_factory = feed_factory
self._broker_factory = broker_factory
self.engine: TradingEngine | None = None
self._subscribers: set[asyncio.Queue] = set()
# --- pub/sub ------------------------------------------------------
def subscribe(self) -> asyncio.Queue:
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
self._subscribers.add(queue)
return queue
def unsubscribe(self, queue: asyncio.Queue) -> None:
self._subscribers.discard(queue)
def _broadcast(self, payload: dict[str, Any]) -> None:
for queue in list(self._subscribers):
if queue.full():
with suppress(asyncio.QueueEmpty):
queue.get_nowait()
with suppress(asyncio.QueueFull):
queue.put_nowait(payload)
# --- lifecycle ----------------------------------------------------
async def start(
self,
strategy_key: str,
params: dict[str, Any] | None = None,
*,
symbol: str | None = None,
timeframe: str | None = None,
starting_balance: float | None = None,
) -> StatusOut:
if self.engine is not None and self.engine.running:
await self.stop()
engine = TradingEngine(
feed=self._feed_factory(self.settings),
broker=self._broker_factory(self.settings),
store=self.store,
settings=self.settings,
)
engine.set_publisher(self._broadcast)
self.engine = engine
await engine.start(
strategy_key,
params,
symbol=symbol,
timeframe=timeframe,
starting_balance=starting_balance,
)
return engine.status()
async def stop(self) -> StatusOut:
if self.engine is not None:
await self.engine.stop()
return self.engine.status()
return self._idle_status()
async def run_backtest(self, request: BacktestRequest) -> BacktestResult:
symbol = request.symbol or self.settings.symbol
timeframe = request.timeframe or self.settings.timeframe
strategy_cls = get_strategy(request.strategy_key)
strategy = strategy_cls(request.params)
feed = self._feed_factory(self.settings)
try:
candles = await feed.fetch_candles(symbol, timeframe, request.limit)
finally:
await feed.close()
backtester = Backtester(
fee_bps=self.settings.fee_bps,
slippage_bps=self.settings.slippage_bps,
starting_balance=request.starting_balance or self.settings.starting_balance,
)
return backtester.run(candles, strategy, symbol, timeframe)
async def shutdown(self) -> None:
if self.engine is not None:
await self.engine.shutdown()
def status(self) -> StatusOut:
if self.engine is not None:
return self.engine.status()
return self._idle_status()
def _idle_status(self) -> StatusOut:
return StatusOut(
running=False,
mode=self.settings.mode,
exchange=self.settings.exchange,
symbol=self.settings.symbol,
timeframe=self.settings.timeframe,
strategy_key=None,
strategy_name=None,
params={},
price=None,
cash=self.settings.starting_balance,
equity=self.settings.starting_balance,
starting_balance=self.settings.starting_balance,
realized_pnl=0.0,
unrealized_pnl=0.0,
total_pnl=0.0,
open_positions=0,
last_tick=None,
)
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class Candle(BaseModel):
ts: int
open: float
high: float
low: float
close: float
volume: float
class ParamSpec(BaseModel):
name: str
type: Literal["int", "float", "bool", "str"]
default: Any
description: str = ""
min: float | None = None
max: float | None = None
class StrategyOut(BaseModel):
key: str
name: str
description: str
params: list[ParamSpec]
class PositionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
strategy_key: str
symbol: str
side: str
qty: float
entry_price: float
status: str
stop_loss: float | None = None
take_profit: float | None = None
exit_price: float | None = None
realized_pnl: float | None = None
unrealized_pnl: float | None = None
opened_at: datetime
closed_at: datetime | None = None
class OrderOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
position_id: int | None
strategy_key: str
symbol: str
side: str
qty: float
price: float
fee: float
reason: str
created_at: datetime
class EventOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
level: str
message: str
strategy_key: str | None
created_at: datetime
class EquityPointOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
equity: float
cash: float
price: float
created_at: datetime
class StatusOut(BaseModel):
running: bool
mode: str
exchange: str
symbol: str
timeframe: str
strategy_key: str | None
strategy_name: str | None
params: dict[str, Any] = Field(default_factory=dict)
price: float | None
cash: float
equity: float
starting_balance: float
realized_pnl: float
unrealized_pnl: float
total_pnl: float
open_positions: int
last_tick: datetime | None
error: str | None = None
class StartRequest(BaseModel):
strategy_key: str
symbol: str | None = None
timeframe: str | None = None
params: dict[str, Any] = Field(default_factory=dict)
starting_balance: float | None = None
class ManualOrderRequest(BaseModel):
side: Literal["buy", "sell"]
qty: float | None = None
quote_amount: float | None = None
reason: str = "manual"
class ClosePositionRequest(BaseModel):
reason: str = "manual"
class BacktestRequest(BaseModel):
strategy_key: str
params: dict[str, Any] = Field(default_factory=dict)
symbol: str | None = None
timeframe: str | None = None
limit: int = Field(default=500, ge=20, le=1000)
starting_balance: float | None = None
class BacktestTradeOut(BaseModel):
side: str
entry_ts: int
exit_ts: int
entry_price: float
exit_price: float
qty: float
pnl: float
return_pct: float
reason: str
class BacktestPointOut(BaseModel):
ts: int
equity: float
buy_hold: float
price: float
class BacktestMetricsOut(BaseModel):
initial_equity: float
final_equity: float
total_return_pct: float
buy_hold_return_pct: float
max_drawdown_pct: float
num_trades: int
win_rate_pct: float
profit_factor: float | None
avg_trade_pct: float
best_trade_pct: float
worst_trade_pct: float
sharpe: float | None
class BacktestResult(BaseModel):
strategy_key: str
strategy_name: str
symbol: str
timeframe: str
starting_balance: float
metrics: BacktestMetricsOut
equity_curve: list[BacktestPointOut]
trades: list[BacktestTradeOut]
error: str | None = None
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from app.db import SessionLocal
from app.models import (
EquitySnapshot,
Event,
Order,
OrderSide,
Position,
PositionSide,
PositionStatus,
StrategyConfig,
)
class Store:
"""Thin persistence layer around the trading tables."""
def __init__(self, session_factory: sessionmaker = SessionLocal) -> None:
self._session_factory: sessionmaker = session_factory
def create_position(
self,
*,
strategy_key: str,
symbol: str,
side: PositionSide,
qty: float,
entry_price: float,
entry_fee: float,
stop_loss: float | None = None,
take_profit: float | None = None,
) -> Position:
with self._session_factory() as session:
position = Position(
strategy_key=strategy_key,
symbol=symbol,
side=side,
qty=qty,
entry_price=entry_price,
entry_fee=entry_fee,
stop_loss=stop_loss,
take_profit=take_profit,
status=PositionStatus.OPEN,
)
session.add(position)
session.commit()
return position
def close_position(
self, position: Position, *, exit_price: float, exit_fee: float, realized_pnl: float
) -> Position:
from app.models import utcnow
with self._session_factory() as session:
merged = session.merge(position)
merged.status = PositionStatus.CLOSED
merged.exit_price = exit_price
merged.exit_fee = exit_fee
merged.realized_pnl = realized_pnl
merged.closed_at = utcnow()
session.commit()
return merged
def abandon_open_positions(self, strategy_key: str, reason: str = "reset") -> int:
"""Mark lingering open positions closed at entry price (used on restart)."""
from app.models import utcnow
with self._session_factory() as session:
positions = list(
session.scalars(
select(Position).where(
Position.strategy_key == strategy_key,
Position.status == PositionStatus.OPEN,
)
).all()
)
for position in positions:
position.status = PositionStatus.CLOSED
position.exit_price = position.entry_price
position.exit_fee = 0.0
position.realized_pnl = -position.entry_fee
position.closed_at = utcnow()
session.add_all(positions)
session.commit()
return len(positions)
def open_position_for(self, strategy_key: str) -> Position | None:
with self._session_factory() as session:
return session.scalars(
select(Position)
.where(
Position.strategy_key == strategy_key,
Position.status == PositionStatus.OPEN,
)
.order_by(Position.id.desc())
).first()
def record_order(
self,
*,
position_id: int | None,
strategy_key: str,
symbol: str,
side: OrderSide,
qty: float,
price: float,
fee: float,
reason: str,
) -> Order:
with self._session_factory() as session:
order = Order(
position_id=position_id,
strategy_key=strategy_key,
symbol=symbol,
side=side,
qty=qty,
price=price,
fee=fee,
reason=reason,
)
session.add(order)
session.commit()
return order
def record_event(self, level: str, message: str, strategy_key: str | None = None) -> Event:
with self._session_factory() as session:
event = Event(level=level, message=message, strategy_key=strategy_key)
session.add(event)
session.commit()
return event
def record_equity(self, equity: float, cash: float, price: float) -> None:
with self._session_factory() as session:
session.add(EquitySnapshot(equity=equity, cash=cash, price=price))
session.commit()
def list_positions(self, limit: int = 50) -> list[Position]:
with self._session_factory() as session:
return list(
session.scalars(select(Position).order_by(Position.id.desc()).limit(limit)).all()
)
def list_orders(self, limit: int = 50) -> list[Order]:
with self._session_factory() as session:
return list(session.scalars(select(Order).order_by(Order.id.desc()).limit(limit)).all())
def list_events(self, limit: int = 50) -> list[Event]:
with self._session_factory() as session:
return list(session.scalars(select(Event).order_by(Event.id.desc()).limit(limit)).all())
def list_equity(self, limit: int = 500) -> list[EquitySnapshot]:
with self._session_factory() as session:
rows = list(
session.scalars(
select(EquitySnapshot).order_by(EquitySnapshot.id.desc()).limit(limit)
).all()
)
return list(reversed(rows))
def save_strategy_config(
self, strategy_key: str, symbol: str, timeframe: str, params: dict
) -> StrategyConfig:
with self._session_factory() as session:
config = StrategyConfig(
strategy_key=strategy_key, symbol=symbol, timeframe=timeframe, params=params
)
session.add(config)
session.commit()
return config
+4
View File
@@ -0,0 +1,4 @@
[pytest]
asyncio_mode = auto
testpaths = tests
addopts = -q
+5
View File
@@ -0,0 +1,5 @@
-r requirements.txt
pytest>=8.3
pytest-asyncio>=0.25
anyio>=4.7
ruff>=0.8
+11
View File
@@ -0,0 +1,11 @@
# Core web stack
fastapi>=0.115
uvicorn>=0.34
pydantic>=2.9
pydantic-settings>=2.7
# Persistence (sync SQLAlchemy; SQLite by default, Postgres optional)
SQLAlchemy>=2.0.36
# HTTP client used for market data + live exchange REST calls
httpx>=0.28
# WebSocket protocol support for uvicorn / starlette
websockets>=14.0
+9
View File
@@ -0,0 +1,9 @@
line-length = 100
target-version = "py311"
[lint]
select = ["E", "F", "I", "UP", "B", "C4", "SIM"]
ignore = ["B008"]
[format]
quote-style = "double"
+63
View File
@@ -0,0 +1,63 @@
# ruff: noqa: E402, I001
from __future__ import annotations
import os
os.environ["DATABASE_URL"] = "sqlite:///./data/test_trader.db"
os.environ["FEE_BPS"] = "0"
os.environ["SLIPPAGE_BPS"] = "0"
os.environ["POLL_INTERVAL"] = "3600"
os.environ["STARTING_BALANCE"] = "10000"
from collections.abc import Iterator # noqa: E402
import pytest # noqa: E402
from app.broker.paper import PaperBroker # noqa: E402
from app.config import Settings # noqa: E402
from app.market.fake import FakeMarketData # noqa: E402
from app.models import Base # noqa: E402
from app.db import engine as db_engine # noqa: E402
from app.runtime import Runtime # noqa: E402
from app.store import Store # noqa: E402
@pytest.fixture
def settings() -> Settings:
return Settings(
mode="paper",
symbol="BTC/USDT",
timeframe="1m",
poll_interval=3600,
starting_balance=10_000,
fee_bps=0,
slippage_bps=0,
)
@pytest.fixture(autouse=True)
def clean_db() -> Iterator[None]:
Base.metadata.drop_all(bind=db_engine)
Base.metadata.create_all(bind=db_engine)
yield
Base.metadata.drop_all(bind=db_engine)
@pytest.fixture
def store() -> Store:
return Store()
@pytest.fixture
def market() -> FakeMarketData:
return FakeMarketData.from_closes([100.0, 101.0, 102.0])
@pytest.fixture
def runtime(settings: Settings, store: Store, market: FakeMarketData) -> Runtime:
return Runtime(
settings,
store,
feed_factory=lambda _settings: market,
broker_factory=lambda _settings: PaperBroker(fee_bps=0, slippage_bps=0),
)
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from app.main import create_app
from app.market.fake import FakeMarketData
from app.runtime import Runtime
def test_health_and_strategies(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
assert client.get("/api/health").json() == {"status": "ok"}
keys = {s["key"] for s in client.get("/api/strategies").json()}
assert {"sma_cross", "rsi_reversion"} <= keys
def test_start_status_stop_flow(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
status = client.get("/api/status").json()
assert status["running"] is False
started = client.post(
"/api/bot/start",
json={"strategy_key": "sma_cross", "params": {"fast": 2, "slow": 4}},
)
assert started.status_code == 200
assert started.json()["running"] is True
assert client.get("/api/positions").status_code == 200
assert client.get("/api/orders").status_code == 200
assert client.get("/api/events").status_code == 200
stopped = client.post("/api/bot/stop").json()
assert stopped["running"] is False
def test_unknown_strategy_returns_404(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
response = client.post("/api/bot/start", json={"strategy_key": "does_not_exist"})
assert response.status_code == 404
def test_backtest_endpoint(runtime: Runtime, market: FakeMarketData) -> None:
market.candles = FakeMarketData.sine_wave(200).candles
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
response = client.post(
"/api/backtest",
json={"strategy_key": "sma_cross", "params": {"fast": 5, "slow": 20}, "limit": 200},
)
assert response.status_code == 200
data = response.json()
assert data["strategy_key"] == "sma_cross"
assert data["error"] is None
assert len(data["equity_curve"]) == 200
assert "total_return_pct" in data["metrics"]
assert data["metrics"]["num_trades"] >= 1
def test_backtest_unknown_strategy_404(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
response = client.post("/api/backtest", json={"strategy_key": "nope"})
assert response.status_code == 404
def test_backtest_limit_validation(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
assert (
client.post("/api/backtest", json={"strategy_key": "sma_cross", "limit": 5}).status_code
== 422
)
def test_websocket_sends_initial_status(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client, client.websocket_connect("/api/ws") as websocket:
message = websocket.receive_json()
assert message["type"] == "status"
assert message["data"]["running"] is False
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
from typing import ClassVar
import pytest
from app.backtest import Backtester
from app.backtest.metrics import max_drawdown_pct, sharpe_ratio
from app.engine.strategies.base import Strategy, StrategyContext
from app.schemas import BacktestPointOut, BacktestTradeOut, Candle
class HoldStrategy(Strategy):
"""Enter long on the first candle and stay in (closed at end of data)."""
key: ClassVar[str] = "test_hold"
name: ClassVar[str] = "Test Hold"
default_params: ClassVar[dict] = {}
def on_candle(self, ctx: StrategyContext) -> None:
if ctx.is_flat:
ctx.enter_long(size=1.0)
class NeverStrategy(Strategy):
key: ClassVar[str] = "test_never"
name: ClassVar[str] = "Test Never"
default_params: ClassVar[dict] = {}
def on_candle(self, ctx: StrategyContext) -> None:
return None
class StopStrategy(Strategy):
key: ClassVar[str] = "test_stop"
name: ClassVar[str] = "Test Stop"
default_params: ClassVar[dict] = {}
def on_candle(self, ctx: StrategyContext) -> None:
if ctx.is_flat and not getattr(self, "_entered", False):
self._entered = True
ctx.enter_long(size=1.0, stop_loss=ctx.price * 0.9)
def candles_from_closes(closes: list[float]) -> list[Candle]:
return [Candle(ts=i, open=c, high=c, low=c, close=c, volume=1.0) for i, c in enumerate(closes)]
def test_hold_backtest_math() -> None:
backtester = Backtester(fee_bps=0, slippage_bps=0, starting_balance=10_000)
result = backtester.run(candles_from_closes([100, 110, 120]), HoldStrategy(), "BTC/USDT", "1h")
assert result.error is None
assert result.metrics.num_trades == 1
# 99 units bought at 100 (cash capped at 99%), closed at 120.
assert result.metrics.final_equity == pytest.approx(11_980.0)
assert result.trades[0].pnl == pytest.approx(1_980.0)
assert result.trades[0].return_pct == pytest.approx(20.0)
assert result.trades[0].reason == "end of data"
assert len(result.equity_curve) == 3
def test_no_trades_leaves_balance_untouched() -> None:
backtester = Backtester(fee_bps=10, slippage_bps=5, starting_balance=5_000)
result = backtester.run(
candles_from_closes([100, 101, 102, 103]), NeverStrategy(), "BTC/USDT", "1m"
)
assert result.metrics.num_trades == 0
assert result.metrics.final_equity == pytest.approx(5_000.0)
assert result.metrics.total_return_pct == pytest.approx(0.0)
assert result.metrics.max_drawdown_pct == pytest.approx(0.0)
def test_stop_loss_is_honoured() -> None:
backtester = Backtester(fee_bps=0, slippage_bps=0, starting_balance=10_000)
candles = candles_from_closes([100, 100, 80])
candles[2] = Candle(ts=2, open=100, high=100, low=80, close=80, volume=1.0)
result = backtester.run(candles, StopStrategy(), "BTC/USDT", "1h")
assert result.metrics.num_trades == 1
assert "stop loss" in result.trades[0].reason
assert result.metrics.final_equity < 10_000
def test_strategy_error_is_surfaced() -> None:
class Boom(Strategy):
key = "test_boom"
name = "Boom"
default_params: ClassVar[dict] = {}
def on_candle(self, ctx: StrategyContext) -> None:
raise RuntimeError("kaboom")
result = Backtester().run(candles_from_closes([1, 2, 3]), Boom(), "BTC/USDT", "1m")
assert result.error is not None and "kaboom" in result.error
def test_max_drawdown() -> None:
assert max_drawdown_pct([100, 120, 90, 130]) == pytest.approx(25.0)
assert max_drawdown_pct([100, 100, 100]) == pytest.approx(0.0)
def test_sharpe_none_for_unknown_timeframe() -> None:
assert sharpe_ratio([100, 101, 102, 103], "nonsense") is None
def _points(equities: list[float]) -> list[BacktestPointOut]:
return [BacktestPointOut(ts=i, equity=e, buy_hold=e, price=e) for i, e in enumerate(equities)]
def test_metrics_profit_factor_and_win_rate() -> None:
from app.backtest.metrics import compute_metrics
trades = [
BacktestTradeOut(
side="long",
entry_ts=0,
exit_ts=1,
entry_price=100,
exit_price=110,
qty=1,
pnl=10,
return_pct=10,
reason="x",
),
BacktestTradeOut(
side="long",
entry_ts=2,
exit_ts=3,
entry_price=100,
exit_price=95,
qty=1,
pnl=-5,
return_pct=-5,
reason="x",
),
]
metrics = compute_metrics(_points([10_000, 10_010, 10_005]), trades, 10_000, "1h")
assert metrics.win_rate_pct == pytest.approx(50.0)
assert metrics.profit_factor == pytest.approx(2.0)
assert metrics.best_trade_pct == pytest.approx(10.0)
assert metrics.worst_trade_pct == pytest.approx(-5.0)
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
from typing import ClassVar
import pytest
from app.broker.paper import PaperBroker
from app.config import Settings
from app.engine.engine import TradingEngine
from app.engine.strategies.base import Strategy, StrategyContext
from app.market.fake import FakeMarketData
from app.models import OrderSide, PositionSide
from app.schemas import Candle
from app.store import Store
class ToggleStrategy(Strategy):
key: ClassVar[str] = "test_toggle"
name: ClassVar[str] = "Toggle"
default_params: ClassVar[dict] = {}
def on_candle(self, ctx: StrategyContext) -> None:
if ctx.is_flat:
ctx.enter_long(size=0.5)
else:
ctx.close()
def build_engine(settings: Settings, store: Store, market: FakeMarketData) -> TradingEngine:
return TradingEngine(
feed=market,
broker=PaperBroker(fee_bps=0, slippage_bps=0),
store=store,
settings=settings,
)
async def test_manual_open_and_close_pnl(settings: Settings, store: Store) -> None:
market = FakeMarketData.from_closes([100.0])
engine = build_engine(settings, store, market)
engine.symbol = "BTC/USDT"
engine.last_price = 100.0
await engine.manual_order(OrderSide.BUY, qty=1.0, quote_amount=None, reason="test")
assert engine.position is not None
assert engine.cash == pytest.approx(9_900.0)
assert engine.equity == pytest.approx(10_000.0)
engine.last_price = 110.0
assert engine.equity == pytest.approx(10_010.0)
assert engine.unrealized_pnl == pytest.approx(10.0)
await engine.close_current_position()
assert engine.position is None
assert engine.realized_pnl == pytest.approx(10.0)
assert engine.cash == pytest.approx(10_010.0)
async def test_stop_loss_exits_at_level(settings: Settings, store: Store) -> None:
market = FakeMarketData.from_closes([100.0])
engine = build_engine(settings, store, market)
engine.symbol = "BTC/USDT"
engine.last_price = 100.0
await engine._open_position(
PositionSide.LONG,
size=0.0,
stop_loss=95.0,
take_profit=120.0,
reason="entry",
qty_override=1.0,
)
await engine._check_risk(Candle(ts=0, open=100, high=101, low=94, close=96, volume=1))
assert engine.position is None
assert engine.realized_pnl == pytest.approx(-5.0)
async def test_take_profit_exits_at_level(settings: Settings, store: Store) -> None:
market = FakeMarketData.from_closes([100.0])
engine = build_engine(settings, store, market)
engine.symbol = "BTC/USDT"
engine.last_price = 100.0
await engine._open_position(
PositionSide.LONG,
size=0.0,
stop_loss=95.0,
take_profit=110.0,
reason="entry",
qty_override=1.0,
)
await engine._check_risk(Candle(ts=0, open=100, high=111, low=100, close=109, volume=1))
assert engine.position is None
assert engine.realized_pnl == pytest.approx(10.0)
async def test_tick_runs_strategy_and_opens_position(settings: Settings, store: Store) -> None:
market = FakeMarketData.from_closes([100.0, 100.0])
engine = build_engine(settings, store, market)
engine.strategy = ToggleStrategy()
engine.strategy_key = "test_toggle"
engine.symbol = "BTC/USDT"
await engine.tick()
assert engine.position is not None
assert engine.position.side == PositionSide.LONG
market.append(Candle(ts=99, open=110, high=110, low=110, close=110, volume=1))
await engine.tick()
assert engine.position is None
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
import pytest
from app.engine.indicators import atr, ema, rolling_std, rsi, sma
from app.schemas import Candle
def candle(high: float, low: float, close: float, open_: float | None = None) -> Candle:
return Candle(
ts=0,
open=open_ if open_ is not None else close,
high=high,
low=low,
close=close,
volume=1,
)
def test_sma() -> None:
assert sma([1, 2, 3, 4, 5], 3) == [None, None, 2.0, 3.0, 4.0]
def test_ema() -> None:
assert ema([1, 2, 3, 4, 5], 3) == [None, None, 2.0, 3.0, 4.0]
def test_rsi_monotonic_is_100() -> None:
values = [float(i) for i in range(1, 20)]
assert rsi(values, 14)[-1] == pytest.approx(100.0)
def test_atr_flat_market_is_zero() -> None:
candles = [candle(100, 100, 100) for _ in range(20)]
assert atr(candles, 5)[-1] == pytest.approx(0.0)
def test_rolling_std() -> None:
result = rolling_std([1, 1, 1, 1], 2)
assert result[-1] == pytest.approx(0.0)
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
import pytest
from app.engine.portfolio import (
account_equity,
apply_close,
apply_open,
order_qty,
realized_pnl,
risk_exit,
unrealized_pnl,
)
from app.models import PositionSide
def test_apply_open_long_and_short() -> None:
assert apply_open(1000.0, PositionSide.LONG, 1.0, 100.0, 1.0) == pytest.approx(899.0)
assert apply_open(1000.0, PositionSide.SHORT, 1.0, 100.0, 1.0) == pytest.approx(1099.0)
def test_apply_close_long_and_short() -> None:
assert apply_close(100.0, PositionSide.LONG, 1.0, 120.0, 1.0) == pytest.approx(219.0)
assert apply_close(2100.0, PositionSide.SHORT, 1.0, 90.0, 1.0) == pytest.approx(2009.0)
def test_realized_pnl_includes_fees() -> None:
assert realized_pnl(PositionSide.LONG, 100, 110, 2, 1, 1) == pytest.approx(18.0)
assert realized_pnl(PositionSide.SHORT, 100, 90, 2, 1, 1) == pytest.approx(18.0)
assert realized_pnl(PositionSide.LONG, 100, 90, 2, 1, 1) == pytest.approx(-22.0)
def test_unrealized_pnl() -> None:
assert unrealized_pnl(PositionSide.LONG, 100, 110, 3) == pytest.approx(30.0)
assert unrealized_pnl(PositionSide.SHORT, 100, 110, 3) == pytest.approx(-30.0)
def test_account_equity_long_and_short() -> None:
assert account_equity(100, PositionSide.LONG, 2, 150) == pytest.approx(400.0)
assert account_equity(300, PositionSide.SHORT, 2, 80) == pytest.approx(140.0)
def test_order_qty_caps_longs_by_cash_and_scales_with_size() -> None:
assert order_qty(10_000, 1.0, PositionSide.LONG, 10_000, 100) == pytest.approx(99.0)
assert order_qty(10_000, 0.5, PositionSide.SHORT, 10_000, 100) == pytest.approx(50.0)
assert order_qty(10_000, 1.0, PositionSide.SHORT, 0, 100) == pytest.approx(100.0)
assert order_qty(10_000, 1.0, PositionSide.LONG, 10_000, 0) == 0.0
def test_risk_exit_long() -> None:
assert risk_exit(PositionSide.LONG, 95, 120, high=101, low=94) == ("stop loss", 95)
assert risk_exit(PositionSide.LONG, 95, 120, high=121, low=100) == ("take profit", 120)
assert risk_exit(PositionSide.LONG, 95, 120, high=110, low=100) is None
def test_risk_exit_short() -> None:
assert risk_exit(PositionSide.SHORT, 110, 90, high=111, low=100) == ("stop loss", 110)
assert risk_exit(PositionSide.SHORT, 110, 90, high=100, low=89) == ("take profit", 90)
assert risk_exit(PositionSide.SHORT, None, None, high=100, low=95) is None
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
from app.engine.strategies import get_strategy, list_strategies, strategy_spec
from app.engine.strategies.base import StrategyContext
from app.models import Position, PositionSide
from app.schemas import Candle
def make_candles(closes: list[float]) -> list[Candle]:
return [Candle(ts=i, open=c, high=c, low=c, close=c, volume=1.0) for i, c in enumerate(closes)]
def test_examples_are_registered() -> None:
keys = {cls.key for cls in list_strategies()}
assert {"sma_cross", "rsi_reversion"} <= keys
def test_param_specs_expose_defaults() -> None:
spec = strategy_spec(get_strategy("sma_cross"))
names = {param.name for param in spec.params}
assert {"fast", "slow", "size"} == names
fast = next(param for param in spec.params if param.name == "fast")
assert fast.default == 10
assert fast.type == "int"
def test_unknown_params_rejected() -> None:
cls = get_strategy("sma_cross")
import pytest
with pytest.raises(ValueError):
cls({"nope": 1})
def test_sma_cross_emits_enter_long() -> None:
strategy = get_strategy("sma_cross")({"fast": 2, "slow": 4, "size": 0.5})
ctx = StrategyContext(
candles=make_candles([10, 10, 10, 10, 10, 11]),
position=None,
equity=1000,
cash=1000,
)
strategy.on_candle(ctx)
intents = ctx.take_intents()
assert len(intents) == 1
assert intents[0].action == "enter_long"
assert intents[0].size == 0.5
def test_sma_cross_emits_close_on_cross_down() -> None:
strategy = get_strategy("sma_cross")({"fast": 2, "slow": 4, "size": 0.5})
position = Position(
strategy_key="sma_cross",
symbol="BTC/USDT",
side=PositionSide.LONG,
qty=1.0,
entry_price=10.0,
)
ctx = StrategyContext(
candles=make_candles([10, 10, 10, 10, 10, 11, 8]),
position=position,
equity=1000,
cash=1000,
)
strategy.on_candle(ctx)
intents = ctx.take_intents()
assert len(intents) == 1
assert intents[0].action == "close"