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

+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"