""" Incremental Backtester for testing incremental strategies. This module provides the IncBacktester class that orchestrates multiple IncTraders for parallel testing, handles data loading and feeding, and supports multiprocessing for parameter optimization. """ import pandas as pd import numpy as np from typing import Dict, List, Optional, Any, Callable, Union, Tuple import logging import time from concurrent.futures import ProcessPoolExecutor, as_completed from itertools import product import multiprocessing as mp from datetime import datetime # Use try/except for imports to handle both relative and absolute import scenarios try: from ..trader.trader import IncTrader from ..strategies.base import IncStrategyBase from .config import BacktestConfig, OptimizationConfig from .utils import DataLoader, SystemUtils, ResultsSaver except ImportError: # Fallback for direct execution import sys import os sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from trader.trader import IncTrader from strategies.base import IncStrategyBase from config import BacktestConfig, OptimizationConfig from utils import DataLoader, SystemUtils, ResultsSaver logger = logging.getLogger(__name__) def _worker_function(args: Tuple[type, Dict, Dict, BacktestConfig]) -> Dict[str, Any]: """ Worker function for multiprocessing parameter optimization. This function must be at module level to be picklable for multiprocessing. Args: args: Tuple containing (strategy_class, strategy_params, trader_params, config) Returns: Dict containing backtest results """ try: strategy_class, strategy_params, trader_params, config = args # Create new backtester instance for this worker worker_backtester = IncBacktester(config) # Create strategy instance strategy = strategy_class(params=strategy_params) # Run backtest result = worker_backtester.run_single_strategy(strategy, trader_params) result["success"] = True return result except Exception as e: logger.error(f"Worker error for {strategy_params}, {trader_params}: {e}") return { "strategy_params": strategy_params, "trader_params": trader_params, "error": str(e), "success": False } class IncBacktester: """ Incremental backtester for testing incremental strategies. This class orchestrates multiple IncTraders for parallel testing: - Loads data using the integrated DataLoader - Creates multiple IncTrader instances with different parameters - Feeds data sequentially to all traders - Collects and aggregates results - Supports multiprocessing for parallel execution - Uses SystemUtils for optimal worker count determination The backtester can run multiple strategies simultaneously or test parameter combinations across multiple CPU cores. Example: # Single strategy backtest config = BacktestConfig( data_file="btc_1min_2023.csv", start_date="2023-01-01", end_date="2023-12-31", initial_usd=10000 ) strategy = RandomStrategy("random", params={"timeframe": "15min"}) backtester = IncBacktester(config) results = backtester.run_single_strategy(strategy) # Multiple strategies strategies = [strategy1, strategy2, strategy3] results = backtester.run_multiple_strategies(strategies) # Parameter optimization param_grid = { "timeframe": ["5min", "15min", "30min"], "stop_loss_pct": [0.01, 0.02, 0.03] } results = backtester.optimize_parameters(strategy_class, param_grid) """ def __init__(self, config: BacktestConfig): """ Initialize the incremental backtester. Args: config: Backtesting configuration """ self.config = config # Initialize utilities self.data_loader = DataLoader(config.data_dir) self.system_utils = SystemUtils() self.results_saver = ResultsSaver(config.results_dir) # State management self.data = None self.results_cache = {} # Track all actions performed during backtesting self.action_log = [] self.session_start_time = datetime.now() logger.info(f"IncBacktester initialized: {config.data_file}, " f"{config.start_date} to {config.end_date}") self._log_action("backtester_initialized", { "config": config.to_dict(), "session_start": self.session_start_time.isoformat(), "system_info": self.system_utils.get_system_info() }) def _log_action(self, action_type: str, details: Dict[str, Any]) -> None: """Log an action performed during backtesting.""" self.action_log.append({ "timestamp": datetime.now().isoformat(), "action_type": action_type, "details": details }) def load_data(self) -> pd.DataFrame: """ Load and prepare data for backtesting. Returns: pd.DataFrame: Loaded OHLCV data with DatetimeIndex """ if self.data is None: logger.info(f"Loading data from {self.config.data_file}...") start_time = time.time() self.data = self.data_loader.load_data( self.config.data_file, self.config.start_date, self.config.end_date ) load_time = time.time() - start_time logger.info(f"Data loaded: {len(self.data)} rows in {load_time:.2f}s") # Validate data if self.data.empty: raise ValueError(f"No data loaded for the specified date range") if not self.data_loader.validate_data(self.data): raise ValueError("Data validation failed") self._log_action("data_loaded", { "file": self.config.data_file, "rows": len(self.data), "load_time_seconds": load_time, "date_range": f"{self.config.start_date} to {self.config.end_date}", "columns": list(self.data.columns) }) return self.data def run_single_strategy(self, strategy: IncStrategyBase, trader_params: Optional[Dict] = None) -> Dict[str, Any]: """ Run backtest for a single strategy. Args: strategy: Incremental strategy instance trader_params: Additional trader parameters Returns: Dict containing backtest results """ data = self.load_data() # Merge trader parameters final_trader_params = { "stop_loss_pct": self.config.stop_loss_pct, "take_profit_pct": self.config.take_profit_pct } if trader_params: final_trader_params.update(trader_params) # Create trader trader = IncTrader( strategy=strategy, initial_usd=self.config.initial_usd, params=final_trader_params ) # Run backtest logger.info(f"Starting backtest for {strategy.name}...") start_time = time.time() self._log_action("single_strategy_backtest_started", { "strategy_name": strategy.name, "strategy_params": strategy.params, "trader_params": final_trader_params, "data_points": len(data) }) # Optimized data iteration using numpy arrays (50-70% faster than iterrows) # Extract columns as numpy arrays for efficient access timestamps = data.index.values open_prices = data['open'].values high_prices = data['high'].values low_prices = data['low'].values close_prices = data['close'].values volumes = data['volume'].values # Process each data point (maintains real-time compatibility) for i in range(len(data)): timestamp = timestamps[i] ohlcv_data = { 'open': float(open_prices[i]), 'high': float(high_prices[i]), 'low': float(low_prices[i]), 'close': float(close_prices[i]), 'volume': float(volumes[i]) } trader.process_data_point(timestamp, ohlcv_data) # Finalize and get results trader.finalize() results = trader.get_results() backtest_time = time.time() - start_time results["backtest_duration_seconds"] = backtest_time results["data_points"] = len(data) results["config"] = self.config.to_dict() logger.info(f"Backtest completed for {strategy.name} in {backtest_time:.2f}s: " f"${results['final_usd']:.2f} ({results['profit_ratio']*100:.2f}%), " f"{results['n_trades']} trades") self._log_action("single_strategy_backtest_completed", { "strategy_name": strategy.name, "backtest_duration_seconds": backtest_time, "final_usd": results['final_usd'], "profit_ratio": results['profit_ratio'], "n_trades": results['n_trades'], "win_rate": results['win_rate'] }) return results def run_multiple_strategies(self, strategies: List[IncStrategyBase], trader_params: Optional[Dict] = None) -> List[Dict[str, Any]]: """ Run backtest for multiple strategies simultaneously. Args: strategies: List of incremental strategy instances trader_params: Additional trader parameters Returns: List of backtest results for each strategy """ self._log_action("multiple_strategies_backtest_started", { "strategy_count": len(strategies), "strategy_names": [s.name for s in strategies] }) results = [] for strategy in strategies: try: result = self.run_single_strategy(strategy, trader_params) results.append(result) except Exception as e: logger.error(f"Error running strategy {strategy.name}: {e}") # Add error result error_result = { "strategy_name": strategy.name, "error": str(e), "success": False } results.append(error_result) self._log_action("strategy_error", { "strategy_name": strategy.name, "error": str(e) }) self._log_action("multiple_strategies_backtest_completed", { "total_strategies": len(strategies), "successful_strategies": len([r for r in results if r.get("success", True)]), "failed_strategies": len([r for r in results if not r.get("success", True)]) }) return results def optimize_parameters(self, strategy_class: type, param_grid: Dict[str, List], trader_param_grid: Optional[Dict[str, List]] = None, max_workers: Optional[int] = None) -> List[Dict[str, Any]]: """ Optimize strategy parameters using grid search with multiprocessing. Args: strategy_class: Strategy class to instantiate param_grid: Grid of strategy parameters to test trader_param_grid: Grid of trader parameters to test max_workers: Maximum number of worker processes (uses SystemUtils if None) Returns: List of results for each parameter combination """ # Generate parameter combinations strategy_combinations = list(self._generate_param_combinations(param_grid)) trader_combinations = list(self._generate_param_combinations(trader_param_grid or {})) # If no trader param grid, use default if not trader_combinations: trader_combinations = [{}] # Create all combinations all_combinations = [] for strategy_params in strategy_combinations: for trader_params in trader_combinations: all_combinations.append((strategy_params, trader_params)) logger.info(f"Starting parameter optimization: {len(all_combinations)} combinations") # Determine number of workers using SystemUtils if max_workers is None: max_workers = self.system_utils.get_optimal_workers() else: max_workers = min(max_workers, len(all_combinations)) self._log_action("parameter_optimization_started", { "strategy_class": strategy_class.__name__, "total_combinations": len(all_combinations), "max_workers": max_workers, "strategy_param_grid": param_grid, "trader_param_grid": trader_param_grid or {} }) # Run optimization if max_workers == 1 or len(all_combinations) == 1: # Single-threaded execution results = [] for strategy_params, trader_params in all_combinations: result = self._run_single_combination(strategy_class, strategy_params, trader_params) results.append(result) else: # Multi-threaded execution results = self._run_parallel_optimization( strategy_class, all_combinations, max_workers ) # Sort results by profit ratio valid_results = [r for r in results if r.get("success", True)] valid_results.sort(key=lambda x: x.get("profit_ratio", -float('inf')), reverse=True) logger.info(f"Parameter optimization completed: {len(valid_results)} successful runs") self._log_action("parameter_optimization_completed", { "total_runs": len(results), "successful_runs": len(valid_results), "failed_runs": len(results) - len(valid_results), "best_profit_ratio": valid_results[0]["profit_ratio"] if valid_results else None, "worst_profit_ratio": valid_results[-1]["profit_ratio"] if valid_results else None }) return results def _generate_param_combinations(self, param_grid: Dict[str, List]) -> List[Dict]: """Generate all parameter combinations from grid.""" if not param_grid: return [{}] keys = list(param_grid.keys()) values = list(param_grid.values()) combinations = [] for combination in product(*values): param_dict = dict(zip(keys, combination)) combinations.append(param_dict) return combinations def _run_single_combination(self, strategy_class: type, strategy_params: Dict, trader_params: Dict) -> Dict[str, Any]: """Run backtest for a single parameter combination.""" try: # Create strategy instance strategy = strategy_class(params=strategy_params) # Run backtest result = self.run_single_strategy(strategy, trader_params) result["success"] = True return result except Exception as e: logger.error(f"Error in parameter combination {strategy_params}, {trader_params}: {e}") return { "strategy_params": strategy_params, "trader_params": trader_params, "error": str(e), "success": False } def _run_parallel_optimization(self, strategy_class: type, combinations: List, max_workers: int) -> List[Dict[str, Any]]: """Run parameter optimization in parallel.""" results = [] # Prepare arguments for worker function worker_args = [] for strategy_params, trader_params in combinations: args = (strategy_class, strategy_params, trader_params, self.config) worker_args.append(args) # Execute in parallel with ProcessPoolExecutor(max_workers=max_workers) as executor: # Submit all jobs future_to_params = { executor.submit(_worker_function, args): args[1:3] # strategy_params, trader_params for args in worker_args } # Collect results as they complete for future in as_completed(future_to_params): combo = future_to_params[future] try: result = future.result() results.append(result) if result.get("success", True): logger.info(f"Completed: {combo[0]} -> " f"${result.get('final_usd', 0):.2f} " f"({result.get('profit_ratio', 0)*100:.2f}%)") except Exception as e: logger.error(f"Worker error for {combo}: {e}") results.append({ "strategy_params": combo[0], "trader_params": combo[1], "error": str(e), "success": False }) return results def get_summary_statistics(self, results: List[Dict[str, Any]]) -> Dict[str, Any]: """ Calculate summary statistics across multiple backtest results. Args: results: List of backtest results Returns: Dict containing summary statistics """ return self.results_saver._calculate_summary_statistics(results) def save_results(self, results: List[Dict[str, Any]], filename: str) -> None: """ Save backtest results to CSV file. Args: results: List of backtest results filename: Output filename """ self.results_saver.save_results_csv(results, filename) def save_comprehensive_results(self, results: List[Dict[str, Any]], base_filename: str, summary: Optional[Dict[str, Any]] = None) -> None: """ Save comprehensive backtest results including summary, individual results, and action log. Args: results: List of backtest results base_filename: Base filename (without extension) summary: Optional summary statistics """ self.results_saver.save_comprehensive_results( results=results, base_filename=base_filename, summary=summary, action_log=self.action_log, session_start_time=self.session_start_time ) def get_action_log(self) -> List[Dict[str, Any]]: """Get the complete action log for this session.""" return self.action_log.copy() def reset_session(self) -> None: """Reset the backtester session (clear cache and logs).""" self.data = None self.results_cache.clear() self.action_log.clear() self.session_start_time = datetime.now() logger.info("Backtester session reset") self._log_action("session_reset", { "reset_time": self.session_start_time.isoformat() }) def __repr__(self) -> str: """String representation of the backtester.""" return (f"IncBacktester(data_file={self.config.data_file}, " f"date_range={self.config.start_date} to {self.config.end_date}, " f"initial_usd=${self.config.initial_usd})")