Crypto trading bot: engine, brokers, backtester, dashboard, deploy
FastAPI backend with a single-strategy async engine, paper and Binance brokers, pure-python indicators, a backtester that shares the engine's portfolio math, and a Vue dashboard with a live WebSocket feed. Adds the Gitea Actions CI/CD workflow that deploys to Traefik via docker_default.
This commit is contained in:
1 parent
98028248c2
commit
9c3bee87ac
73 files changed
+9759
-1
No files matched your search
@@ -0,0 +1,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"]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
Reference in new issue
Block a user