TradingAgents/tradingagents/backtest/report_generator.py

1498 lines
51 KiB
Python

"""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 = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
:root {{
--primary: {color_primary};
--secondary: {color_secondary};
--success: {color_success};
--danger: {color_danger};
--warning: {color_warning};
--background: {color_background};
--text: {color_text};
--border: {color_border};
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: var(--background);
color: var(--text);
line-height: 1.6;
padding: 2rem;
}}
.container {{ max-width: 1200px; margin: 0 auto; }}
.header {{
text-align: center;
padding: 2rem 0;
border-bottom: 2px solid var(--border);
margin-bottom: 2rem;
}}
.header h1 {{ font-size: 2.5rem; color: var(--primary); }}
.header .meta {{ color: #64748b; margin-top: 0.5rem; }}
.section {{
background: white;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}}
.section h2 {{
font-size: 1.5rem;
color: var(--primary);
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}}
.metrics-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}}
.metric-card {{
background: var(--background);
padding: 1rem;
border-radius: 6px;
text-align: center;
}}
.metric-value {{
font-size: 1.75rem;
font-weight: bold;
color: var(--primary);
}}
.metric-value.positive {{ color: var(--success); }}
.metric-value.negative {{ color: var(--danger); }}
.metric-label {{ font-size: 0.875rem; color: #64748b; }}
table {{
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}}
th, td {{
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border);
}}
th {{
background: var(--background);
font-weight: 600;
color: var(--text);
}}
tr:hover {{ background: var(--background); }}
.positive {{ color: var(--success); }}
.negative {{ color: var(--danger); }}
.chart-container {{
width: 100%;
height: 400px;
margin: 1rem 0;
}}
.chart-placeholder {{
width: 100%;
height: 100%;
background: var(--background);
border: 2px dashed var(--border);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
}}
.footer {{
text-align: center;
padding: 2rem 0;
color: #64748b;
font-size: 0.875rem;
border-top: 1px solid var(--border);
margin-top: 2rem;
}}
.heatmap {{
display: grid;
grid-template-columns: repeat(13, 1fr);
gap: 2px;
margin-top: 1rem;
}}
.heatmap-cell {{
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
border-radius: 4px;
color: white;
}}
.heatmap-header {{
background: var(--background);
color: var(--text);
font-weight: 600;
}}
.svg-chart {{
width: 100%;
height: 100%;
}}
@media print {{
body {{ padding: 0; }}
.section {{ page-break-inside: avoid; }}
}}
</style>
</head>
<body>
<div class="container">
{header}
{content}
{footer}
</div>
</body>
</html>"""
# ============================================================================
# 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"""
<div class="header">
<h1>{html.escape(content.title)}</h1>
<div class="meta">
Generated: {content.generated_at.strftime('%Y-%m-%d %H:%M:%S')}
{f' | Author: {html.escape(self.config.author)}' if self.config.author else ''}
</div>
</div>
"""
# 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"""
<div class="footer">
{html.escape(self.config.footer_text)}
</div>
"""
# 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"""
<div class="metric-card">
<div class="metric-value {val_class}">{format_value(value, is_pct)}</div>
<div class="metric-label">{html.escape(label)}</div>
</div>
""")
return f"""
<div class="section">
<h2>Summary</h2>
<div class="metrics-grid">
{''.join(cards_html)}
</div>
</div>
"""
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"<tr><td>{html.escape(str(label))}</td><td>{html.escape(str(value))}</td></tr>"
for label, value in rows
])
return f"""
<div class="section">
<h2>Trade Statistics</h2>
<table>
<thead>
<tr><th>Metric</th><th>Value</th></tr>
</thead>
<tbody>
{rows_html}
</tbody>
</table>
</div>
"""
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"<tr><td>{html.escape(str(label))}</td><td>{html.escape(str(value))}</td></tr>"
for label, value in rows
])
return f"""
<div class="section">
<h2>Risk Metrics</h2>
<table>
<thead>
<tr><th>Metric</th><th>Value</th></tr>
</thead>
<tbody>
{rows_html}
</tbody>
</table>
</div>
"""
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 = ['<div class="heatmap-cell heatmap-header">Year</div>']
for month in months:
header_cells.append(f'<div class="heatmap-cell heatmap-header">{month}</div>')
# Data rows
data_rows = []
for year in years:
row_cells = [f'<div class="heatmap-cell heatmap-header">{year}</div>']
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'<div class="heatmap-cell" style="background-color: {color}">{cell_text}</div>'
)
data_rows.append("".join(row_cells))
return f"""
<div class="section">
<h2>Monthly Returns</h2>
<div class="heatmap">
{''.join(header_cells)}
{''.join(data_rows)}
</div>
</div>
"""
def _render_trade_list_html(self, trades: list[dict[str, Any]]) -> str:
"""Render trade list section to HTML."""
if not trades:
return """
<div class="section">
<h2>Trade List</h2>
<p>No trades executed.</p>
</div>
"""
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"""
<tr>
<td>{html.escape(str(trade.get('id', '')))}</td>
<td>{html.escape(str(trade.get('timestamp', '')))}</td>
<td>{html.escape(str(trade.get('symbol', '')))}</td>
<td>{html.escape(str(trade.get('side', '')))}</td>
<td>{html.escape(str(trade.get('quantity', '')))}</td>
<td>${float(trade.get('price', 0)):,.2f}</td>
<td>${float(trade.get('value', 0)):,.2f}</td>
<td class="{pnl_class}">${pnl:,.2f}</td>
</tr>
""")
return f"""
<div class="section">
<h2>Trade List</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>Timestamp</th>
<th>Symbol</th>
<th>Side</th>
<th>Quantity</th>
<th>Price</th>
<th>Value</th>
<th>P&L</th>
</tr>
</thead>
<tbody>
{''.join(rows_html)}
</tbody>
</table>
</div>
"""
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"""
<div class="section">
<h2>{html.escape(chart.title)}</h2>
<div class="chart-container">
<div class="chart-placeholder">No data available</div>
</div>
</div>
"""
# Generate simple SVG chart
svg = self._generate_svg_chart(chart)
return f"""
<div class="section">
<h2>{html.escape(chart.title)}</h2>
<div class="chart-container">
{svg}
</div>
</div>
"""
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 '<div class="chart-placeholder">No data available</div>'
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"""
<svg class="svg-chart" viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="{width}" height="{height}" fill="white"/>
<!-- Grid lines -->
<g stroke="#e2e8f0" stroke-width="1">
<line x1="{padding}" y1="{padding}" x2="{padding}" y2="{height - padding}"/>
<line x1="{padding}" y1="{height - padding}" x2="{width - padding}" y2="{height - padding}"/>
</g>
<!-- Y-axis labels -->
<g font-size="12" fill="#64748b" text-anchor="end">
<text x="{padding - 10}" y="{padding + 5}">{y_max:,.0f}</text>
<text x="{padding - 10}" y="{height - padding + 5}">{y_min:,.0f}</text>
</g>
<!-- Data line -->
<polyline
fill="none"
stroke="{color}"
stroke-width="2"
points="{polyline_points}"
/>
<!-- Area fill -->
<polygon
fill="{color}"
fill-opacity="0.1"
points="{padding},{height - padding} {polyline_points} {width - padding},{height - padding}"
/>
<!-- Axis labels -->
<text x="{width / 2}" y="{height - 10}" font-size="12" fill="#64748b" text-anchor="middle">
{html.escape(chart.x_label)}
</text>
<text x="15" y="{height / 2}" font-size="12" fill="#64748b" text-anchor="middle"
transform="rotate(-90, 15, {height / 2})">
{html.escape(chart.y_label)}
</text>
</svg>
"""
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)