#!/usr/bin/env python
"""
Main report generator.
Generates backtest reports in HTML, PDF, and JSON formats.
"""
import json
import math
import os
from datetime import datetime
from .charts import ReportChart
from .performance import PerformanceCalculator
# Try to import Jinja2
try:
from jinja2 import BaseLoader, Environment, FileSystemLoader
JINJA2_AVAILABLE = True
except ImportError:
JINJA2_AVAILABLE = False
# Try to import weasyprint (PDF generation)
try:
from weasyprint import HTML as WeasyHTML
WEASYPRINT_AVAILABLE = True
except ImportError:
WEASYPRINT_AVAILABLE = False
# Default HTML template
DEFAULT_TEMPLATE = """<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Backtrader Report - {{ strategy_name }}</title>
<style>
@page {
size: A4;
margin: 12mm 15mm;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: 10pt;
line-height: 1.4;
color: #2c3e50;
background: white;
}
/* Header - Compact */
.header {
background: #1a365d;
color: white;
padding: 15px 20px;
margin-bottom: 12px;
border-bottom: 3px solid #3182ce;
}
.header h1 {
font-size: 18pt;
font-weight: 600;
margin-bottom: 3px;
}
.header .subtitle {
font-size: 11pt;
font-weight: 400;
margin-bottom: 10px;
opacity: 0.9;
}
.header-info {
font-size: 9pt;
line-height: 1.6;
}
.header-info span {
margin-right: 20px;
}
.header-info b { color: #90cdf4; }
/* Sections - Minimal spacing */
.section {
margin-bottom: 8px;
}
.section h2 {
padding: 6px 12px;
margin-bottom: 8px;
background: #edf2f7;
border-left: 3px solid #3182ce;
font-size: 12pt;
font-weight: 600;
color: #1a365d;
}
/* Notes */
.notes {
background: #fffbeb;
border: 1px solid #f6e05e;
padding: 8px 12px;
margin: 0 12px 10px 12px;
font-size: 9pt;
color: #744210;
}
/* Charts - New Page */
.section.charts-page {
page-break-before: always;
}
.charts {
padding: 0 12px;
}
.charts img {
width: 100%;
height: auto;
margin-bottom: 12px;
border: 1px solid #e2e8f0;
}
/* Params - New Page */
.section.params-page {
page-break-before: always;
}
/* Metrics Table - Compact */
.metrics-container {
padding: 0 12px;
}
.metrics-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 10px;
font-size: 9pt;
}
.metrics-table td {
padding: 5px 8px;
border-bottom: 1px solid #e2e8f0;
}
.metrics-table .group-header td {
background: #3182ce;
color: white;
font-weight: 600;
font-size: 9pt;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 6px 8px;
}
.metrics-table .label {
color: #4a5568;
width: 22%;
}
.metrics-table .value {
font-weight: 600;
color: #2d3748;
text-align: right;
width: 28%;
}
.metrics-table .value.positive { color: #276749; }
.metrics-table .value.negative { color: #c53030; }
/* Parameters - Compact */
.params-table {
width: 60%;
border-collapse: collapse;
font-size: 9pt;
margin: 0 12px;
}
.params-table td {
padding: 4px 8px;
border: 1px solid #e2e8f0;
}
.params-table .param-name {
background: #f7fafc;
font-weight: 500;
width: 40%;
}
/* Footer - Minimal */
.footer {
text-align: center;
color: #718096;
font-size: 8pt;
padding: 10px;
border-top: 1px solid #e2e8f0;
margin-top: 15px;
}
.footer p { margin: 2px 0; }
</style>
</head>
<body>
<div class="header">
<h1>{{ strategy_name }}</h1>
<div class="subtitle">Backtest Performance Report</div>
<div class="header-info">
<span><b>Data:</b> {{ data_name }}</span>
<span><b>Period:</b> {{ start_date }} ~ {{ end_date }}</span>
<span><b>Bars:</b> {{ bars }}</span>
{% if user %}<span><b>Analyst:</b> {{ user }}</span>{% endif %}
<span><b>Generated:</b> {{ report_date }}</span>
</div>
</div>
{% if memo %}
<div class="section">
<h2>Notes</h2>
<div class="notes">{{ memo }}</div>
</div>
{% endif %}
<div class="section">
<h2>Performance Summary</h2>
<div class="metrics-container">
<table class="metrics-table">
<tr class="group-header"><td colspan="4">Profit & Loss</td></tr>
<tr>
<td class="label">Start Capital</td>
<td class="value">{{ "${:,.2f}".format(start_cash) if start_cash is not none else 'N/A' }}</td>
<td class="label">End Value</td>
<td class="value">{{ "${:,.2f}".format(end_value) if end_value is not none else 'N/A' }}</td>
</tr>
<tr>
<td class="label">Net Profit</td>
<td class="value {{ 'positive' if rpl is not none and rpl > 0 else 'negative' if rpl is not none and rpl < 0 else '' }}">{{ "${:,.2f}".format(rpl) if rpl is not none else 'N/A' }}</td>
<td class="label">Total Return</td>
<td class="value {{ 'positive' if total_return is not none and total_return > 0 else 'negative' if total_return is not none and total_return < 0 else '' }}">{{ "{:.2f}%".format(total_return) if total_return is not none else 'N/A' }}</td>
</tr>
<tr>
<td class="label">Annual Return</td>
<td class="value {{ 'positive' if annual_return is not none and annual_return > 0 else 'negative' if annual_return is not none and annual_return < 0 else '' }}">{{ "{:.2f}%".format(annual_return) if annual_return is not none else 'N/A' }}</td>
<td class="label">Profit Factor</td>
<td class="value">{{ "{:.2f}".format(profit_factor) if profit_factor is not none else 'N/A' }}</td>
</tr>
<tr class="group-header"><td colspan="4">Risk Metrics</td></tr>
<tr>
<td class="label">Max Drawdown ($)</td>
<td class="value negative">{{ "${:,.2f}".format(max_money_drawdown) if max_money_drawdown is not none else 'N/A' }}</td>
<td class="label">Max Drawdown (%)</td>
<td class="value negative">{{ "{:.2f}%".format(max_pct_drawdown) if max_pct_drawdown is not none else 'N/A' }}</td>
</tr>
<tr>
<td class="label">Sharpe Ratio</td>
<td class="value">{{ "{:.2f}".format(sharpe_ratio) if sharpe_ratio is not none else 'N/A' }}</td>
<td class="label">Calmar Ratio</td>
<td class="value">{{ "{:.2f}".format(calmar_ratio) if calmar_ratio is not none else 'N/A' }}</td>
</tr>
<tr>
<td class="label">SQN Score</td>
<td class="value">{{ "{:.2f}".format(sqn_score) if sqn_score is not none else 'N/A' }}</td>
<td class="label">SQN Rating</td>
<td class="value">{{ sqn_human if sqn_human is not none else 'N/A' }}</td>
</tr>
<tr class="group-header"><td colspan="4">Trade Statistics</td></tr>
<tr>
<td class="label">Total Trades</td>
<td class="value">{{ total_number_trades }}</td>
<td class="label">Closed Trades</td>
<td class="value">{{ trades_closed }}</td>
</tr>
<tr>
<td class="label">Win Rate</td>
<td class="value">{{ "{:.2f}%".format(pct_winning) if pct_winning is not none else 'N/A' }}</td>
<td class="label">Avg Win</td>
<td class="value positive">{{ "${:,.2f}".format(avg_money_winning) if avg_money_winning is not none else 'N/A' }}</td>
</tr>
<tr>
<td class="label">Avg Loss</td>
<td class="value negative">{{ "${:,.2f}".format(avg_money_losing) if avg_money_losing is not none else 'N/A' }}</td>
<td class="label">Best Trade</td>
<td class="value positive">{{ "${:,.2f}".format(best_winning_trade) if best_winning_trade is not none else 'N/A' }}</td>
</tr>
<tr>
<td class="label">Worst Trade</td>
<td class="value negative">{{ "${:,.2f}".format(worst_losing_trade) if worst_losing_trade is not none else 'N/A' }}</td>
<td class="label"></td>
<td class="value"></td>
</tr>
</table>
</div>
</div>
<div class="section charts-page">
<h2>Performance Charts</h2>
<div class="charts">
{% if equity_curve_img %}<img src="{{ equity_curve_img|safe }}" alt="Equity Curve">{% endif %}
{% if return_bars_img %}<img src="{{ return_bars_img|safe }}" alt="Return Bars">{% endif %}
{% if drawdown_img %}<img src="{{ drawdown_img|safe }}" alt="Drawdown">{% endif %}
</div>
</div>
{% if params %}
<div class="section params-page">
<h2>Strategy Parameters</h2>
<div class="metrics-container">
<table class="params-table">
{% for key, value in params.items() %}
<tr>
<td class="param-name">{{ key }}</td>
<td>{{ value }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% endif %}
<div class="footer">
<p>Generated by <strong>Backtrader Reports Module</strong></p>
<p>{{ report_date }}</p>
</div>
</body>
</html>
"""
[docs]
class ReportGenerator:
"""Main report generator.
Generates backtest reports in HTML, PDF, and JSON formats.
Attributes:
strategy: Strategy instance
calculator: Performance calculator
charts: Chart generator
Usage example:
report = ReportGenerator(strategy)
report.generate_html('report.html')
report.generate_pdf('report.pdf')
report.generate_json('report.json')
"""
[docs]
def __init__(self, strategy, template="default"):
"""Initialize the report generator.
Args:
strategy: backtrader strategy instance
template: Template name or template string
"""
self.strategy = strategy
self.calculator = PerformanceCalculator(strategy)
self.charts = ReportChart()
self.template = template
[docs]
def generate_html(self, output_path, user=None, memo=None, **kwargs):
"""Generate HTML report.
Args:
output_path: Output file path
user: Username
memo: Notes
**kwargs: Additional template variables
Returns:
str: Output file path
"""
if not JINJA2_AVAILABLE:
raise ImportError(
"jinja2 is required for HTML report generation. Install it with: pip install jinja2"
)
# Collect all data
context = self._build_context(user=user, memo=memo, **kwargs)
# Render template
html_content = self._render_template(context)
# Write to file
with open(output_path, "w", encoding="utf-8") as f:
f.write(html_content)
# Clean up charts
self.charts.close_all()
return output_path
[docs]
def generate_pdf(self, output_path, user=None, memo=None, **kwargs):
"""Generate PDF report.
Args:
output_path: Output file path
user: Username
memo: Notes
**kwargs: Additional template variables
Returns:
str: Output file path
"""
if not WEASYPRINT_AVAILABLE:
raise ImportError(
"weasyprint is required for PDF report generation. Install it with: pip install weasyprint"
)
# Collect all data
context = self._build_context(user=user, memo=memo, **kwargs)
# Render template
html_content = self._render_template(context)
# Convert to PDF
WeasyHTML(string=html_content).write_pdf(output_path)
# Clean up charts
self.charts.close_all()
return output_path
[docs]
def generate_json(self, output_path, indent=2, **kwargs):
"""Generate JSON report.
Args:
output_path: Output file path
indent: JSON indentation
**kwargs: Additional data
Returns:
str: Output file path
"""
# Get all metrics
metrics = self.calculator.get_all_metrics()
strategy_info = self.calculator.get_strategy_info()
data_info = self.calculator.get_data_info()
# Build JSON structure
report_data = {
"generated_at": datetime.now().isoformat(),
"strategy": strategy_info,
"data": {
"name": data_info.get("data_name"),
"start_date": (
str(data_info.get("start_date")) if data_info.get("start_date") else None
),
"end_date": str(data_info.get("end_date")) if data_info.get("end_date") else None,
"bars": data_info.get("bars"),
},
"metrics": {
"pnl": {
"start_cash": metrics.get("start_cash"),
"end_value": metrics.get("end_value"),
"net_profit": metrics.get("rpl"),
"total_return": metrics.get("total_return"),
"annual_return": metrics.get("annual_return"),
"profit_factor": metrics.get("profit_factor"),
},
"risk": {
"max_drawdown_money": metrics.get("max_money_drawdown"),
"max_drawdown_pct": metrics.get("max_pct_drawdown"),
"sharpe_ratio": metrics.get("sharpe_ratio"),
"calmar_ratio": metrics.get("calmar_ratio"),
"sqn_score": metrics.get("sqn_score"),
"sqn_rating": metrics.get("sqn_human"),
},
"trades": {
"total": metrics.get("total_number_trades"),
"closed": metrics.get("trades_closed"),
"won": metrics.get("trades_won"),
"lost": metrics.get("trades_lost"),
"win_rate": metrics.get("pct_winning"),
"avg_win": metrics.get("avg_money_winning"),
"avg_loss": metrics.get("avg_money_losing"),
"best_trade": metrics.get("best_winning_trade"),
"worst_trade": metrics.get("worst_losing_trade"),
},
},
**kwargs,
}
# Handle non-serializable values
report_data = self._make_json_serializable(report_data)
# Write to file
with open(output_path, "w", encoding="utf-8") as f:
json.dump(report_data, f, indent=indent, ensure_ascii=False)
return output_path
def _build_context(self, user=None, memo=None, **kwargs):
"""Build template context.
Args:
user: Username for report metadata
memo: Notes for report metadata
**kwargs: Additional template variables
Returns:
dict: Template variables dictionary
"""
# Get metrics
metrics = self.calculator.get_all_metrics()
strategy_info = self.calculator.get_strategy_info()
data_info = self.calculator.get_data_info()
# Generate charts
dates, values = self.calculator.get_equity_curve()
benchmark_dates, benchmark_values = self.calculator.get_buynhold_curve()
equity_curve_img = ""
return_bars_img = ""
drawdown_img = ""
if dates and values:
# Equity curve
fig_equity = self.charts.plot_equity_curve(
dates, values, benchmark_dates, benchmark_values
)
if fig_equity:
equity_curve_img = self.charts.to_base64(fig_equity)
# Return bars chart
fig_returns = self.charts.plot_return_bars(dates, values)
if fig_returns:
return_bars_img = self.charts.to_base64(fig_returns)
# Drawdown chart
fig_drawdown = self.charts.plot_drawdown(dates, values)
if fig_drawdown:
drawdown_img = self.charts.to_base64(fig_drawdown)
# Build context
serializable_metrics = self._make_json_serializable(metrics)
serializable_kwargs = self._make_json_serializable(kwargs)
context = {
# Strategy information
"strategy_name": strategy_info.get("strategy_name", "Strategy"),
"params": strategy_info.get("params", {}),
# Data information
"data_name": data_info.get("data_name", "Data"),
"start_date": (
str(data_info.get("start_date", ""))[:10] if data_info.get("start_date") else "N/A"
),
"end_date": (
str(data_info.get("end_date", ""))[:10] if data_info.get("end_date") else "N/A"
),
"bars": data_info.get("bars", 0),
# User information
"user": user,
"memo": memo,
"report_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
# Charts
"equity_curve_img": equity_curve_img,
"return_bars_img": return_bars_img,
"drawdown_img": drawdown_img,
# Metrics
**serializable_metrics,
**serializable_kwargs,
}
return context
def _render_template(self, context):
"""Render template.
Args:
context: Template variables dictionary
Returns:
str: Rendered HTML
"""
if self.template == "default":
# Use default template
env = Environment(loader=BaseLoader(), autoescape=True)
template = env.from_string(DEFAULT_TEMPLATE)
else:
# Try to load as file path
if os.path.isfile(self.template):
template_dir = os.path.dirname(self.template)
template_name = os.path.basename(self.template)
env = Environment(loader=FileSystemLoader(template_dir), autoescape=True)
template = env.get_template(template_name)
else:
# Handle as template string
env = Environment(loader=BaseLoader(), autoescape=True)
template = env.from_string(self.template)
return template.render(**context)
def _make_json_serializable(self, obj):
"""Make object JSON serializable.
Args:
obj: Object to process
Returns:
Serializable object
"""
import math
if isinstance(obj, dict):
return {k: self._make_json_serializable(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [self._make_json_serializable(v) for v in obj]
if isinstance(obj, float):
if math.isnan(obj) or math.isinf(obj):
return None
return obj
if hasattr(obj, "isoformat") and callable(getattr(obj, "isoformat", None)):
return obj.isoformat()
if hasattr(obj, "__dict__"):
return str(obj)
return obj
[docs]
def get_metrics(self):
"""Get all performance metrics.
Returns:
dict: Performance metrics dictionary
"""
return self.calculator.get_all_metrics()
@staticmethod
def _fmt_metric(value, fmt=",.2f", suffix=""):
"""Format a metric value for console display.
Args:
value: Metric value (may be None)
fmt: Format string for numeric values
suffix: Suffix to append (e.g. '%')
Returns:
str: Formatted value or 'N/A'
"""
if value is None:
return "N/A"
if isinstance(value, (int, float)) and not math.isfinite(value):
return "N/A"
try:
return f"{value:{fmt}}{suffix}"
except (ValueError, TypeError):
return str(value)
[docs]
def print_summary(self):
"""Print performance summary to console."""
metrics = self.calculator.get_all_metrics()
strategy_info = self.calculator.get_strategy_info()
fmt = self._fmt_metric
print("\n" + "=" * 60)
print(f"Strategy: {strategy_info.get('strategy_name', 'Strategy')}")
print("=" * 60)
print("\n*** PnL ***")
print(f"Start Capital : {fmt(metrics.get('start_cash'))}")
print(f"Net Profit : {fmt(metrics.get('rpl'))}")
print(f"Total Return : {fmt(metrics.get('total_return'), '.2f', '%')}")
print(f"Annual Return : {fmt(metrics.get('annual_return'), '.2f', '%')}")
print("\n*** Risk ***")
print(f"Max Drawdown ($) : {fmt(metrics.get('max_money_drawdown'))}")
print(f"Max Drawdown (%) : {fmt(metrics.get('max_pct_drawdown'), '.2f', '%')}")
print(f"Sharpe Ratio : {fmt(metrics.get('sharpe_ratio'), '.2f')}")
print("\n*** Trades ***")
print(f"Total Trades : {metrics.get('total_number_trades', 0)}")
print(f"Win Rate : {fmt(metrics.get('pct_winning'), '.2f', '%')}")
print(f"SQN Score : {fmt(metrics.get('sqn_score'), '.2f')}")
print(f"SQN Rating : {metrics.get('sqn_human', 'N/A')}")
print("\n" + "=" * 60)