From c89701668527ab94a124ac5ceb7a8e1045da1d72 Mon Sep 17 00:00:00 2001 From: TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:22:07 +0900 Subject: feat(backtester): add detailed metrics (Sharpe, Sortino, drawdown) Add metrics.py with TradeRecord/DetailedMetrics dataclasses and compute_detailed_metrics() that pairs BUY/SELL trades FIFO, computes win/loss stats, profit factor, equity curve, max drawdown, Sharpe, Sortino, Calmar ratios, and monthly returns. --- services/backtester/tests/test_metrics.py | 96 +++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 services/backtester/tests/test_metrics.py (limited to 'services/backtester/tests') diff --git a/services/backtester/tests/test_metrics.py b/services/backtester/tests/test_metrics.py new file mode 100644 index 0000000..b222b8a --- /dev/null +++ b/services/backtester/tests/test_metrics.py @@ -0,0 +1,96 @@ +"""Tests for detailed backtest metrics.""" +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +import pytest + +from backtester.metrics import TradeRecord, compute_detailed_metrics + + +def _make_trade(side: str, price: str, minutes_offset: int = 0) -> TradeRecord: + return TradeRecord( + time=datetime(2025, 1, 1, tzinfo=timezone.utc) + timedelta(minutes=minutes_offset), + symbol="BTCUSDT", + side=side, + price=Decimal(price), + quantity=Decimal("1"), + ) + + +def test_compute_metrics_basic(): + """Two round-trip trades: 1 win, 1 loss. Verify counts and win_rate.""" + trades = [ + _make_trade("BUY", "100", 0), + _make_trade("SELL", "120", 10), # win: +20 + _make_trade("BUY", "130", 20), + _make_trade("SELL", "110", 30), # loss: -20 + ] + metrics = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10000")) + + assert metrics.total_trades == 4 + assert metrics.winning_trades == 1 + assert metrics.losing_trades == 1 + assert metrics.win_rate == pytest.approx(50.0) + + +def test_compute_metrics_profit_factor(): + """Verify profit_factor = gross_profit / gross_loss.""" + trades = [ + _make_trade("BUY", "100", 0), + _make_trade("SELL", "150", 10), # win: +50 + _make_trade("BUY", "150", 20), + _make_trade("SELL", "130", 30), # loss: -20 + ] + metrics = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10030")) + + # gross_profit=50, gross_loss=20 → profit_factor=2.5 + assert metrics.profit_factor == pytest.approx(2.5) + + +def test_compute_metrics_max_drawdown(): + """Max drawdown should be > 0 when there is a losing trade after a peak.""" + trades = [ + _make_trade("BUY", "100", 0), + _make_trade("SELL", "150", 10), # win: equity goes up + _make_trade("BUY", "150", 20), + _make_trade("SELL", "120", 30), # loss: equity drops + ] + metrics = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10020")) + + assert metrics.max_drawdown > 0 + + +def test_compute_metrics_sharpe_ratio(): + """Sharpe ratio should be a finite number with multiple trades.""" + trades = [ + _make_trade("BUY", "100", 0), + _make_trade("SELL", "110", 60), + _make_trade("BUY", "105", 120), + _make_trade("SELL", "115", 180), + _make_trade("BUY", "110", 240), + _make_trade("SELL", "108", 300), + ] + metrics = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10018")) + + assert math.isfinite(metrics.sharpe_ratio) + assert math.isfinite(metrics.sortino_ratio) + + +def test_compute_metrics_empty_trades(): + """Empty trades should return all zeros.""" + metrics = compute_detailed_metrics([], Decimal("10000"), Decimal("10000")) + + assert metrics.total_return == 0.0 + assert metrics.total_trades == 0 + assert metrics.winning_trades == 0 + assert metrics.losing_trades == 0 + assert metrics.win_rate == 0.0 + assert metrics.profit_factor == 0.0 + assert metrics.sharpe_ratio == 0.0 + assert metrics.sortino_ratio == 0.0 + assert metrics.calmar_ratio == 0.0 + assert metrics.max_drawdown == 0.0 + assert metrics.monthly_returns == {} + + +import math -- cgit v1.2.3