feat(backtest): add ReportGenerator for PDF/HTML reports - Issue #44 (44 tests)

Implements comprehensive report generation for backtest results:
- ReportFormat, ReportSection, ChartType enums for configuration
- ReportConfig dataclass for customization (format, sections, colors)
- ChartData, ReportContent, ReportResult dataclasses
- ReportGenerator class with multi-format output:
  - HTML reports with embedded CSS styling
  - PDF reports (with HTML fallback)
  - JSON structured data export
  - Markdown text reports
- SVG chart generation for equity curves and drawdowns
- Monthly returns heatmap visualization
- Configurable color schemes and sections

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andrew Kaszubski 2025-12-26 23:16:38 +11:00
parent 722b88d5b4
commit 3d1267a818
3 changed files with 2331 additions and 0 deletions

View File

@ -0,0 +1,805 @@
"""Tests for report generator module.
Issue #44: [BT-43] Report generator - PDF/HTML reports
"""
import json
import tempfile
from datetime import datetime, timedelta
from decimal import Decimal
from pathlib import Path
import pytest
from tradingagents.backtest import (
# Backtest engine
BacktestResult,
BacktestTrade,
BacktestSnapshot,
BacktestConfig,
OrderSide,
# Report generator
ReportFormat,
ReportSection,
ChartType,
ReportConfig,
ChartData,
ReportContent,
ReportResult,
ReportGenerator,
create_report_generator,
)
# ============================================================================
# Test Fixtures
# ============================================================================
@pytest.fixture
def sample_config() -> BacktestConfig:
"""Create sample backtest configuration."""
return BacktestConfig(
initial_capital=Decimal("100000"),
)
@pytest.fixture
def sample_trades() -> list[BacktestTrade]:
"""Create sample trades for testing."""
trades = []
base_date = datetime(2023, 1, 3)
# Winning trade
trades.append(BacktestTrade(
timestamp=base_date,
symbol="AAPL",
side=OrderSide.BUY,
quantity=Decimal("100"),
price=Decimal("150.00"),
commission=Decimal("1.00"),
slippage=Decimal("0.50"),
pnl=Decimal("500.00"),
))
# Losing trade
trades.append(BacktestTrade(
timestamp=base_date + timedelta(days=5),
symbol="GOOGL",
side=OrderSide.BUY,
quantity=Decimal("50"),
price=Decimal("100.00"),
commission=Decimal("1.00"),
slippage=Decimal("0.25"),
pnl=Decimal("-200.00"),
))
# Another winning trade
trades.append(BacktestTrade(
timestamp=base_date + timedelta(days=10),
symbol="MSFT",
side=OrderSide.BUY,
quantity=Decimal("75"),
price=Decimal("300.00"),
commission=Decimal("1.50"),
slippage=Decimal("0.75"),
pnl=Decimal("750.00"),
))
return trades
@pytest.fixture
def sample_snapshots() -> list[BacktestSnapshot]:
"""Create sample snapshots for testing."""
snapshots = []
base_date = datetime(2023, 1, 1)
for i in range(30):
equity = Decimal("100000") + Decimal(str(i * 100 - 50 * (i % 5)))
snapshots.append(BacktestSnapshot(
timestamp=base_date + timedelta(days=i),
cash=equity * Decimal("0.3"),
positions_value=equity * Decimal("0.7"),
total_value=equity,
))
return snapshots
@pytest.fixture
def sample_result(sample_trades, sample_snapshots) -> BacktestResult:
"""Create sample backtest result."""
return BacktestResult(
initial_capital=Decimal("100000"),
final_value=Decimal("101050"),
total_return=Decimal("1.05"),
total_trades=3,
winning_trades=2,
losing_trades=1,
win_rate=Decimal("66.67"),
profit_factor=Decimal("6.25"),
max_drawdown=Decimal("0.50"),
sharpe_ratio=Decimal("1.85"),
sortino_ratio=Decimal("2.50"),
total_commission=Decimal("3.50"),
total_slippage=Decimal("1.50"),
start_date=datetime(2023, 1, 1),
end_date=datetime(2023, 1, 30),
trades=sample_trades,
snapshots=sample_snapshots,
)
# ============================================================================
# Test Enums
# ============================================================================
class TestReportFormat:
"""Tests for ReportFormat enum."""
def test_values(self):
"""Test enum values."""
assert ReportFormat.HTML.value == "html"
assert ReportFormat.PDF.value == "pdf"
assert ReportFormat.JSON.value == "json"
assert ReportFormat.MARKDOWN.value == "markdown"
def test_all_formats(self):
"""Test all formats exist."""
formats = list(ReportFormat)
assert len(formats) == 4
class TestReportSection:
"""Tests for ReportSection enum."""
def test_values(self):
"""Test enum values."""
assert ReportSection.SUMMARY.value == "summary"
assert ReportSection.EQUITY_CURVE.value == "equity_curve"
assert ReportSection.DRAWDOWN.value == "drawdown"
assert ReportSection.TRADE_LIST.value == "trade_list"
def test_all_sections(self):
"""Test all sections exist."""
sections = list(ReportSection)
assert len(sections) >= 8
class TestChartType:
"""Tests for ChartType enum."""
def test_values(self):
"""Test enum values."""
assert ChartType.LINE.value == "line"
assert ChartType.BAR.value == "bar"
assert ChartType.HEATMAP.value == "heatmap"
# ============================================================================
# Test Data Classes
# ============================================================================
class TestReportConfig:
"""Tests for ReportConfig dataclass."""
def test_default_creation(self):
"""Test default configuration."""
config = ReportConfig()
assert config.title == "Backtest Report"
assert config.author == ""
assert config.chart_width == 800
assert config.chart_height == 400
assert config.decimal_places == 2
assert config.include_trade_list is True
assert config.max_trades_shown == 100
assert config.include_timestamp is True
def test_custom_creation(self):
"""Test custom configuration."""
config = ReportConfig(
title="My Report",
author="Test Author",
chart_width=1024,
chart_height=768,
decimal_places=4,
include_trade_list=False,
max_trades_shown=50,
)
assert config.title == "My Report"
assert config.author == "Test Author"
assert config.chart_width == 1024
assert config.chart_height == 768
assert config.decimal_places == 4
assert config.include_trade_list is False
assert config.max_trades_shown == 50
def test_color_scheme(self):
"""Test default color scheme."""
config = ReportConfig()
assert "primary" in config.color_scheme
assert "secondary" in config.color_scheme
assert "success" in config.color_scheme
assert "danger" in config.color_scheme
def test_include_sections_default(self):
"""Test default sections include all."""
config = ReportConfig()
# Default includes all sections
assert len(config.include_sections) > 0
class TestChartData:
"""Tests for ChartData dataclass."""
def test_creation(self):
"""Test chart data creation."""
chart = ChartData(
chart_type=ChartType.LINE,
title="Test Chart",
x_data=[1, 2, 3],
y_data=[10, 20, 30],
x_label="X Axis",
y_label="Y Axis",
)
assert chart.chart_type == ChartType.LINE
assert chart.title == "Test Chart"
assert chart.x_data == [1, 2, 3]
assert chart.y_data == [10, 20, 30]
assert chart.x_label == "X Axis"
assert chart.y_label == "Y Axis"
def test_default_values(self):
"""Test default values."""
chart = ChartData(
chart_type=ChartType.BAR,
title="Test",
x_data=[],
y_data=[],
)
assert chart.x_label == ""
assert chart.y_label == ""
assert chart.series_names == []
assert chart.colors == []
class TestReportContent:
"""Tests for ReportContent dataclass."""
def test_creation(self):
"""Test content creation."""
now = datetime.now()
content = ReportContent(
title="Test Report",
generated_at=now,
)
assert content.title == "Test Report"
assert content.generated_at == now
assert content.sections == {}
assert content.charts == {}
assert content.metadata == {}
class TestReportResult:
"""Tests for ReportResult dataclass."""
def test_success_result(self):
"""Test successful result."""
result = ReportResult(
success=True,
output_path=Path("/tmp/report.html"),
format=ReportFormat.HTML,
file_size_bytes=1024,
generation_time_ms=500,
)
assert result.success is True
assert result.output_path == Path("/tmp/report.html")
assert result.format == ReportFormat.HTML
assert result.file_size_bytes == 1024
assert result.generation_time_ms == 500
assert result.error is None
def test_failure_result(self):
"""Test failure result."""
result = ReportResult(
success=False,
format=ReportFormat.PDF,
error="PDF library not available",
)
assert result.success is False
assert result.output_path is None
assert result.error == "PDF library not available"
# ============================================================================
# Test ReportGenerator
# ============================================================================
class TestReportGenerator:
"""Tests for ReportGenerator class."""
def test_initialization(self):
"""Test generator initialization."""
generator = ReportGenerator()
assert generator.config is not None
assert generator.analyzer is not None
def test_initialization_with_config(self):
"""Test initialization with custom config."""
config = ReportConfig(title="Custom Title")
generator = ReportGenerator(config=config)
assert generator.config.title == "Custom Title"
def test_generate_html(self, sample_result):
"""Test HTML generation."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.html"
result = generator.generate_html(sample_result, output_path)
assert result.success is True
assert result.output_path == output_path
assert result.format == ReportFormat.HTML
assert result.file_size_bytes > 0
assert output_path.exists()
def test_generate_html_content(self, sample_result):
"""Test HTML content contains expected sections."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.html"
generator.generate_html(sample_result, output_path)
content = output_path.read_text()
# Check structure
assert "<!DOCTYPE html>" in content
assert "<html" in content
assert "</html>" in content
# Check sections
assert "Summary" in content
assert "Trade Statistics" in content
assert "Risk Metrics" in content
def test_generate_json(self, sample_result):
"""Test JSON generation."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.json"
result = generator.generate_json(sample_result, output_path)
assert result.success is True
assert result.format == ReportFormat.JSON
assert output_path.exists()
# Verify JSON is valid
data = json.loads(output_path.read_text())
assert "report" in data
assert "backtest" in data
assert "performance" in data
assert "trades" in data
def test_generate_json_structure(self, sample_result):
"""Test JSON structure is correct."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.json"
generator.generate_json(sample_result, output_path)
data = json.loads(output_path.read_text())
# Check report metadata
assert "title" in data["report"]
assert "generated_at" in data["report"]
# Check backtest data
assert "initial_capital" in data["backtest"]
assert "final_capital" in data["backtest"]
# Check performance metrics
assert "total_return" in data["performance"]
assert "sharpe_ratio" in data["performance"]
def test_generate_markdown(self, sample_result):
"""Test Markdown generation."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.md"
result = generator.generate_markdown(sample_result, output_path)
assert result.success is True
assert result.format == ReportFormat.MARKDOWN
assert output_path.exists()
def test_generate_markdown_content(self, sample_result):
"""Test Markdown content structure."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.md"
generator.generate_markdown(sample_result, output_path)
content = output_path.read_text()
# Check structure
assert "# " in content # Title
assert "## Summary" in content
assert "## Trade Statistics" in content
assert "## Risk Metrics" in content
assert "|" in content # Tables
def test_generate_pdf_fallback(self, sample_result):
"""Test PDF generation falls back to HTML if libraries unavailable."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.pdf"
result = generator.generate_pdf(sample_result, output_path)
# Should succeed but might fallback to HTML
assert result.success is True
# Either PDF worked or fell back to HTML
assert result.output_path is not None
def test_generate_generic(self, sample_result):
"""Test generic generate method."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
# Test HTML
html_path = Path(tmpdir) / "report.html"
result = generator.generate(sample_result, html_path, ReportFormat.HTML)
assert result.success is True
assert result.format == ReportFormat.HTML
# Test JSON
json_path = Path(tmpdir) / "report.json"
result = generator.generate(sample_result, json_path, ReportFormat.JSON)
assert result.success is True
assert result.format == ReportFormat.JSON
def test_custom_title(self, sample_result):
"""Test custom report title."""
config = ReportConfig(title="My Custom Report")
generator = ReportGenerator(config=config)
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.html"
generator.generate_html(sample_result, output_path)
content = output_path.read_text()
assert "My Custom Report" in content
def test_custom_author(self, sample_result):
"""Test custom author in report."""
config = ReportConfig(author="Test Author")
generator = ReportGenerator(config=config)
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.html"
generator.generate_html(sample_result, output_path)
content = output_path.read_text()
assert "Test Author" in content
def test_max_trades_shown(self, sample_result):
"""Test max trades limit in report."""
config = ReportConfig(max_trades_shown=1)
generator = ReportGenerator(config=config)
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.json"
generator.generate_json(sample_result, output_path)
data = json.loads(output_path.read_text())
# Should only show 1 trade
assert len(data["trade_list"]) <= 1
class TestHTMLRendering:
"""Tests for HTML rendering."""
def test_summary_metrics(self, sample_result):
"""Test summary metrics in HTML."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.html"
generator.generate_html(sample_result, output_path)
content = output_path.read_text()
# Check metrics are present
assert "Total Return" in content
assert "Win Rate" in content
assert "Sharpe Ratio" in content
def test_trade_list_rendering(self, sample_result):
"""Test trade list in HTML."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.html"
generator.generate_html(sample_result, output_path)
content = output_path.read_text()
# Check trade table
assert "Trade List" in content
assert "Symbol" in content
assert "AAPL" in content
def test_css_styling(self, sample_result):
"""Test CSS is included."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.html"
generator.generate_html(sample_result, output_path)
content = output_path.read_text()
# Check CSS is present
assert "<style>" in content
assert "font-family" in content
assert "color" in content
def test_responsive_design(self, sample_result):
"""Test responsive design elements."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.html"
generator.generate_html(sample_result, output_path)
content = output_path.read_text()
# Check viewport meta
assert "viewport" in content
# Check grid layout
assert "grid" in content
def test_color_scheme_applied(self, sample_result):
"""Test custom color scheme is applied."""
config = ReportConfig(
color_scheme={
"primary": "#ff0000",
"secondary": "#00ff00",
"success": "#0000ff",
"danger": "#ffff00",
"warning": "#ff00ff",
"background": "#ffffff",
"text": "#000000",
"border": "#cccccc",
}
)
generator = ReportGenerator(config=config)
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.html"
generator.generate_html(sample_result, output_path)
content = output_path.read_text()
assert "#ff0000" in content # primary color
class TestSVGCharts:
"""Tests for SVG chart generation."""
def test_equity_curve_chart(self, sample_result):
"""Test equity curve SVG chart."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.html"
generator.generate_html(sample_result, output_path)
content = output_path.read_text()
# Check SVG is present
assert "<svg" in content
assert "Equity Curve" in content
def test_drawdown_chart(self, sample_result):
"""Test drawdown SVG chart."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.html"
generator.generate_html(sample_result, output_path)
content = output_path.read_text()
# Check drawdown section
assert "Drawdown" in content
class TestMonthlyReturns:
"""Tests for monthly returns heatmap."""
def test_monthly_returns_section(self, sample_result):
"""Test monthly returns heatmap is generated."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.html"
generator.generate_html(sample_result, output_path)
content = output_path.read_text()
# Check monthly returns section
assert "Monthly Returns" in content
assert "heatmap" in content
class TestErrorHandling:
"""Tests for error handling."""
def test_invalid_output_path(self, sample_result):
"""Test handling of invalid output path."""
generator = ReportGenerator()
# Path to non-existent nested directory
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "nested" / "dirs" / "report.html"
result = generator.generate_html(sample_result, output_path)
# Should succeed (creates directories)
assert result.success is True
def test_empty_result(self):
"""Test handling of empty result."""
empty_result = BacktestResult(
initial_capital=Decimal("100000"),
final_value=Decimal("100000"),
total_return=Decimal("0"),
total_trades=0,
winning_trades=0,
losing_trades=0,
trades=[],
snapshots=[],
)
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "report.html"
result = generator.generate_html(empty_result, output_path)
assert result.success is True
assert output_path.exists()
class TestFactoryFunction:
"""Tests for factory function."""
def test_create_report_generator(self):
"""Test factory function."""
generator = create_report_generator(
title="Factory Report",
author="Factory Author",
include_trade_list=True,
max_trades=50,
)
assert generator.config.title == "Factory Report"
assert generator.config.author == "Factory Author"
assert generator.config.include_trade_list is True
assert generator.config.max_trades_shown == 50
def test_create_report_generator_defaults(self):
"""Test factory function with defaults."""
generator = create_report_generator()
assert generator.config.title == "Backtest Report"
assert generator.config.author == ""
class TestModuleExports:
"""Tests for module exports."""
def test_imports_from_package(self):
"""Test imports work from package."""
from tradingagents.backtest import (
ReportFormat,
ReportSection,
ChartType,
ReportConfig,
ChartData,
ReportContent,
ReportResult,
ReportGenerator,
create_report_generator,
)
# All imports should work
assert ReportFormat is not None
assert ReportSection is not None
assert ChartType is not None
assert ReportConfig is not None
assert ChartData is not None
assert ReportContent is not None
assert ReportResult is not None
assert ReportGenerator is not None
assert create_report_generator is not None
class TestIntegration:
"""Integration tests."""
def test_full_workflow(self, sample_result):
"""Test complete report generation workflow."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
# Generate all formats
html_result = generator.generate_html(
sample_result,
Path(tmpdir) / "report.html",
)
json_result = generator.generate_json(
sample_result,
Path(tmpdir) / "report.json",
)
md_result = generator.generate_markdown(
sample_result,
Path(tmpdir) / "report.md",
)
# All should succeed
assert html_result.success is True
assert json_result.success is True
assert md_result.success is True
# All files should exist
assert (Path(tmpdir) / "report.html").exists()
assert (Path(tmpdir) / "report.json").exists()
assert (Path(tmpdir) / "report.md").exists()
def test_with_pre_computed_analysis(self, sample_result):
"""Test using pre-computed analysis."""
from tradingagents.backtest import ResultsAnalyzer
analyzer = ResultsAnalyzer()
analysis = analyzer.analyze(sample_result)
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
result = generator.generate_html(
sample_result,
Path(tmpdir) / "report.html",
analysis=analysis,
)
assert result.success is True
def test_generation_time_tracked(self, sample_result):
"""Test that generation time is tracked."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
result = generator.generate_html(
sample_result,
Path(tmpdir) / "report.html",
)
assert result.generation_time_ms >= 0
def test_file_size_tracked(self, sample_result):
"""Test that file size is tracked."""
generator = ReportGenerator()
with tempfile.TemporaryDirectory() as tmpdir:
result = generator.generate_html(
sample_result,
Path(tmpdir) / "report.html",
)
assert result.file_size_bytes > 0

View File

@ -127,6 +127,22 @@ from .results_analyzer import (
create_results_analyzer,
)
from .report_generator import (
# Enums
ReportFormat,
ReportSection,
ChartType,
# Data Classes
ReportConfig,
ChartData,
ReportContent,
ReportResult,
# Main Classes
ReportGenerator,
# Factory Functions
create_report_generator,
)
__all__ = [
# Enums
"OrderSide",
@ -171,4 +187,17 @@ __all__ = [
"BenchmarkComparison",
"DrawdownAnalysis",
"AnalysisResult",
# Report Generator Enums
"ReportFormat",
"ReportSection",
"ChartType",
# Report Generator Data Classes
"ReportConfig",
"ChartData",
"ReportContent",
"ReportResult",
# Report Generator Main Classes
"ReportGenerator",
# Report Generator Factory Functions
"create_report_generator",
]

File diff suppressed because it is too large Load Diff