""" Results Processor This module handles processing, aggregation, and analysis of backtest results. It provides utilities for calculating metrics, aggregating results across timeframes, and formatting output data. """ import pandas as pd import numpy as np import logging from typing import Dict, List, Tuple, Any, Optional from collections import defaultdict class BacktestMetrics: """Container for backtest metrics calculation.""" @staticmethod def calculate_trade_metrics(trades: List[Dict[str, Any]]) -> Dict[str, float]: """Calculate trade-level metrics.""" if not trades: return { "n_trades": 0, "n_winning_trades": 0, "win_rate": 0.0, "total_profit": 0.0, "total_loss": 0.0, "avg_trade": 0.0, "profit_ratio": 0.0, "max_drawdown": 0.0 } n_trades = len(trades) wins = [t for t in trades if t.get('exit') and t['exit'] > t['entry']] n_winning_trades = len(wins) win_rate = n_winning_trades / n_trades if n_trades > 0 else 0 total_profit = sum(trade['profit_pct'] for trade in trades) total_loss = sum(-trade['profit_pct'] for trade in trades if trade['profit_pct'] < 0) avg_trade = total_profit / n_trades if n_trades > 0 else 0 profit_ratio = total_profit / total_loss if total_loss > 0 else float('inf') # Calculate max drawdown cumulative_profit = 0 max_drawdown = 0 peak = 0 for trade in trades: cumulative_profit += trade['profit_pct'] if cumulative_profit > peak: peak = cumulative_profit drawdown = peak - cumulative_profit if drawdown > max_drawdown: max_drawdown = drawdown return { "n_trades": n_trades, "n_winning_trades": n_winning_trades, "win_rate": win_rate, "total_profit": total_profit, "total_loss": total_loss, "avg_trade": avg_trade, "profit_ratio": profit_ratio, "max_drawdown": max_drawdown } @staticmethod def calculate_portfolio_metrics(trades: List[Dict[str, Any]], initial_usd: float) -> Dict[str, float]: """Calculate portfolio-level metrics.""" final_usd = initial_usd for trade in trades: final_usd *= (1 + trade['profit_pct']) total_fees_usd = sum(trade.get('fee_usd', 0.0) for trade in trades) return { "initial_usd": initial_usd, "final_usd": final_usd, "total_fees_usd": total_fees_usd, "total_return": (final_usd - initial_usd) / initial_usd } class ResultsProcessor: """ Processes and aggregates backtest results. Handles result processing, metric calculation, and aggregation across multiple timeframes and configurations. """ def __init__(self): """Initialize results processor.""" self.logger = logging.getLogger(__name__) def process_backtest_results(self, results: Dict[str, Any], timeframe: str, config: Dict[str, Any], strategy_summary: Dict[str, Any]) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: """ Process results from a single backtest run. Args: results: Raw backtest results timeframe: Timeframe identifier config: Configuration used for the test strategy_summary: Summary of strategies used Returns: Tuple[Dict, List]: (summary_row, trade_rows) """ trades = results.get('trades', []) initial_usd = config['initial_usd'] # Calculate metrics trade_metrics = BacktestMetrics.calculate_trade_metrics(trades) portfolio_metrics = BacktestMetrics.calculate_portfolio_metrics(trades, initial_usd) # Get primary strategy info for reporting primary_strategy = strategy_summary['strategies'][0] if strategy_summary['strategies'] else {} primary_timeframe = primary_strategy.get('timeframes', ['unknown'])[0] stop_loss_pct = primary_strategy.get('params', {}).get('stop_loss_pct', 'N/A') # Create summary row summary_row = { "timeframe": f"{timeframe}({primary_timeframe})", "stop_loss_pct": stop_loss_pct, "n_stop_loss": sum(1 for trade in trades if trade.get('type') == 'STOP_LOSS'), **trade_metrics, **portfolio_metrics } # Create trade rows trade_rows = [] for trade in trades: trade_rows.append({ "timeframe": f"{timeframe}({primary_timeframe})", "stop_loss_pct": stop_loss_pct, "entry_time": trade.get("entry_time"), "exit_time": trade.get("exit_time"), "entry_price": trade.get("entry"), "exit_price": trade.get("exit"), "profit_pct": trade.get("profit_pct"), "type": trade.get("type"), "fee_usd": trade.get("fee_usd"), }) # Log results strategy_names = [s['name'] for s in strategy_summary['strategies']] self.logger.info( f"Timeframe: {timeframe}({primary_timeframe}), Stop Loss: {stop_loss_pct}, " f"Trades: {trade_metrics['n_trades']}, Strategies: {strategy_names}" ) return summary_row, trade_rows def aggregate_results(self, all_rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ Aggregate results per timeframe and stop_loss_pct. Args: all_rows: List of result rows to aggregate Returns: List[Dict]: Aggregated summary rows """ grouped = defaultdict(list) for row in all_rows: key = (row['timeframe'], row['stop_loss_pct']) grouped[key].append(row) summary_rows = [] for (timeframe, stop_loss_pct), rows in grouped.items(): if not rows: continue # Aggregate metrics total_trades = sum(r['n_trades'] for r in rows) total_stop_loss = sum(r['n_stop_loss'] for r in rows) # Average metrics across runs avg_win_rate = np.mean([r['win_rate'] for r in rows]) avg_max_drawdown = np.mean([r['max_drawdown'] for r in rows]) avg_avg_trade = np.mean([r['avg_trade'] for r in rows]) avg_profit_ratio = np.mean([r['profit_ratio'] for r in rows if r['profit_ratio'] != float('inf')]) # Portfolio metrics initial_usd = rows[0]['initial_usd'] # Should be same for all avg_final_usd = np.mean([r['final_usd'] for r in rows]) avg_total_fees_usd = np.mean([r['total_fees_usd'] for r in rows]) summary_rows.append({ "timeframe": timeframe, "stop_loss_pct": stop_loss_pct, "n_trades": total_trades, "n_stop_loss": total_stop_loss, "win_rate": avg_win_rate, "max_drawdown": avg_max_drawdown, "avg_trade": avg_avg_trade, "profit_ratio": avg_profit_ratio, "initial_usd": initial_usd, "final_usd": avg_final_usd, "total_fees_usd": avg_total_fees_usd, }) return summary_rows def create_metadata_lines(self, config_manager, data_1min: pd.DataFrame) -> List[str]: """ Create metadata lines for result files. Args: config_manager: Configuration manager instance data_1min: 1-minute data for price lookups Returns: List[str]: Metadata lines """ start_date = config_manager.start_date stop_date = config_manager.stop_date initial_usd = config_manager.initial_usd def get_nearest_price(df: pd.DataFrame, target_date: str) -> Tuple[Optional[str], Optional[float]]: """Get nearest price for a given date.""" if len(df) == 0: return None, None target_ts = pd.to_datetime(target_date) nearest_idx = df.index.get_indexer([target_ts], method='nearest')[0] nearest_time = df.index[nearest_idx] price = df.iloc[nearest_idx]['close'] return str(nearest_time), price nearest_start_time, start_price = get_nearest_price(data_1min, start_date) nearest_stop_time, stop_price = get_nearest_price(data_1min, stop_date) return [ f"Start date\t{start_date}\tPrice\t{start_price}", f"Stop date\t{stop_date}\tPrice\t{stop_price}", f"Initial USD\t{initial_usd}" ]