1498 lines
51 KiB
Python
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)
|