"""Tests for detailed backtest metrics.""" import math 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 == {} def test_recovery_factor(): """Recovery factor should be positive when there is a drawdown.""" trades = [ _make_trade("BUY", "100", 0), _make_trade("SELL", "150", 10), # win _make_trade("BUY", "150", 20), _make_trade("SELL", "120", 30), # loss: creates drawdown ] metrics = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10020")) assert metrics.recovery_factor > 0 def test_consecutive_losses(): """Consecutive loss tracking should count streaks correctly.""" trades = [ _make_trade("BUY", "100", 0), _make_trade("SELL", "110", 10), # win _make_trade("BUY", "110", 20), _make_trade("SELL", "105", 30), # loss _make_trade("BUY", "105", 40), _make_trade("SELL", "100", 50), # loss ] metrics = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10005")) assert metrics.max_consecutive_losses >= 1 assert metrics.max_consecutive_wins >= 1 def test_risk_free_rate_affects_sharpe(): """Higher risk-free rate should lower Sharpe ratio.""" base = datetime(2025, 1, 1, tzinfo=timezone.utc) trades = [ TradeRecord( time=base, symbol="BTCUSDT", side="BUY", price=Decimal("100"), quantity=Decimal("1") ), TradeRecord( time=base + timedelta(days=1), symbol="BTCUSDT", side="SELL", price=Decimal("110"), quantity=Decimal("1"), ), TradeRecord( time=base + timedelta(days=2), symbol="BTCUSDT", side="BUY", price=Decimal("105"), quantity=Decimal("1"), ), TradeRecord( time=base + timedelta(days=3), symbol="BTCUSDT", side="SELL", price=Decimal("115"), quantity=Decimal("1"), ), TradeRecord( time=base + timedelta(days=4), symbol="BTCUSDT", side="BUY", price=Decimal("110"), quantity=Decimal("1"), ), TradeRecord( time=base + timedelta(days=5), symbol="BTCUSDT", side="SELL", price=Decimal("108"), quantity=Decimal("1"), ), ] m1 = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10018"), risk_free_rate=0.0) m2 = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10018"), risk_free_rate=0.10) assert m2.sharpe_ratio <= m1.sharpe_ratio def test_daily_returns_populated(): """Daily returns list should be populated when there are trades.""" trades = [ _make_trade("BUY", "100", 0), _make_trade("SELL", "110", 60), _make_trade("BUY", "105", 120), _make_trade("SELL", "115", 180), ] metrics = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10020")) assert len(metrics.daily_returns) > 0 def test_fee_subtracted_from_pnl(): """Fees should be subtracted from trade PnL.""" base = datetime(2025, 1, 1, tzinfo=timezone.utc) trades_with_fees = [ TradeRecord( time=base, symbol="BTC", side="BUY", price=Decimal("100"), quantity=Decimal("1"), fee=Decimal("1"), ), TradeRecord( time=base + timedelta(minutes=10), symbol="BTC", side="SELL", price=Decimal("110"), quantity=Decimal("1"), fee=Decimal("1"), ), ] # PnL should be 10 - 1 - 1 = 8 metrics = compute_detailed_metrics(trades_with_fees, Decimal("10000"), Decimal("10008")) assert metrics.winning_trades == 1 assert metrics.trade_pairs[0]["pnl"] == pytest.approx(8.0)