"""Unit tests for strategy optimizer module.""" import time from datetime import datetime, timedelta from unittest.mock import Mock import numpy as np import pytest from openclaw.backtest.engine import BacktestResult from openclaw.optimizer import ( BayesianOptimizer, GridSearchOptimizer, OptimizationAnalyzer, OptimizationResult, OptimizerConfig, ParameterSpace, RandomSearchOptimizer, StrategyOptimizer, ) from openclaw.optimizer.base import OptimizationObjective class TestParameterSpace: """Test ParameterSpace class.""" def test_add_continuous_parameter(self): """Test adding continuous parameter.""" space = ParameterSpace() space.add_continuous("learning_rate", 0.001, 0.1, distribution="log_uniform") assert "learning_rate" in space param = space.get_parameter("learning_rate") assert param.param_type.value == "continuous" assert param.bounds == (0.001, 0.1) assert param.distribution == "log_uniform" def test_add_integer_parameter(self): """Test adding integer parameter.""" space = ParameterSpace() space.add_integer("window_size", 5, 50) assert "window_size" in space param = space.get_parameter("window_size") assert param.param_type.value == "integer" assert param.bounds == (5, 50) def test_add_discrete_parameter(self): """Test adding discrete parameter.""" space = ParameterSpace() space.add_discrete("threshold", [0.1, 0.2, 0.3, 0.4, 0.5]) assert "threshold" in space param = space.get_parameter("threshold") assert param.param_type.value == "discrete" assert param.bounds == [0.1, 0.2, 0.3, 0.4, 0.5] def test_add_categorical_parameter(self): """Test adding categorical parameter.""" space = ParameterSpace() space.add_categorical("strategy_type", ["momentum", "mean_reversion", "trend_following"]) assert "strategy_type" in space param = space.get_parameter("strategy_type") assert param.param_type.value == "categorical" assert "momentum" in param.bounds def test_method_chaining(self): """Test that methods can be chained.""" space = ( ParameterSpace() .add_continuous("param1", 0.0, 1.0) .add_integer("param2", 1, 10) .add_categorical("param3", ["a", "b"]) ) assert len(space) == 3 assert "param1" in space assert "param2" in space assert "param3" in space def test_invalid_bounds(self): """Test that invalid bounds raise errors.""" space = ParameterSpace() with pytest.raises(ValueError, match="Invalid bounds"): space.add_continuous("invalid", 1.0, 0.5) def test_sample_random(self): """Test random sampling from parameter space.""" space = ( ParameterSpace() .add_continuous("continuous", 0.0, 1.0) .add_integer("integer", 1, 10) .add_categorical("categorical", ["a", "b", "c"]) ) params = space.sample_random() assert "continuous" in params assert 0.0 <= params["continuous"] <= 1.0 assert "integer" in params assert 1 <= params["integer"] <= 10 assert "categorical" in params assert params["categorical"] in ["a", "b", "c"] def test_get_grid_points(self): """Test grid point generation.""" space = ( ParameterSpace() .add_continuous("param1", 0.0, 1.0) .add_integer("param2", 1, 3) ) grid = space.get_grid_points(n_points=3) # Should have 3 (continuous) * 3 (integer: 1,2,3) = 9 points assert len(grid) == 9 # Check first point assert "param1" in grid[0] assert "param2" in grid[0] def test_get_nonexistent_parameter(self): """Test getting a parameter that doesn't exist.""" space = ParameterSpace() with pytest.raises(KeyError): space.get_parameter("nonexistent") class TestOptimizerConfig: """Test OptimizerConfig class.""" def test_default_config(self): """Test default configuration values.""" config = OptimizerConfig() assert config.objective == OptimizationObjective.MAXIMIZE_SHARPE assert config.max_iterations == 100 assert config.n_jobs == -1 assert config.early_stopping is True assert config.early_stopping_patience == 10 def test_custom_config(self): """Test custom configuration.""" config = OptimizerConfig( objective=OptimizationObjective.MAXIMIZE_RETURN, max_iterations=50, n_jobs=4, early_stopping=False, random_state=42, ) assert config.objective == OptimizationObjective.MAXIMIZE_RETURN assert config.max_iterations == 50 assert config.n_jobs == 4 assert config.early_stopping is False assert config.random_state == 42 def test_invalid_max_iterations(self): """Test that invalid max_iterations raises error.""" with pytest.raises(ValueError, match="max_iterations"): OptimizerConfig(max_iterations=0) def test_invalid_validation_split(self): """Test that invalid validation_split raises error.""" with pytest.raises(ValueError, match="validation_split"): OptimizerConfig(validation_split=1.5) class TestGridSearchOptimizer: """Test GridSearchOptimizer class.""" @pytest.fixture def simple_space(self): """Create a simple parameter space.""" return ( ParameterSpace() .add_discrete("threshold", [0.1, 0.2, 0.3]) .add_integer("window", 5, 6) ) @pytest.fixture def mock_backtest_fn(self): """Create a mock backtest function.""" def backtest_fn(params): # Simple scoring: higher threshold + window = better score = params.get("threshold", 0) * 100 + params.get("window", 0) return BacktestResult( start_date=datetime.now(), end_date=datetime.now(), initial_capital=10000.0, final_equity=10000.0 + score * 100, total_return=score, total_trades=10, winning_trades=5, losing_trades=5, win_rate=50.0, avg_win=100.0, avg_loss=-50.0, profit_factor=1.5, sharpe_ratio=score / 10, max_drawdown=10.0, max_drawdown_duration=5, volatility=15.0, calmar_ratio=score / 10, ) return backtest_fn def test_initialization(self, simple_space): """Test optimizer initialization.""" optimizer = GridSearchOptimizer(simple_space, n_points=3) assert optimizer.parameter_space == simple_space assert optimizer.n_points == 3 def test_get_grid_size(self, simple_space): """Test getting grid size.""" optimizer = GridSearchOptimizer(simple_space, n_points=3) # 3 thresholds * 2 windows (5, 6) = 6 assert optimizer.get_grid_size() == 6 def test_optimize(self, simple_space, mock_backtest_fn): """Test optimization.""" config = OptimizerConfig(max_iterations=100, n_jobs=1) optimizer = GridSearchOptimizer(simple_space, config=config, n_points=3) result = optimizer.optimize(mock_backtest_fn) assert isinstance(result, OptimizationResult) assert result.best_params is not None assert result.best_score > float("-inf") assert len(result.all_results) == 6 # All combinations assert result.converged is True def test_optimize_with_max_iterations(self, simple_space, mock_backtest_fn): """Test optimization respects max_iterations.""" config = OptimizerConfig(max_iterations=4, n_jobs=1) optimizer = GridSearchOptimizer(simple_space, config=config, n_points=5) result = optimizer.optimize(mock_backtest_fn) # Should be limited by max_iterations assert result.n_iterations <= 4 def test_optimize_with_callback(self, simple_space, mock_backtest_fn): """Test optimization with callback.""" callback_calls = [] def callback(iteration, params, score): callback_calls.append((iteration, params, score)) config = OptimizerConfig(max_iterations=100, n_jobs=1) optimizer = GridSearchOptimizer(simple_space, config=config) optimizer.optimize(mock_backtest_fn, callback=callback) assert len(callback_calls) == 6 class TestRandomSearchOptimizer: """Test RandomSearchOptimizer class.""" @pytest.fixture def continuous_space(self): """Create a parameter space with continuous parameters.""" return ( ParameterSpace() .add_continuous("alpha", 0.0, 1.0) .add_continuous("beta", 0.0, 1.0) ) @pytest.fixture def mock_backtest_fn(self): """Create a mock backtest function.""" def backtest_fn(params): score = params.get("alpha", 0) + params.get("beta", 0) return BacktestResult( start_date=datetime.now(), end_date=datetime.now(), initial_capital=10000.0, final_equity=10000.0, total_return=score * 100, total_trades=10, winning_trades=5, losing_trades=5, win_rate=50.0, avg_win=100.0, avg_loss=-50.0, profit_factor=1.5, sharpe_ratio=score, max_drawdown=10.0, max_drawdown_duration=5, volatility=15.0, calmar_ratio=score, ) return backtest_fn def test_initialization(self, continuous_space): """Test optimizer initialization.""" optimizer = RandomSearchOptimizer(continuous_space, n_samples=50) assert optimizer.parameter_space == continuous_space assert optimizer.n_samples == 50 def test_optimize(self, continuous_space, mock_backtest_fn): """Test optimization.""" config = OptimizerConfig(max_iterations=100, n_jobs=1, early_stopping=False, random_state=42) optimizer = RandomSearchOptimizer(continuous_space, config=config, n_samples=20) result = optimizer.optimize(mock_backtest_fn) assert isinstance(result, OptimizationResult) assert result.best_params is not None assert result.best_score > float("-inf") assert result.n_iterations == 20 def test_optimize_with_early_stopping(self, continuous_space, mock_backtest_fn): """Test optimization with early stopping.""" config = OptimizerConfig( max_iterations=100, n_jobs=1, early_stopping=True, early_stopping_patience=2, early_stopping_min_delta=0.1, random_state=42, ) optimizer = RandomSearchOptimizer(continuous_space, config=config, n_samples=50) result = optimizer.optimize(mock_backtest_fn) # Should stop early due to no improvement assert result.n_iterations < 50 def test_optimize_with_warm_start(self, continuous_space, mock_backtest_fn): """Test optimization with warm start.""" initial_params = [ ({"alpha": 0.5, "beta": 0.5}, 1.0), ({"alpha": 0.6, "beta": 0.4}, 1.0), ] config = OptimizerConfig(max_iterations=100, n_jobs=1, random_state=42) optimizer = RandomSearchOptimizer(continuous_space, config=config, n_samples=10) result = optimizer.optimize_with_warm_start(mock_backtest_fn, initial_params) assert len(result.all_results) >= 2 # At least initial params assert result.best_score >= 1.0 class TestBayesianOptimizer: """Test BayesianOptimizer class.""" @pytest.fixture def simple_space(self): """Create a simple parameter space.""" return ( ParameterSpace() .add_continuous("x", 0.0, 10.0) .add_continuous("y", 0.0, 10.0) ) @pytest.fixture def mock_backtest_fn(self): """Create a mock backtest function with known optimum.""" def backtest_fn(params): # Optimum at x=7, y=3 x = params.get("x", 0) y = params.get("y", 0) score = -((x - 7) ** 2 + (y - 3) ** 2) / 100 + 5 return BacktestResult( start_date=datetime.now(), end_date=datetime.now(), initial_capital=10000.0, final_equity=10000.0, total_return=score * 10, total_trades=10, winning_trades=5, losing_trades=5, win_rate=50.0, avg_win=100.0, avg_loss=-50.0, profit_factor=1.5, sharpe_ratio=score, max_drawdown=10.0, max_drawdown_duration=5, volatility=15.0, calmar_ratio=score, ) return backtest_fn def test_initialization(self, simple_space): """Test optimizer initialization.""" optimizer = BayesianOptimizer( simple_space, n_initial_points=5, acquisition="ei", ) assert optimizer.parameter_space == simple_space assert optimizer.n_initial_points == 5 assert optimizer.acquisition == "ei" def test_invalid_acquisition(self, simple_space): """Test that invalid acquisition function raises error.""" with pytest.raises(ValueError, match="Unknown acquisition"): BayesianOptimizer(simple_space, acquisition="invalid") def test_optimize(self, simple_space, mock_backtest_fn): """Test optimization.""" config = OptimizerConfig(max_iterations=20, n_jobs=1, random_state=42) optimizer = BayesianOptimizer( simple_space, config=config, n_initial_points=5, acquisition="ei", ) result = optimizer.optimize(mock_backtest_fn) assert isinstance(result, OptimizationResult) assert result.best_params is not None assert result.best_score > float("-inf") assert result.n_iterations >= 5 # At least initial points # Should find something close to optimum best = result.best_params assert 5 <= best["x"] <= 9 # Around 7 assert 1 <= best["y"] <= 5 # Around 3 def test_parameter_importance(self, simple_space, mock_backtest_fn): """Test parameter importance calculation.""" config = OptimizerConfig(max_iterations=15, n_jobs=1, random_state=42) optimizer = BayesianOptimizer( simple_space, config=config, n_initial_points=5, ) result = optimizer.optimize(mock_backtest_fn) assert "x" in result.parameter_importance assert "y" in result.parameter_importance # Importance scores should sum to 1 assert abs(sum(result.parameter_importance.values()) - 1.0) < 0.01 class TestOptimizationAnalyzer: """Test OptimizationAnalyzer class.""" @pytest.fixture def analyzer(self): """Create an analyzer instance.""" return OptimizationAnalyzer() @pytest.fixture def sample_result(self): """Create a sample optimization result.""" all_results = [ ({"param1": 0.1, "param2": 10}, 1.0, None), ({"param1": 0.2, "param2": 20}, 2.0, None), ({"param1": 0.3, "param2": 30}, 3.0, None), ({"param1": 0.4, "param2": 40}, 4.0, None), ({"param1": 0.5, "param2": 50}, 5.0, None), ] return OptimizationResult( best_params={"param1": 0.5, "param2": 50}, best_score=5.0, best_result=None, all_results=all_results, optimization_time=10.0, n_iterations=5, converged=True, ) def test_analyze_parameter_sensitivity(self, analyzer, sample_result): """Test parameter sensitivity analysis.""" sensitivity = analyzer.analyze_parameter_sensitivity( sample_result, "param1" ) assert sensitivity.parameter_name == "param1" assert len(sensitivity.values) > 0 assert len(sensitivity.scores) > 0 assert sensitivity.sensitivity_score >= 0.0 def test_detect_overfitting(self, analyzer): """Test overfitting detection.""" train_result = OptimizationResult( best_params={}, best_score=10.0, best_result=None, all_results=[], converged=True, ) validation_result = OptimizationResult( best_params={}, best_score=5.0, best_result=None, all_results=[], converged=True, ) overfitting = analyzer.detect_overfitting( train_result, validation_result, threshold=0.3 ) assert overfitting.is_overfitted is True assert overfitting.train_score == 10.0 assert overfitting.validation_score == 5.0 assert overfitting.severity in ["low", "medium", "high"] def test_no_overfitting(self, analyzer): """Test when there's no overfitting.""" train_result = OptimizationResult( best_params={}, best_score=10.0, best_result=None, all_results=[], converged=True, ) validation_result = OptimizationResult( best_params={}, best_score=10.0, # Same as train = no overfitting best_result=None, all_results=[], converged=True, ) overfitting = analyzer.detect_overfitting( train_result, validation_result, threshold=0.3 ) assert overfitting.is_overfitted is False assert overfitting.severity == "none" def test_get_optimization_curve(self, analyzer, sample_result): """Test getting optimization curve.""" iterations, best_scores = analyzer.get_optimization_curve(sample_result) assert len(iterations) == 5 assert len(best_scores) == 5 assert best_scores == [1.0, 2.0, 3.0, 4.0, 5.0] # Cumulative best def test_get_convergence_rate(self, analyzer, sample_result): """Test convergence rate calculation.""" rate = analyzer.get_convergence_rate(sample_result, window_size=2) assert isinstance(rate, float) def test_get_top_configurations(self, analyzer, sample_result): """Test getting top configurations.""" top = analyzer.get_top_configurations(sample_result, n_top=3) assert len(top) == 3 # Should be sorted by score (descending) assert top[0][1] >= top[1][1] >= top[2][1] def test_calculate_robustness_score(self, analyzer, sample_result): """Test robustness score calculation.""" score = analyzer.calculate_robustness_score(sample_result, n_bootstrap=50) assert 0.0 <= score <= 1.0 def test_generate_report(self, analyzer, sample_result): """Test report generation.""" report = analyzer.generate_report(sample_result) assert "best_params" in report assert "best_score" in report assert "n_iterations" in report assert "score_statistics" in report assert "parameter_sensitivity" in report assert "top_configurations" in report assert report["best_score"] == 5.0 assert report["converged"] is True def test_empty_results(self, analyzer): """Test handling of empty results.""" empty_result = OptimizationResult( best_params={}, best_score=0.0, best_result=None, all_results=[], converged=False, ) report = analyzer.generate_report(empty_result) assert "error" in report class TestIntegration: """Integration tests for the optimizer module.""" def test_full_optimization_workflow(self): """Test a complete optimization workflow.""" # Create parameter space space = ( ParameterSpace() .add_continuous("multiplier", 1.0, 3.0) .add_integer("period", 5, 15) ) # Create mock backtest function call_count = 0 def backtest_fn(params): nonlocal call_count call_count += 1 multiplier = params.get("multiplier", 1.0) period = params.get("period", 10) score = multiplier * period / 10 return BacktestResult( start_date=datetime.now(), end_date=datetime.now(), initial_capital=10000.0, final_equity=10000.0 + score * 100, total_return=score * 10, total_trades=10, winning_trades=5, losing_trades=5, win_rate=50.0, avg_win=100.0, avg_loss=-50.0, profit_factor=1.5, sharpe_ratio=score, max_drawdown=10.0, max_drawdown_duration=5, volatility=15.0, calmar_ratio=score, ) # Test with each optimizer config = OptimizerConfig(max_iterations=10, n_jobs=1, random_state=42) # Grid Search call_count = 0 grid_optimizer = GridSearchOptimizer(space, config=config, n_points=2) grid_result = grid_optimizer.optimize(backtest_fn) assert grid_result.best_score > 0 assert len(grid_result.all_results) > 0 # Random Search call_count = 0 random_optimizer = RandomSearchOptimizer(space, config=config, n_samples=10) random_result = random_optimizer.optimize(backtest_fn) assert random_result.best_score > 0 # Bayesian Optimization call_count = 0 bayesian_optimizer = BayesianOptimizer( space, config=config, n_initial_points=5, acquisition="ei", ) bayesian_result = bayesian_optimizer.optimize(backtest_fn) assert bayesian_result.best_score > 0 # Analyze results analyzer = OptimizationAnalyzer() grid_report = analyzer.generate_report(grid_result) assert grid_report["best_score"] == grid_result.best_score def test_different_objectives(self): """Test optimization with different objectives.""" space = ParameterSpace().add_continuous("param", 0.0, 1.0) def backtest_fn(params): value = params.get("param", 0.5) return BacktestResult( start_date=datetime.now(), end_date=datetime.now(), initial_capital=10000.0, final_equity=10000.0, total_return=value * 100, total_trades=10, winning_trades=int(value * 10), losing_trades=int((1 - value) * 10), win_rate=value * 100, avg_win=100.0, avg_loss=-50.0, profit_factor=1.5, sharpe_ratio=value * 2, max_drawdown=(1 - value) * 20, max_drawdown_duration=5, volatility=15.0, calmar_ratio=value * 5, ) # Test different objectives objectives = [ OptimizationObjective.MAXIMIZE_RETURN, OptimizationObjective.MAXIMIZE_SHARPE, OptimizationObjective.MAXIMIZE_CALMAR, OptimizationObjective.MINIMIZE_DRAWDOWN, OptimizationObjective.MAXIMIZE_WIN_RATE, ] for objective in objectives: config = OptimizerConfig( objective=objective, max_iterations=5, n_jobs=1, random_state=42, ) optimizer = RandomSearchOptimizer(space, config=config, n_samples=5) result = optimizer.optimize(backtest_fn) assert result.best_score != float("-inf") def test_parameter_correlations(self): """Test parameter correlation analysis.""" all_results = [ ({"x": 1.0, "y": 1.0}, 2.0, None), ({"x": 2.0, "y": 2.0}, 4.0, None), ({"x": 3.0, "y": 3.0}, 6.0, None), ({"x": 4.0, "y": 4.0}, 8.0, None), ({"x": 5.0, "y": 5.0}, 10.0, None), ] result = OptimizationResult( best_params={"x": 5.0, "y": 5.0}, best_score=10.0, best_result=None, all_results=all_results, converged=True, ) analyzer = OptimizationAnalyzer() correlations = analyzer.analyze_parameter_correlations(result) # x and y are perfectly correlated in the data assert ("x", "y") in correlations or ("y", "x") in correlations