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.
84 lines
2.7 KiB
Python
84 lines
2.7 KiB
Python
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),
|
|
)
|