"""Report Generator for backtest results. Issue #44: [BT-43] Report generator - PDF/HTML reports This module provides report generation for backtest results: - HTML report generation with embedded charts - PDF report generation - Performance summary sections - Trade analysis tables - Equity curve visualization - Drawdown charts - Monthly/yearly performance heatmaps Classes: ReportFormat: Output format enum ReportSection: Section configuration ChartType: Chart type enum ReportConfig: Report configuration ChartData: Chart data container ReportContent: Report content container ReportResult: Generation result ReportGenerator: Main generator class Example: >>> from tradingagents.backtest import BacktestResult >>> from tradingagents.backtest.report_generator import ReportGenerator >>> >>> generator = ReportGenerator() >>> result = generator.generate_html(backtest_result, "report.html") >>> print(f"Report saved to: {result.output_path}") """ from dataclasses import dataclass, field from datetime import datetime, timedelta from decimal import Decimal from enum import Enum from pathlib import Path from typing import Any, Optional, Callable import base64 import html import io import logging from .backtest_engine import ( BacktestResult, BacktestTrade, BacktestSnapshot, OrderSide, ZERO, ONE, HUNDRED, ) from .results_analyzer import ( ResultsAnalyzer, AnalysisResult, TradeStatistics, RiskMetrics, DrawdownAnalysis, PerformanceBreakdown, TimeFrame, ) logger = logging.getLogger(__name__) # ============================================================================ # Enums # ============================================================================ class ReportFormat(Enum): """Report output format.""" HTML = "html" PDF = "pdf" JSON = "json" MARKDOWN = "markdown" class ReportSection(Enum): """Report sections.""" SUMMARY = "summary" EQUITY_CURVE = "equity_curve" DRAWDOWN = "drawdown" MONTHLY_RETURNS = "monthly_returns" TRADE_LIST = "trade_list" TRADE_ANALYSIS = "trade_analysis" RISK_METRICS = "risk_metrics" STATISTICS = "statistics" BENCHMARK = "benchmark" class ChartType(Enum): """Chart types for visualization.""" LINE = "line" BAR = "bar" HEATMAP = "heatmap" PIE = "pie" SCATTER = "scatter" HISTOGRAM = "histogram" # ============================================================================ # Data Classes # ============================================================================ @dataclass class ReportConfig: """Configuration for report generation. Attributes: title: Report title author: Report author name include_sections: Sections to include chart_width: Default chart width in pixels chart_height: Default chart height in pixels decimal_places: Decimal places for formatting include_trade_list: Include individual trade list max_trades_shown: Maximum trades to show in list color_scheme: Color scheme for charts logo_path: Optional path to logo image footer_text: Optional footer text include_timestamp: Include generation timestamp """ title: str = "Backtest Report" author: str = "" include_sections: list[ReportSection] = field( default_factory=lambda: list(ReportSection) ) chart_width: int = 800 chart_height: int = 400 decimal_places: int = 2 include_trade_list: bool = True max_trades_shown: int = 100 color_scheme: dict[str, str] = field(default_factory=lambda: { "primary": "#2563eb", "secondary": "#7c3aed", "success": "#16a34a", "danger": "#dc2626", "warning": "#ca8a04", "background": "#f8fafc", "text": "#1e293b", "border": "#e2e8f0", }) logo_path: Optional[Path] = None footer_text: str = "Generated by TradingAgents Backtest Engine" include_timestamp: bool = True @dataclass class ChartData: """Data container for chart generation. Attributes: chart_type: Type of chart title: Chart title x_data: X-axis data points y_data: Y-axis data points (or list of series) x_label: X-axis label y_label: Y-axis label series_names: Names for multiple series colors: Colors for series annotations: Chart annotations """ chart_type: ChartType title: str x_data: list[Any] y_data: list[Any] x_label: str = "" y_label: str = "" series_names: list[str] = field(default_factory=list) colors: list[str] = field(default_factory=list) annotations: list[dict[str, Any]] = field(default_factory=list) @dataclass class ReportContent: """Container for report content. Attributes: title: Report title generated_at: Generation timestamp sections: Section content by name charts: Chart data by name metadata: Additional metadata """ title: str generated_at: datetime sections: dict[str, Any] = field(default_factory=dict) charts: dict[str, ChartData] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict) @dataclass class ReportResult: """Result of report generation. Attributes: success: Whether generation succeeded output_path: Path to generated report format: Report format file_size_bytes: Size of generated file generation_time_ms: Time taken to generate error: Error message if failed warnings: Any warnings during generation """ success: bool output_path: Optional[Path] = None format: ReportFormat = ReportFormat.HTML file_size_bytes: int = 0 generation_time_ms: int = 0 error: Optional[str] = None warnings: list[str] = field(default_factory=list) # ============================================================================ # HTML Templates # ============================================================================ HTML_TEMPLATE = """ {title}
{header} {content} {footer}
""" # ============================================================================ # Main Class # ============================================================================ class ReportGenerator: """Generator for backtest reports. Generates professional reports from backtest results in various formats including HTML and PDF with embedded charts and analysis. Attributes: config: Report configuration analyzer: Results analyzer for metrics Example: >>> generator = ReportGenerator() >>> result = generator.generate_html(backtest_result, "report.html") >>> if result.success: ... print(f"Report saved to {result.output_path}") """ def __init__( self, config: Optional[ReportConfig] = None, ) -> None: """Initialize report generator. Args: config: Report configuration """ self.config = config or ReportConfig() self.analyzer = ResultsAnalyzer() def generate_html( self, result: BacktestResult, output_path: str | Path, analysis: Optional[AnalysisResult] = None, ) -> ReportResult: """Generate HTML report. Args: result: Backtest result to report on output_path: Path to save HTML file analysis: Pre-computed analysis (optional) Returns: ReportResult with generation status """ start_time = datetime.now() output_path = Path(output_path) try: # Analyze results if not provided if analysis is None: analysis = self.analyzer.analyze(result) # Build content content = self._build_content(result, analysis) # Generate HTML html_content = self._render_html(content) # Write file output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(html_content, encoding="utf-8") generation_time = int((datetime.now() - start_time).total_seconds() * 1000) return ReportResult( success=True, output_path=output_path, format=ReportFormat.HTML, file_size_bytes=output_path.stat().st_size, generation_time_ms=generation_time, ) except Exception as e: logger.error(f"Failed to generate HTML report: {e}") return ReportResult( success=False, format=ReportFormat.HTML, error=str(e), ) def generate_pdf( self, result: BacktestResult, output_path: str | Path, analysis: Optional[AnalysisResult] = None, ) -> ReportResult: """Generate PDF report. Note: Requires weasyprint or similar library for PDF generation. Falls back to HTML if PDF libraries not available. Args: result: Backtest result to report on output_path: Path to save PDF file analysis: Pre-computed analysis (optional) Returns: ReportResult with generation status """ start_time = datetime.now() output_path = Path(output_path) try: # Analyze results if not provided if analysis is None: analysis = self.analyzer.analyze(result) # Build content content = self._build_content(result, analysis) # Generate HTML first html_content = self._render_html(content) # Try to convert to PDF pdf_generated = self._html_to_pdf(html_content, output_path) if not pdf_generated: # Fallback to HTML html_path = output_path.with_suffix(".html") html_path.write_text(html_content, encoding="utf-8") generation_time = int((datetime.now() - start_time).total_seconds() * 1000) return ReportResult( success=True, output_path=html_path, format=ReportFormat.HTML, file_size_bytes=html_path.stat().st_size, generation_time_ms=generation_time, warnings=["PDF generation not available, saved as HTML"], ) generation_time = int((datetime.now() - start_time).total_seconds() * 1000) return ReportResult( success=True, output_path=output_path, format=ReportFormat.PDF, file_size_bytes=output_path.stat().st_size, generation_time_ms=generation_time, ) except Exception as e: logger.error(f"Failed to generate PDF report: {e}") return ReportResult( success=False, format=ReportFormat.PDF, error=str(e), ) def generate_json( self, result: BacktestResult, output_path: str | Path, analysis: Optional[AnalysisResult] = None, ) -> ReportResult: """Generate JSON report. Args: result: Backtest result to report on output_path: Path to save JSON file analysis: Pre-computed analysis (optional) Returns: ReportResult with generation status """ import json start_time = datetime.now() output_path = Path(output_path) try: # Analyze results if not provided if analysis is None: analysis = self.analyzer.analyze(result) # Build JSON structure data = self._build_json_data(result, analysis) # Write file output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text( json.dumps(data, indent=2, default=str), encoding="utf-8", ) generation_time = int((datetime.now() - start_time).total_seconds() * 1000) return ReportResult( success=True, output_path=output_path, format=ReportFormat.JSON, file_size_bytes=output_path.stat().st_size, generation_time_ms=generation_time, ) except Exception as e: logger.error(f"Failed to generate JSON report: {e}") return ReportResult( success=False, format=ReportFormat.JSON, error=str(e), ) def generate_markdown( self, result: BacktestResult, output_path: str | Path, analysis: Optional[AnalysisResult] = None, ) -> ReportResult: """Generate Markdown report. Args: result: Backtest result to report on output_path: Path to save Markdown file analysis: Pre-computed analysis (optional) Returns: ReportResult with generation status """ start_time = datetime.now() output_path = Path(output_path) try: # Analyze results if not provided if analysis is None: analysis = self.analyzer.analyze(result) # Build markdown content md_content = self._build_markdown(result, analysis) # Write file output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(md_content, encoding="utf-8") generation_time = int((datetime.now() - start_time).total_seconds() * 1000) return ReportResult( success=True, output_path=output_path, format=ReportFormat.MARKDOWN, file_size_bytes=output_path.stat().st_size, generation_time_ms=generation_time, ) except Exception as e: logger.error(f"Failed to generate Markdown report: {e}") return ReportResult( success=False, format=ReportFormat.MARKDOWN, error=str(e), ) def generate( self, result: BacktestResult, output_path: str | Path, format: ReportFormat = ReportFormat.HTML, analysis: Optional[AnalysisResult] = None, ) -> ReportResult: """Generate report in specified format. Args: result: Backtest result to report on output_path: Path to save report format: Output format analysis: Pre-computed analysis (optional) Returns: ReportResult with generation status """ generators = { ReportFormat.HTML: self.generate_html, ReportFormat.PDF: self.generate_pdf, ReportFormat.JSON: self.generate_json, ReportFormat.MARKDOWN: self.generate_markdown, } generator = generators.get(format, self.generate_html) return generator(result, output_path, analysis) # ======================================================================== # Content Building # ======================================================================== def _build_content( self, result: BacktestResult, analysis: AnalysisResult, ) -> ReportContent: """Build report content from backtest results.""" content = ReportContent( title=self.config.title, generated_at=datetime.now(), ) # Metadata content.metadata = { "start_date": result.start_date.isoformat() if result.start_date else None, "end_date": result.end_date.isoformat() if result.end_date else None, "initial_capital": str(result.initial_capital), "final_capital": str(result.final_value), "total_trades": result.total_trades, "author": self.config.author, } # Summary section if ReportSection.SUMMARY in self.config.include_sections: content.sections["summary"] = self._build_summary_section(result, analysis) # Statistics section if ReportSection.STATISTICS in self.config.include_sections: content.sections["statistics"] = self._build_statistics_section(analysis) # Risk metrics section if ReportSection.RISK_METRICS in self.config.include_sections: content.sections["risk_metrics"] = self._build_risk_section(analysis) # Trade list section if ReportSection.TRADE_LIST in self.config.include_sections: content.sections["trade_list"] = self._build_trade_list_section(result) # Monthly returns section if ReportSection.MONTHLY_RETURNS in self.config.include_sections: content.sections["monthly_returns"] = self._build_monthly_section(analysis) # Build charts if ReportSection.EQUITY_CURVE in self.config.include_sections: content.charts["equity_curve"] = self._build_equity_chart(result) if ReportSection.DRAWDOWN in self.config.include_sections: content.charts["drawdown"] = self._build_drawdown_chart(result) return content def _build_summary_section( self, result: BacktestResult, analysis: AnalysisResult, ) -> dict[str, Any]: """Build summary metrics section.""" return { "total_return": result.total_return, "total_return_pct": result.total_return, "final_capital": result.final_value, "initial_capital": result.initial_capital, "total_trades": result.total_trades, "winning_trades": result.winning_trades, "losing_trades": result.losing_trades, "win_rate": result.win_rate, "profit_factor": result.profit_factor, "max_drawdown": result.max_drawdown, "max_drawdown_pct": result.max_drawdown, "sharpe_ratio": result.sharpe_ratio, "sortino_ratio": result.sortino_ratio, "total_commission": result.total_commission, "total_slippage": result.total_slippage, } def _build_statistics_section( self, analysis: AnalysisResult, ) -> dict[str, Any]: """Build trade statistics section.""" stats = analysis.trade_statistics return { "total_trades": stats.total_trades, "winning_trades": stats.winning_trades, "losing_trades": stats.losing_trades, "win_rate": stats.win_rate, "profit_factor": stats.profit_factor, "avg_win": stats.avg_win, "avg_loss": stats.avg_loss, "largest_win": stats.max_win, "largest_loss": stats.max_loss, "avg_trade_pnl": stats.avg_trade, "median_trade_pnl": stats.median_trade, "max_consecutive_wins": stats.max_consecutive_wins, "max_consecutive_losses": stats.max_consecutive_losses, "avg_holding_period_days": stats.avg_holding_period, "expectancy": stats.expectancy, } def _build_risk_section( self, analysis: AnalysisResult, ) -> dict[str, Any]: """Build risk metrics section.""" risk = analysis.risk_metrics return { "sharpe_ratio": risk.sharpe_ratio, "sortino_ratio": risk.sortino_ratio, "calmar_ratio": risk.calmar_ratio, "max_drawdown": risk.max_drawdown, "max_drawdown_duration": risk.max_drawdown_duration, "var_95": risk.var_95, "cvar_95": risk.cvar_95, "ulcer_index": risk.ulcer_index, "recovery_factor": risk.recovery_factor, } def _build_trade_list_section( self, result: BacktestResult, ) -> list[dict[str, Any]]: """Build trade list section.""" trades = [] for i, trade in enumerate(result.trades[:self.config.max_trades_shown]): trades.append({ "id": i + 1, "timestamp": trade.timestamp.isoformat() if trade.timestamp else "", "symbol": trade.symbol, "side": trade.side.value if trade.side else "", "quantity": str(trade.quantity), "price": str(trade.price), "value": str(trade.price * trade.quantity), "commission": str(trade.commission), "slippage": str(trade.slippage), "pnl": str(trade.pnl), }) return trades def _build_monthly_section( self, analysis: AnalysisResult, ) -> dict[str, Any]: """Build monthly returns section.""" monthly_data: dict[str, dict[str, Decimal]] = {} for breakdown in analysis.monthly_performance: # Parse period string like "2023-01" into year and month try: parts = breakdown.period.split("-") if len(parts) >= 2: year = parts[0] month = int(parts[1]) else: year = parts[0] month = 0 except (ValueError, AttributeError): continue if year not in monthly_data: monthly_data[year] = {} monthly_data[year][str(month)] = breakdown.return_pct return { "monthly_returns": monthly_data, "years": sorted(monthly_data.keys()), } def _build_equity_chart( self, result: BacktestResult, ) -> ChartData: """Build equity curve chart data.""" x_data = [] y_data = [] for snapshot in result.snapshots: x_data.append(snapshot.timestamp.isoformat() if snapshot.timestamp else "") y_data.append(float(snapshot.total_value)) return ChartData( chart_type=ChartType.LINE, title="Equity Curve", x_data=x_data, y_data=y_data, x_label="Date", y_label="Portfolio Value", colors=[self.config.color_scheme["primary"]], ) def _build_drawdown_chart( self, result: BacktestResult, ) -> ChartData: """Build drawdown chart data.""" x_data = [] y_data = [] peak = ZERO for snapshot in result.snapshots: equity = snapshot.total_value if equity > peak: peak = equity drawdown = ((peak - equity) / peak * HUNDRED) if peak > ZERO else ZERO x_data.append(snapshot.timestamp.isoformat() if snapshot.timestamp else "") y_data.append(float(drawdown)) return ChartData( chart_type=ChartType.LINE, title="Drawdown", x_data=x_data, y_data=y_data, x_label="Date", y_label="Drawdown %", colors=[self.config.color_scheme["danger"]], ) # ======================================================================== # Rendering # ======================================================================== def _render_html(self, content: ReportContent) -> str: """Render content to HTML.""" # Header header_html = f"""

{html.escape(content.title)}

Generated: {content.generated_at.strftime('%Y-%m-%d %H:%M:%S')} {f' | Author: {html.escape(self.config.author)}' if self.config.author else ''}
""" # Content sections sections_html = [] # Summary section if "summary" in content.sections: sections_html.append(self._render_summary_html(content.sections["summary"])) # Equity curve chart if "equity_curve" in content.charts: sections_html.append(self._render_chart_html(content.charts["equity_curve"])) # Drawdown chart if "drawdown" in content.charts: sections_html.append(self._render_chart_html(content.charts["drawdown"])) # Statistics section if "statistics" in content.sections: sections_html.append(self._render_statistics_html(content.sections["statistics"])) # Risk metrics section if "risk_metrics" in content.sections: sections_html.append(self._render_risk_html(content.sections["risk_metrics"])) # Monthly returns section if "monthly_returns" in content.sections: sections_html.append(self._render_monthly_html(content.sections["monthly_returns"])) # Trade list section if "trade_list" in content.sections: sections_html.append(self._render_trade_list_html(content.sections["trade_list"])) # Footer footer_html = f""" """ # Render full HTML return HTML_TEMPLATE.format( title=html.escape(content.title), color_primary=self.config.color_scheme["primary"], color_secondary=self.config.color_scheme["secondary"], color_success=self.config.color_scheme["success"], color_danger=self.config.color_scheme["danger"], color_warning=self.config.color_scheme["warning"], color_background=self.config.color_scheme["background"], color_text=self.config.color_scheme["text"], color_border=self.config.color_scheme["border"], header=header_html, content="\n".join(sections_html), footer=footer_html, ) def _render_summary_html(self, data: dict[str, Any]) -> str: """Render summary section to HTML.""" dp = self.config.decimal_places def format_value(val: Any, is_pct: bool = False) -> str: if isinstance(val, Decimal): formatted = f"{float(val):,.{dp}f}" elif isinstance(val, (int, float)): formatted = f"{val:,.{dp}f}" else: formatted = str(val) return f"{formatted}%" if is_pct else formatted def value_class(val: Any) -> str: try: return "positive" if float(val) > 0 else "negative" if float(val) < 0 else "" except (ValueError, TypeError): return "" metrics = [ ("Total Return", data.get("total_return_pct", 0), True, True), ("Final Capital", data.get("final_capital", 0), False, False), ("Total Trades", data.get("total_trades", 0), False, False), ("Win Rate", data.get("win_rate", 0), True, False), ("Profit Factor", data.get("profit_factor", 0), False, False), ("Max Drawdown", data.get("max_drawdown_pct", 0), True, True), ("Sharpe Ratio", data.get("sharpe_ratio", 0), False, False), ("Sortino Ratio", data.get("sortino_ratio", 0), False, False), ] cards_html = [] for label, value, is_pct, has_sign in metrics: val_class = value_class(value) if has_sign else "" cards_html.append(f"""
{format_value(value, is_pct)}
{html.escape(label)}
""") return f"""

Summary

{''.join(cards_html)}
""" def _render_statistics_html(self, data: dict[str, Any]) -> str: """Render trade statistics section to HTML.""" dp = self.config.decimal_places rows = [ ("Total Trades", data.get("total_trades", 0)), ("Winning Trades", data.get("winning_trades", 0)), ("Losing Trades", data.get("losing_trades", 0)), ("Win Rate", f"{float(data.get('win_rate', 0)):,.{dp}f}%"), ("Profit Factor", f"{float(data.get('profit_factor', 0)):,.{dp}f}"), ("Average Win", f"${float(data.get('avg_win', 0)):,.{dp}f}"), ("Average Loss", f"${float(data.get('avg_loss', 0)):,.{dp}f}"), ("Largest Win", f"${float(data.get('largest_win', 0)):,.{dp}f}"), ("Largest Loss", f"${float(data.get('largest_loss', 0)):,.{dp}f}"), ("Avg Trade P&L", f"${float(data.get('avg_trade_pnl', 0)):,.{dp}f}"), ("Max Consecutive Wins", data.get("max_consecutive_wins", 0)), ("Max Consecutive Losses", data.get("max_consecutive_losses", 0)), ("Avg Holding Period", f"{float(data.get('avg_holding_period_days', 0)):,.1f} days"), ("Expectancy", f"${float(data.get('expectancy', 0)):,.{dp}f}"), ] rows_html = "\n".join([ f"{html.escape(str(label))}{html.escape(str(value))}" for label, value in rows ]) return f"""

Trade Statistics

{rows_html}
MetricValue
""" def _render_risk_html(self, data: dict[str, Any]) -> str: """Render risk metrics section to HTML.""" dp = self.config.decimal_places rows = [ ("Sharpe Ratio", f"{float(data.get('sharpe_ratio', 0)):,.{dp}f}"), ("Sortino Ratio", f"{float(data.get('sortino_ratio', 0)):,.{dp}f}"), ("Calmar Ratio", f"{float(data.get('calmar_ratio', 0)):,.{dp}f}"), ("Max Drawdown %", f"{float(data.get('max_drawdown', 0)):,.{dp}f}%"), ("Max Drawdown Duration", f"{data.get('max_drawdown_duration', 0)} days"), ("VaR (95%)", f"${float(data.get('var_95', 0)):,.{dp}f}"), ("CVaR (95%)", f"${float(data.get('cvar_95', 0)):,.{dp}f}"), ("Ulcer Index", f"{float(data.get('ulcer_index', 0)):,.{dp}f}"), ("Recovery Factor", f"{float(data.get('recovery_factor', 0)):,.{dp}f}"), ] rows_html = "\n".join([ f"{html.escape(str(label))}{html.escape(str(value))}" for label, value in rows ]) return f"""

Risk Metrics

{rows_html}
MetricValue
""" def _render_monthly_html(self, data: dict[str, Any]) -> str: """Render monthly returns heatmap to HTML.""" monthly_returns = data.get("monthly_returns", {}) years = data.get("years", []) months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] # Header row header_cells = ['
Year
'] for month in months: header_cells.append(f'
{month}
') # Data rows data_rows = [] for year in years: row_cells = [f'
{year}
'] year_data = monthly_returns.get(year, {}) for month_num in range(1, 13): value = year_data.get(str(month_num), Decimal("0")) val_float = float(value) # Color based on value if val_float > 5: color = "#15803d" # Dark green elif val_float > 2: color = "#22c55e" # Green elif val_float > 0: color = "#86efac" # Light green elif val_float > -2: color = "#fca5a5" # Light red elif val_float > -5: color = "#ef4444" # Red else: color = "#b91c1c" # Dark red cell_text = f"{val_float:+.1f}%" if value != ZERO else "-" row_cells.append( f'
{cell_text}
' ) data_rows.append("".join(row_cells)) return f"""

Monthly Returns

{''.join(header_cells)} {''.join(data_rows)}
""" def _render_trade_list_html(self, trades: list[dict[str, Any]]) -> str: """Render trade list section to HTML.""" if not trades: return """

Trade List

No trades executed.

""" rows_html = [] for trade in trades: pnl = float(trade.get("pnl", 0)) pnl_class = "positive" if pnl > 0 else "negative" if pnl < 0 else "" rows_html.append(f""" {html.escape(str(trade.get('id', '')))} {html.escape(str(trade.get('timestamp', '')))} {html.escape(str(trade.get('symbol', '')))} {html.escape(str(trade.get('side', '')))} {html.escape(str(trade.get('quantity', '')))} ${float(trade.get('price', 0)):,.2f} ${float(trade.get('value', 0)):,.2f} ${pnl:,.2f} """) return f"""

Trade List

{''.join(rows_html)}
# Timestamp Symbol Side Quantity Price Value P&L
""" def _render_chart_html(self, chart: ChartData) -> str: """Render chart to HTML with SVG.""" if not chart.x_data or not chart.y_data: return f"""

{html.escape(chart.title)}

No data available
""" # Generate simple SVG chart svg = self._generate_svg_chart(chart) return f"""

{html.escape(chart.title)}

{svg}
""" def _generate_svg_chart(self, chart: ChartData) -> str: """Generate SVG chart from chart data.""" width = self.config.chart_width height = self.config.chart_height padding = 60 # Calculate data bounds y_values = [float(y) for y in chart.y_data if y is not None] if not y_values: return '
No data available
' y_min = min(y_values) y_max = max(y_values) y_range = y_max - y_min if y_max != y_min else 1 # Scale data to SVG coordinates plot_width = width - 2 * padding plot_height = height - 2 * padding points = [] for i, y in enumerate(y_values): x = padding + (i / max(len(y_values) - 1, 1)) * plot_width y_scaled = height - padding - ((y - y_min) / y_range) * plot_height points.append(f"{x:.1f},{y_scaled:.1f}") polyline_points = " ".join(points) color = chart.colors[0] if chart.colors else self.config.color_scheme["primary"] # Generate SVG svg = f""" {y_max:,.0f} {y_min:,.0f} {html.escape(chart.x_label)} {html.escape(chart.y_label)} """ return svg # ======================================================================== # PDF Generation # ======================================================================== def _html_to_pdf(self, html_content: str, output_path: Path) -> bool: """Convert HTML to PDF. Returns True if successful, False if PDF libraries not available. """ try: # Try weasyprint first from weasyprint import HTML HTML(string=html_content).write_pdf(output_path) return True except ImportError: pass try: # Try pdfkit as fallback import pdfkit pdfkit.from_string(html_content, str(output_path)) return True except ImportError: pass logger.warning("No PDF library available (weasyprint or pdfkit)") return False # ======================================================================== # JSON Generation # ======================================================================== def _build_json_data( self, result: BacktestResult, analysis: AnalysisResult, ) -> dict[str, Any]: """Build JSON data structure from backtest results.""" return { "report": { "title": self.config.title, "generated_at": datetime.now().isoformat(), "author": self.config.author, }, "backtest": { "start_date": result.start_date.isoformat() if result.start_date else None, "end_date": result.end_date.isoformat() if result.end_date else None, "initial_capital": str(result.initial_capital), "final_capital": str(result.final_value), }, "performance": { "total_return": str(result.total_return), "total_return_pct": str(result.total_return), "win_rate": str(result.win_rate), "profit_factor": str(result.profit_factor), "max_drawdown": str(result.max_drawdown), "max_drawdown_pct": str(result.max_drawdown), "sharpe_ratio": str(result.sharpe_ratio), "sortino_ratio": str(result.sortino_ratio), }, "trades": { "total": result.total_trades, "winning": result.winning_trades, "losing": result.losing_trades, "total_commission": str(result.total_commission), "total_slippage": str(result.total_slippage), }, "trade_statistics": { "avg_win": str(analysis.trade_statistics.avg_win), "avg_loss": str(analysis.trade_statistics.avg_loss), "largest_win": str(analysis.trade_statistics.max_win), "largest_loss": str(analysis.trade_statistics.max_loss), "expectancy": str(analysis.trade_statistics.expectancy), "max_consecutive_wins": analysis.trade_statistics.max_consecutive_wins, "max_consecutive_losses": analysis.trade_statistics.max_consecutive_losses, }, "risk_metrics": { "sharpe_ratio": str(analysis.risk_metrics.sharpe_ratio), "sortino_ratio": str(analysis.risk_metrics.sortino_ratio), "calmar_ratio": str(analysis.risk_metrics.calmar_ratio), "max_drawdown": str(analysis.risk_metrics.max_drawdown), "var_95": str(analysis.risk_metrics.var_95), "cvar_95": str(analysis.risk_metrics.cvar_95), "ulcer_index": str(analysis.risk_metrics.ulcer_index), }, "equity_curve": [ { "timestamp": s.timestamp.isoformat() if s.timestamp else None, "equity": str(s.total_value), } for s in result.snapshots ], "trade_list": [ { "timestamp": t.timestamp.isoformat() if t.timestamp else None, "symbol": t.symbol, "side": t.side.value if t.side else None, "quantity": str(t.quantity), "price": str(t.price), "pnl": str(t.pnl), } for t in result.trades[:self.config.max_trades_shown] ], } # ======================================================================== # Markdown Generation # ======================================================================== def _build_markdown( self, result: BacktestResult, analysis: AnalysisResult, ) -> str: """Build Markdown report from backtest results.""" dp = self.config.decimal_places lines = [] # Title lines.append(f"# {self.config.title}") lines.append("") lines.append(f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") if self.config.author: lines.append(f"**Author:** {self.config.author}") lines.append("") # Summary lines.append("## Summary") lines.append("") lines.append(f"| Metric | Value |") lines.append(f"|--------|-------|") lines.append(f"| Initial Capital | ${float(result.initial_capital):,.{dp}f} |") lines.append(f"| Final Capital | ${float(result.final_value):,.{dp}f} |") lines.append(f"| Total Return | {float(result.total_return):,.{dp}f}% |") lines.append(f"| Total Trades | {result.total_trades} |") lines.append(f"| Win Rate | {float(result.win_rate):,.{dp}f}% |") lines.append(f"| Profit Factor | {float(result.profit_factor):,.{dp}f} |") lines.append(f"| Max Drawdown | {float(result.max_drawdown):,.{dp}f}% |") lines.append(f"| Sharpe Ratio | {float(result.sharpe_ratio):,.{dp}f} |") lines.append("") # Trade Statistics lines.append("## Trade Statistics") lines.append("") stats = analysis.trade_statistics lines.append(f"| Metric | Value |") lines.append(f"|--------|-------|") lines.append(f"| Winning Trades | {stats.winning_trades} |") lines.append(f"| Losing Trades | {stats.losing_trades} |") lines.append(f"| Average Win | ${float(stats.avg_win):,.{dp}f} |") lines.append(f"| Average Loss | ${float(stats.avg_loss):,.{dp}f} |") lines.append(f"| Largest Win | ${float(stats.max_win):,.{dp}f} |") lines.append(f"| Largest Loss | ${float(stats.max_loss):,.{dp}f} |") lines.append(f"| Max Consecutive Wins | {stats.max_consecutive_wins} |") lines.append(f"| Max Consecutive Losses | {stats.max_consecutive_losses} |") lines.append(f"| Expectancy | ${float(stats.expectancy):,.{dp}f} |") lines.append("") # Risk Metrics lines.append("## Risk Metrics") lines.append("") risk = analysis.risk_metrics lines.append(f"| Metric | Value |") lines.append(f"|--------|-------|") lines.append(f"| Sharpe Ratio | {float(risk.sharpe_ratio):,.{dp}f} |") lines.append(f"| Sortino Ratio | {float(risk.sortino_ratio):,.{dp}f} |") lines.append(f"| Calmar Ratio | {float(risk.calmar_ratio):,.{dp}f} |") lines.append(f"| Max Drawdown | {float(risk.max_drawdown):,.{dp}f}% |") lines.append(f"| VaR (95%) | ${float(risk.var_95):,.{dp}f} |") lines.append(f"| CVaR (95%) | ${float(risk.cvar_95):,.{dp}f} |") lines.append(f"| Ulcer Index | {float(risk.ulcer_index):,.{dp}f} |") lines.append("") # Recent Trades if result.trades: lines.append("## Recent Trades") lines.append("") lines.append("| # | Symbol | Side | Quantity | Price | P&L |") lines.append("|---|--------|------|----------|-------|-----|") for i, trade in enumerate(result.trades[:20]): pnl = float(trade.pnl) pnl_str = f"${pnl:,.{dp}f}" if pnl >= 0 else f"-${abs(pnl):,.{dp}f}" lines.append( f"| {i + 1} | {trade.symbol} | {trade.side.value if trade.side else ''} | " f"{float(trade.quantity):,.2f} | ${float(trade.price):,.{dp}f} | {pnl_str} |" ) lines.append("") # Footer lines.append("---") lines.append(f"*{self.config.footer_text}*") return "\n".join(lines) # ============================================================================ # Factory Functions # ============================================================================ def create_report_generator( title: str = "Backtest Report", author: str = "", include_trade_list: bool = True, max_trades: int = 100, ) -> ReportGenerator: """Create a configured report generator. Args: title: Report title author: Author name include_trade_list: Whether to include trade list max_trades: Maximum trades to show Returns: Configured ReportGenerator """ config = ReportConfig( title=title, author=author, include_trade_list=include_trade_list, max_trades_shown=max_trades, ) return ReportGenerator(config=config)