""" Reporting and visualization for backtesting. This module generates comprehensive HTML reports with interactive charts showing backtest results, performance metrics, and trade analysis. """ import logging from pathlib import Path from typing import Dict, List, Optional, Any from datetime import datetime import io import base64 import pandas as pd import numpy as np import matplotlib matplotlib.use('Agg') # Use non-interactive backend import matplotlib.pyplot as plt import seaborn as sns from .performance import PerformanceMetrics from .exceptions import ReportingError logger = logging.getLogger(__name__) # Set style sns.set_style("darkgrid") plt.rcParams['figure.figsize'] = (12, 6) class BacktestReporter: """ Generates comprehensive backtest reports. Creates HTML reports with embedded charts and statistics. """ def __init__(self): """Initialize reporter.""" logger.info("BacktestReporter initialized") def generate_html_report( self, output_path: str, metrics: PerformanceMetrics, equity_curve: pd.Series, trades: pd.DataFrame, benchmark: Optional[pd.Series] = None, positions: Optional[pd.DataFrame] = None, config: Optional[Dict[str, Any]] = None, ) -> None: """ Generate HTML report with charts and statistics. Args: output_path: Path to save HTML report metrics: Performance metrics equity_curve: Portfolio value time series trades: DataFrame with trade information benchmark: Optional benchmark time series positions: Optional positions DataFrame config: Optional backtest configuration Raises: ReportingError: If report generation fails """ try: logger.info(f"Generating HTML report: {output_path}") # Generate all charts charts = self._generate_charts( equity_curve, trades, benchmark, positions, metrics, ) # Generate HTML html = self._create_html( metrics, charts, config, ) # Save to file output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, 'w') as f: f.write(html) logger.info(f"HTML report saved to {output_path}") except Exception as e: raise ReportingError(f"Failed to generate HTML report: {e}") def _generate_charts( self, equity_curve: pd.Series, trades: pd.DataFrame, benchmark: Optional[pd.Series], positions: Optional[pd.DataFrame], metrics: PerformanceMetrics, ) -> Dict[str, str]: """Generate all charts and return as base64 encoded images.""" charts = {} # Equity curve charts['equity_curve'] = self._plot_equity_curve(equity_curve, benchmark) # Drawdown chart charts['drawdown'] = self._plot_drawdown(equity_curve) # Monthly returns heatmap charts['monthly_returns'] = self._plot_monthly_returns(equity_curve) # Returns distribution charts['returns_dist'] = self._plot_returns_distribution(equity_curve) # Trade analysis if not trades.empty and 'pnl' in trades.columns: charts['trade_pnl'] = self._plot_trade_pnl(trades) charts['cumulative_pnl'] = self._plot_cumulative_pnl(trades) # Rolling metrics charts['rolling_sharpe'] = self._plot_rolling_sharpe(equity_curve) return charts def _plot_equity_curve( self, equity_curve: pd.Series, benchmark: Optional[pd.Series] = None ) -> str: """Plot equity curve.""" fig, ax = plt.subplots(figsize=(14, 7)) # Normalize to 100 normalized_equity = equity_curve / equity_curve.iloc[0] * 100 ax.plot(normalized_equity.index, normalized_equity.values, label='Strategy', linewidth=2, color='#2E86AB') if benchmark is not None and len(benchmark) > 0: normalized_benchmark = benchmark / benchmark.iloc[0] * 100 ax.plot(normalized_benchmark.index, normalized_benchmark.values, label='Benchmark', linewidth=2, color='#A23B72', alpha=0.7) ax.set_title('Equity Curve', fontsize=16, fontweight='bold') ax.set_xlabel('Date', fontsize=12) ax.set_ylabel('Portfolio Value (Base = 100)', fontsize=12) ax.legend(loc='best', fontsize=11) ax.grid(True, alpha=0.3) plt.tight_layout() return self._fig_to_base64(fig) def _plot_drawdown(self, equity_curve: pd.Series) -> str: """Plot drawdown chart.""" fig, ax = plt.subplots(figsize=(14, 6)) # Calculate drawdown cumulative_max = equity_curve.expanding().max() drawdown = (equity_curve - cumulative_max) / cumulative_max * 100 ax.fill_between(drawdown.index, drawdown.values, 0, alpha=0.6, color='#F18F01', label='Drawdown') ax.plot(drawdown.index, drawdown.values, color='#C73E1D', linewidth=1.5) ax.set_title('Drawdown', fontsize=16, fontweight='bold') ax.set_xlabel('Date', fontsize=12) ax.set_ylabel('Drawdown (%)', fontsize=12) ax.legend(loc='best', fontsize=11) ax.grid(True, alpha=0.3) plt.tight_layout() return self._fig_to_base64(fig) def _plot_monthly_returns(self, equity_curve: pd.Series) -> str: """Plot monthly returns heatmap.""" # Calculate monthly returns monthly = equity_curve.resample('M').last() monthly_returns = monthly.pct_change().dropna() * 100 if len(monthly_returns) < 2: # Not enough data for heatmap fig, ax = plt.subplots(figsize=(12, 6)) ax.text(0.5, 0.5, 'Insufficient data for monthly returns', ha='center', va='center', fontsize=14) ax.axis('off') return self._fig_to_base64(fig) # Create pivot table monthly_df = pd.DataFrame({ 'return': monthly_returns, 'year': monthly_returns.index.year, 'month': monthly_returns.index.month, }) pivot = monthly_df.pivot(index='year', columns='month', values='return') # Create heatmap fig, ax = plt.subplots(figsize=(14, max(6, len(pivot) * 0.5))) sns.heatmap(pivot, annot=True, fmt='.1f', cmap='RdYlGn', center=0, cbar_kws={'label': 'Return (%)'}, ax=ax, linewidths=0.5, linecolor='gray') ax.set_title('Monthly Returns (%)', fontsize=16, fontweight='bold') ax.set_xlabel('Month', fontsize=12) ax.set_ylabel('Year', fontsize=12) # Month labels month_labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] ax.set_xticklabels(month_labels[:len(pivot.columns)], rotation=0) plt.tight_layout() return self._fig_to_base64(fig) def _plot_returns_distribution(self, equity_curve: pd.Series) -> str: """Plot returns distribution.""" returns = equity_curve.pct_change().dropna() * 100 fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) # Histogram ax1.hist(returns, bins=50, alpha=0.7, color='#2E86AB', edgecolor='black') ax1.axvline(returns.mean(), color='red', linestyle='--', linewidth=2, label=f'Mean: {returns.mean():.2f}%') ax1.set_title('Returns Distribution', fontsize=14, fontweight='bold') ax1.set_xlabel('Daily Return (%)', fontsize=12) ax1.set_ylabel('Frequency', fontsize=12) ax1.legend() ax1.grid(True, alpha=0.3) # Q-Q plot from scipy import stats stats.probplot(returns, dist="norm", plot=ax2) ax2.set_title('Q-Q Plot', fontsize=14, fontweight='bold') ax2.grid(True, alpha=0.3) plt.tight_layout() return self._fig_to_base64(fig) def _plot_trade_pnl(self, trades: pd.DataFrame) -> str: """Plot trade P&L.""" fig, ax = plt.subplots(figsize=(14, 6)) pnl = trades['pnl'].values colors = ['green' if p > 0 else 'red' for p in pnl] ax.bar(range(len(pnl)), pnl, color=colors, alpha=0.7) ax.axhline(0, color='black', linewidth=1) ax.set_title('Trade P&L', fontsize=16, fontweight='bold') ax.set_xlabel('Trade Number', fontsize=12) ax.set_ylabel('P&L', fontsize=12) ax.grid(True, alpha=0.3) plt.tight_layout() return self._fig_to_base64(fig) def _plot_cumulative_pnl(self, trades: pd.DataFrame) -> str: """Plot cumulative P&L.""" fig, ax = plt.subplots(figsize=(14, 6)) cumulative_pnl = trades['pnl'].cumsum() ax.plot(cumulative_pnl.index, cumulative_pnl.values, linewidth=2, color='#2E86AB') ax.fill_between(cumulative_pnl.index, cumulative_pnl.values, 0, alpha=0.3, color='#2E86AB') ax.axhline(0, color='black', linewidth=1) ax.set_title('Cumulative P&L', fontsize=16, fontweight='bold') ax.set_xlabel('Trade Number', fontsize=12) ax.set_ylabel('Cumulative P&L', fontsize=12) ax.grid(True, alpha=0.3) plt.tight_layout() return self._fig_to_base64(fig) def _plot_rolling_sharpe(self, equity_curve: pd.Series, window: int = 252) -> str: """Plot rolling Sharpe ratio.""" returns = equity_curve.pct_change().dropna() if len(returns) < window: fig, ax = plt.subplots(figsize=(12, 6)) ax.text(0.5, 0.5, 'Insufficient data for rolling Sharpe', ha='center', va='center', fontsize=14) ax.axis('off') return self._fig_to_base64(fig) # Calculate rolling Sharpe rolling_sharpe = ( returns.rolling(window).mean() * 252 / (returns.rolling(window).std() * np.sqrt(252)) ) fig, ax = plt.subplots(figsize=(14, 6)) ax.plot(rolling_sharpe.index, rolling_sharpe.values, linewidth=2, color='#2E86AB') ax.axhline(0, color='black', linewidth=1, linestyle='--') ax.axhline(1, color='green', linewidth=1, linestyle='--', alpha=0.5) ax.set_title(f'Rolling Sharpe Ratio ({window}-day)', fontsize=16, fontweight='bold') ax.set_xlabel('Date', fontsize=12) ax.set_ylabel('Sharpe Ratio', fontsize=12) ax.grid(True, alpha=0.3) plt.tight_layout() return self._fig_to_base64(fig) def _fig_to_base64(self, fig) -> str: """Convert matplotlib figure to base64 string.""" buffer = io.BytesIO() fig.savefig(buffer, format='png', dpi=100, bbox_inches='tight') buffer.seek(0) image_base64 = base64.b64encode(buffer.read()).decode() plt.close(fig) return f"data:image/png;base64,{image_base64}" def _create_html( self, metrics: PerformanceMetrics, charts: Dict[str, str], config: Optional[Dict[str, Any]] = None, ) -> str: """Create HTML report.""" html = f"""
Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}