"""Unit tests for backtest performance analyzer. Tests the PerformanceAnalyzer class and its various metric calculations. """ from datetime import datetime, timedelta import numpy as np import pytest from openclaw.backtest.analyzer import ( BacktestResult, PerformanceAnalyzer, TradeRecord, ) class TestPerformanceAnalyzer: """Test suite for PerformanceAnalyzer.""" @pytest.fixture def analyzer(self): """Create a PerformanceAnalyzer instance.""" return PerformanceAnalyzer() @pytest.fixture def sample_equity_curve(self): """Create a sample equity curve for testing.""" # Start with 10000, grow to 15000 with some volatility np.random.seed(42) returns = np.random.normal(0.0005, 0.02, 252) # Daily returns equity = [10000.0] for r in returns: equity.append(equity[-1] * (1 + r)) return equity @pytest.fixture def sample_timestamps(self, sample_equity_curve): """Create sample timestamps matching equity curve length.""" start = datetime(2023, 1, 1) return [start + timedelta(days=i) for i in range(len(sample_equity_curve))] @pytest.fixture def sample_trades(self): """Create sample trades for testing.""" base_time = datetime(2023, 1, 1) return [ TradeRecord( entry_time=base_time + timedelta(days=i), exit_time=base_time + timedelta(days=i + 5), side="long", entry_price=100.0, exit_price=110.0 if i % 2 == 0 else 95.0, quantity=10.0, pnl=100.0 if i % 2 == 0 else -50.0, is_win=i % 2 == 0, ) for i in range(20) ] @pytest.fixture def sample_backtest_result(self, sample_equity_curve, sample_timestamps, sample_trades): """Create a complete backtest result.""" return BacktestResult( initial_capital=10000.0, final_capital=sample_equity_curve[-1], equity_curve=sample_equity_curve, timestamps=sample_timestamps, trades=sample_trades, start_time=sample_timestamps[0], end_time=sample_timestamps[-1], ) class TestReturns: """Tests for return calculations.""" def test_calculate_returns_basic(self, analyzer): """Test basic return calculation.""" equity = [100.0, 110.0, 121.0] returns = analyzer.calculate_returns(equity) assert len(returns) == 2 assert np.isclose(returns[0], 0.10) # 10% return assert np.isclose(returns[1], 0.10) # 10% return def test_calculate_returns_empty(self, analyzer): """Test return calculation with empty curve.""" returns = analyzer.calculate_returns([]) assert len(returns) == 0 def test_calculate_returns_single_point(self, analyzer): """Test return calculation with single point.""" returns = analyzer.calculate_returns([100.0]) assert len(returns) == 0 def test_calculate_total_return(self, analyzer): """Test total return calculation.""" equity = [100.0, 110.0, 121.0] total_return = analyzer.calculate_total_return(equity) assert np.isclose(total_return, 0.21) # 21% total return def test_calculate_total_return_empty(self, analyzer): """Test total return with empty curve.""" total_return = analyzer.calculate_total_return([]) assert total_return == 0.0 def test_calculate_annualized_return(self, analyzer): """Test annualized return calculation.""" start = datetime(2023, 1, 1) timestamps = [start + timedelta(days=i) for i in range(366)] equity = [100.0, 121.0] + [121.0] * 364 # 21% return over 1 year annualized = analyzer.calculate_annualized_return(equity, timestamps) assert annualized > 0 assert np.isclose(annualized, 0.21, rtol=0.1) # ~21% annualized def test_calculate_annualized_return_same_day(self, analyzer): """Test annualized return with same start/end day.""" timestamps = [datetime(2023, 1, 1), datetime(2023, 1, 1)] equity = [100.0, 110.0] annualized = analyzer.calculate_annualized_return(equity, timestamps) assert annualized == 0.0 class TestDrawdown: """Tests for drawdown calculations.""" def test_calculate_max_drawdown(self, analyzer): """Test max drawdown calculation.""" # Peak at 150, drop to 100, recover to 150 (full recovery) equity = [100.0, 120.0, 150.0, 140.0, 130.0, 100.0, 110.0, 150.0] stats = analyzer.calculate_max_drawdown(equity) assert stats["max_drawdown"] == pytest.approx(0.3333, abs=0.001) assert stats["peak"] == 150.0 assert stats["trough"] == 100.0 assert stats["recovery_index"] == 7 def test_calculate_max_drawdown_no_recovery(self, analyzer): """Test max drawdown without recovery.""" equity = [100.0, 150.0, 120.0, 100.0] # No recovery stats = analyzer.calculate_max_drawdown(equity) assert stats["max_drawdown"] == pytest.approx(0.3333, abs=0.001) assert stats["recovery_index"] == -1 def test_calculate_max_drawdown_no_drawdown(self, analyzer): """Test with no drawdown (always increasing).""" equity = [100.0, 110.0, 120.0, 130.0] stats = analyzer.calculate_max_drawdown(equity) assert stats["max_drawdown"] == 0.0 assert stats["peak"] == 130.0 assert stats["trough"] == 130.0 def test_calculate_max_drawdown_multiple(self, analyzer): """Test with multiple drawdowns.""" # Two drawdowns: 150->100 (33%) and 180->140 (22%) equity = [100.0, 150.0, 120.0, 100.0, 130.0, 180.0, 160.0, 140.0, 170.0] stats = analyzer.calculate_max_drawdown(equity) assert stats["max_drawdown"] == pytest.approx(0.3333, abs=0.001) assert stats["peak"] == 150.0 class TestSharpeRatio: """Tests for Sharpe ratio calculation.""" def test_calculate_sharpe_ratio_positive(self, analyzer): """Test Sharpe ratio with positive returns.""" # Consistent positive returns returns = np.array([0.001] * 252) # 0.1% daily sharpe = analyzer.calculate_sharpe_ratio(returns, risk_free_rate=0.0) assert sharpe > 10 # Very high Sharpe with consistent returns def test_calculate_sharpe_ratio_zero_volatility(self, analyzer): """Test Sharpe ratio with zero volatility.""" returns = np.array([0.0] * 10) sharpe = analyzer.calculate_sharpe_ratio(returns) assert sharpe == 0.0 def test_calculate_sharpe_ratio_empty(self, analyzer): """Test Sharpe ratio with empty returns.""" returns = np.array([]) sharpe = analyzer.calculate_sharpe_ratio(returns) assert sharpe == 0.0 def test_calculate_sharpe_ratio_with_risk_free_rate(self, analyzer): """Test Sharpe ratio with risk-free rate.""" # 10% annual return, some volatility np.random.seed(42) returns = np.random.normal(0.0004, 0.02, 252) sharpe = analyzer.calculate_sharpe_ratio(returns, risk_free_rate=0.02) # Should be a reasonable value assert isinstance(sharpe, float) assert not np.isnan(sharpe) class TestSortinoRatio: """Tests for Sortino ratio calculation.""" def test_calculate_sortino_ratio_positive(self, analyzer): """Test Sortino ratio with positive returns.""" returns = np.array([0.001] * 252) sortino = analyzer.calculate_sortino_ratio(returns, risk_free_rate=0.0) assert sortino == float("inf") # No downside def test_calculate_sortino_ratio_with_downside(self, analyzer): """Test Sortino ratio with downside volatility.""" np.random.seed(42) returns = np.random.normal(0.0005, 0.02, 252) sortino = analyzer.calculate_sortino_ratio(returns) assert isinstance(sortino, float) assert not np.isnan(sortino) assert sortino != float("inf") def test_calculate_sortino_ratio_all_negative(self, analyzer): """Test Sortino ratio with all negative returns.""" returns = np.array([-0.01] * 10) sortino = analyzer.calculate_sortino_ratio(returns) assert sortino < 0 # Negative Sortino for negative returns class TestCalmarRatio: """Tests for Calmar ratio calculation.""" def test_calculate_calmar_ratio(self, analyzer): """Test Calmar ratio calculation.""" returns = np.array([0.001] * 252) # ~25% annual return max_dd = 0.10 # 10% drawdown calmar = analyzer.calculate_calmar_ratio(returns, max_dd) assert calmar > 0 assert isinstance(calmar, float) def test_calculate_calmar_ratio_zero_drawdown(self, analyzer): """Test Calmar ratio with zero drawdown.""" returns = np.array([0.001] * 10) calmar = analyzer.calculate_calmar_ratio(returns, 0.0) assert calmar == 0.0 def test_calculate_calmar_ratio_empty(self, analyzer): """Test Calmar ratio with empty returns.""" returns = np.array([]) calmar = analyzer.calculate_calmar_ratio(returns, 0.10) assert calmar == 0.0 class TestWinRate: """Tests for win rate calculations.""" def test_calculate_win_rate(self, analyzer, sample_trades): """Test win rate calculation.""" win_rate = analyzer.calculate_win_rate(sample_trades) # 10 wins out of 20 trades assert win_rate == 0.5 def test_calculate_win_rate_empty(self, analyzer): """Test win rate with no trades.""" win_rate = analyzer.calculate_win_rate([]) assert win_rate == 0.0 def test_calculate_win_rate_all_wins(self, analyzer): """Test win rate with all winning trades.""" base_time = datetime(2023, 1, 1) trades = [ TradeRecord( entry_time=base_time, exit_time=base_time + timedelta(days=1), side="long", entry_price=100.0, exit_price=110.0, quantity=1.0, pnl=10.0, is_win=True, ) for _ in range(10) ] win_rate = analyzer.calculate_win_rate(trades) assert win_rate == 1.0 def test_calculate_loss_rate(self, analyzer, sample_trades): """Test loss rate calculation.""" loss_rate = analyzer.calculate_loss_rate(sample_trades) assert loss_rate == 0.5 class TestProfitFactor: """Tests for profit factor calculation.""" def test_calculate_profit_factor(self, analyzer): """Test profit factor calculation.""" base_time = datetime(2023, 1, 1) trades = [ # Winning trades: +500 total TradeRecord( entry_time=base_time, exit_time=base_time + timedelta(days=1), side="long", entry_price=100.0, exit_price=110.0, quantity=10.0, pnl=100.0, is_win=True, ), TradeRecord( entry_time=base_time + timedelta(days=2), exit_time=base_time + timedelta(days=3), side="long", entry_price=100.0, exit_price=105.0, quantity=10.0, pnl=50.0, is_win=True, ), # Losing trades: -100 total TradeRecord( entry_time=base_time + timedelta(days=4), exit_time=base_time + timedelta(days=5), side="long", entry_price=100.0, exit_price=95.0, quantity=10.0, pnl=-50.0, is_win=False, ), TradeRecord( entry_time=base_time + timedelta(days=6), exit_time=base_time + timedelta(days=7), side="long", entry_price=100.0, exit_price=95.0, quantity=10.0, pnl=-50.0, is_win=False, ), ] pf = analyzer.calculate_profit_factor(trades) assert pf == pytest.approx(1.5, abs=0.01) # 150/100 = 1.5 def test_calculate_profit_factor_no_losses(self, analyzer): """Test profit factor with no losing trades.""" base_time = datetime(2023, 1, 1) trades = [ TradeRecord( entry_time=base_time, exit_time=base_time + timedelta(days=1), side="long", entry_price=100.0, exit_price=110.0, quantity=10.0, pnl=100.0, is_win=True, ), ] pf = analyzer.calculate_profit_factor(trades) assert pf == float("inf") def test_calculate_profit_factor_empty(self, analyzer): """Test profit factor with no trades.""" pf = analyzer.calculate_profit_factor([]) assert pf == 0.0 class TestAverageTrade: """Tests for average trade calculations.""" def test_calculate_avg_trade(self, analyzer, sample_trades): """Test average trade statistics.""" stats = analyzer.calculate_avg_trade(sample_trades) assert stats["avg_pnl"] > 0 assert stats["avg_win"] > 0 assert stats["avg_loss"] > 0 assert stats["win_loss_ratio"] > 0 def test_calculate_avg_trade_empty(self, analyzer): """Test average trade with no trades.""" stats = analyzer.calculate_avg_trade([]) assert stats["avg_pnl"] == 0.0 assert stats["avg_win"] == 0.0 assert stats["avg_loss"] == 0.0 assert stats["win_loss_ratio"] == 0.0 class TestVolatility: """Tests for volatility calculations.""" def test_calculate_volatility(self, analyzer): """Test volatility calculation.""" np.random.seed(42) returns = np.random.normal(0, 0.02, 252) vol = analyzer.calculate_volatility(returns, annualize=True) assert vol > 0 # Annualized vol should be approximately 0.02 * sqrt(252) expected = 0.02 * np.sqrt(252) assert np.isclose(vol, expected, rtol=0.2) def test_calculate_volatility_not_annualized(self, analyzer): """Test volatility without annualization.""" returns = np.array([0.01, -0.01, 0.01, -0.01]) vol = analyzer.calculate_volatility(returns, annualize=False) assert vol > 0 def test_calculate_volatility_empty(self, analyzer): """Test volatility with empty returns.""" vol = analyzer.calculate_volatility(np.array([])) assert vol == 0.0 class TestVaR: """Tests for Value at Risk calculations.""" def test_calculate_var(self, analyzer): """Test VaR calculation.""" np.random.seed(42) returns = np.random.normal(0, 0.02, 1000) var = analyzer.calculate_var(returns, confidence=0.05) assert var < 0 # VaR should be negative (loss) # Approximately 5% of returns should be below VaR below_var = np.sum(returns < var) assert 30 < below_var < 70 # Allow some tolerance def test_calculate_var_empty(self, analyzer): """Test VaR with empty returns.""" var = analyzer.calculate_var(np.array([])) assert var == 0.0 def test_calculate_cvar(self, analyzer): """Test CVaR calculation.""" np.random.seed(42) returns = np.random.normal(0, 0.02, 1000) cvar = analyzer.calculate_cvar(returns, confidence=0.05) var = analyzer.calculate_var(returns, confidence=0.05) assert cvar < 0 assert cvar <= var # CVaR should be worse than VaR class TestConsecutiveStats: """Tests for consecutive trade statistics.""" def test_calculate_consecutive_stats(self, analyzer): """Test consecutive stats calculation.""" base_time = datetime(2023, 1, 1) # 3 wins, 2 losses, 4 wins, 1 loss trades = [ TradeRecord( entry_time=base_time + timedelta(days=i), exit_time=base_time + timedelta(days=i + 1), side="long", entry_price=100.0, exit_price=110.0, quantity=1.0, pnl=10.0, is_win=pattern, ) for i, pattern in enumerate( [True, True, True, False, False, True, True, True, True, False] ) ] stats = analyzer.calculate_consecutive_stats(trades) assert stats["max_consecutive_wins"] == 4 assert stats["max_consecutive_losses"] == 2 assert stats["current_streak"] == -1 # Ended with a loss def test_calculate_consecutive_stats_empty(self, analyzer): """Test consecutive stats with no trades.""" stats = analyzer.calculate_consecutive_stats([]) assert stats["max_consecutive_wins"] == 0 assert stats["max_consecutive_losses"] == 0 assert stats["current_streak"] == 0 class TestGenerateReport: """Tests for report generation.""" def test_generate_report_structure(self, analyzer, sample_backtest_result): """Test that report contains all expected keys.""" report = analyzer.generate_report(sample_backtest_result) expected_keys = [ "initial_capital", "final_capital", "total_return", "total_return_pct", "annualized_return", "annualized_return_pct", "num_trades", "num_winning_trades", "num_losing_trades", "win_rate", "win_rate_pct", "loss_rate", "profit_factor", "avg_pnl", "avg_win", "avg_loss", "win_loss_ratio", "max_drawdown", "max_drawdown_pct", "volatility", "sharpe_ratio", "sortino_ratio", "calmar_ratio", "var_5pct", "cvar_5pct", "max_consecutive_wins", "max_consecutive_losses", "duration_days", "start_time", "end_time", ] for key in expected_keys: assert key in report, f"Missing key: {key}" def test_generate_report_values(self, analyzer, sample_backtest_result): """Test that report values are reasonable.""" report = analyzer.generate_report(sample_backtest_result) assert report["initial_capital"] == 10000.0 assert report["num_trades"] == 20 assert 0 <= report["win_rate"] <= 1 assert report["max_drawdown"] >= 0 assert report["duration_days"] > 0 def test_generate_report_no_trades(self, analyzer, sample_equity_curve, sample_timestamps): """Test report generation with no trades.""" result = BacktestResult( initial_capital=10000.0, final_capital=sample_equity_curve[-1], equity_curve=sample_equity_curve, timestamps=sample_timestamps, trades=[], start_time=sample_timestamps[0], end_time=sample_timestamps[-1], ) report = analyzer.generate_report(result) assert report["num_trades"] == 0 assert report["win_rate"] == 0.0 assert report["profit_factor"] == 0.0 class TestToDataFrame: """Tests for DataFrame conversion.""" def test_to_dataframe(self, analyzer, sample_backtest_result): """Test conversion to DataFrame.""" df = analyzer.to_dataframe(sample_backtest_result) assert len(df) == len(sample_backtest_result.equity_curve) assert "timestamp" in df.columns assert "equity" in df.columns assert "returns" in df.columns assert "drawdown" in df.columns def test_to_dataframe_returns(self, analyzer, sample_backtest_result): """Test that returns column is calculated correctly.""" df = analyzer.to_dataframe(sample_backtest_result) # First return should be NaN assert pd.isna(df["returns"].iloc[0]) # Other returns should be calculated assert not pd.isna(df["returns"].iloc[1]) import pandas as pd # noqa: E402