use crate::{BacktestReport, PerformanceMetrics};
use polars::prelude::*;
#[derive(Debug, Clone)]
pub struct TearsheetOptions {
pub title: String,
pub width: u32,
pub height: u32,
}
impl Default for TearsheetOptions {
fn default() -> Self {
Self {
title: "QuantWave Backtest Report".to_string(),
width: 720,
height: 220,
}
}
}
pub fn render_tearsheet_html(report: &BacktestReport, options: &TearsheetOptions) -> String {
let equity = extract_f64_column(&report.result.equity_curve, "equity");
let drawdown = compute_drawdown_pct(&equity);
let metrics = &report.metrics;
let equity_svg = line_chart_svg(&equity, options.width, options.height, "#2563eb", "Equity");
let dd_svg = line_chart_svg(&drawdown, options.width, options.height, "#dc2626", "Drawdown %");
let metrics_table = metrics_table_html(metrics);
let trade_table = trade_stats_html(&report.result.trades);
let stats_extra = stats_kv_html(&report.result.stats);
format!(
r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{title}</title>
<style>
body {{ font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 24px; color: #111; background: #fafafa; }}
h1 {{ font-size: 1.5rem; margin-bottom: 0.25rem; }}
.subtitle {{ color: #555; margin-bottom: 1.5rem; font-size: 0.9rem; }}
.grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; }}
.card {{ background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; }}
.card h2 {{ font-size: 1rem; margin: 0 0 12px; color: #374151; }}
table {{ width: 100%; border-collapse: collapse; font-size: 0.875rem; }}
th, td {{ text-align: left; padding: 6px 8px; border-bottom: 1px solid #f3f4f6; }}
th {{ color: #6b7280; font-weight: 600; }}
svg {{ max-width: 100%; height: auto; }}
.footer {{ margin-top: 24px; font-size: 0.75rem; color: #9ca3af; }}
</style>
</head>
<body>
<h1>{title}</h1>
<p class="subtitle">Generated by QuantWave backtest engine</p>
<div class="grid">
<div class="card"><h2>Performance Metrics</h2>{metrics_table}</div>
<div class="card"><h2>Run Stats</h2>{stats_extra}</div>
</div>
<div class="grid" style="margin-top:16px">
<div class="card"><h2>Equity Curve</h2>{equity_svg}</div>
<div class="card"><h2>Drawdown</h2>{dd_svg}</div>
</div>
<div class="card" style="margin-top:16px"><h2>Trade Summary</h2>{trade_table}</div>
<p class="footer">QuantWave · batch/streaming parity backtest · not investment advice</p>
</body>
</html>"##,
title = html_escape(&options.title),
metrics_table = metrics_table,
stats_extra = stats_extra,
equity_svg = equity_svg,
dd_svg = dd_svg,
trade_table = trade_table,
)
}
fn extract_f64_column(df: &DataFrame, name: &str) -> Vec<f64> {
df.column(name)
.ok()
.and_then(|c| c.f64().ok())
.map(|ca| ca.into_iter().map(|v| v.unwrap_or(f64::NAN)).collect())
.unwrap_or_default()
}
fn compute_drawdown_pct(equity: &[f64]) -> Vec<f64> {
let mut peak = f64::NEG_INFINITY;
equity
.iter()
.map(|&e| {
if e.is_nan() {
return f64::NAN;
}
if e > peak {
peak = e;
}
if peak <= 0.0 {
0.0
} else {
(peak - e) / peak * 100.0
}
})
.collect()
}
fn line_chart_svg(values: &[f64], width: u32, height: u32, color: &str, label: &str) -> String {
let valid: Vec<f64> = values.iter().copied().filter(|v| v.is_finite()).collect();
if valid.is_empty() {
return format!("<p>No {label} data</p>");
}
let min_v = valid.iter().copied().fold(f64::INFINITY, f64::min);
let max_v = valid.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let range = (max_v - min_v).max(1e-12);
let w = width as f64;
let h = height as f64;
let pad = 8.0;
let n = valid.len().max(2);
let points: String = valid
.iter()
.enumerate()
.map(|(i, &v)| {
let x = pad + (i as f64 / (n - 1) as f64) * (w - 2.0 * pad);
let y = pad + (1.0 - (v - min_v) / range) * (h - 2.0 * pad);
format!("{x:.1},{y:.1}")
})
.collect::<Vec<_>>()
.join(" ");
format!(
r##"<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="{label}">
<rect width="100%" height="100%" fill="#f9fafb"/>
<polyline fill="none" stroke="{color}" stroke-width="2" points="{points}"/>
</svg>"##,
width = width,
height = height,
color = color,
label = html_escape(label),
points = points,
)
}
fn metrics_table_html(m: &PerformanceMetrics) -> String {
let rows = [
("Trades", format_num(m.num_trades, 0)),
("Win rate", format_pct(m.win_rate)),
("Profit factor", format_num(m.profit_factor, 2)),
("Max drawdown", format_pct(m.max_drawdown_pct)),
("CAGR", format_pct(m.cagr)),
("Sharpe", format_num(m.sharpe_ratio, 2)),
("Sortino", format_num(m.sortino_ratio, 2)),
("Total return", format_pct(m.total_return)),
("Final equity", format_num(m.final_equity, 2)),
("Avg trade PnL", format_num(m.avg_trade_pnl, 2)),
];
table_from_pairs(&rows)
}
fn stats_kv_html(stats: &std::collections::HashMap<String, f64>) -> String {
if stats.is_empty() {
return "<p>No run stats</p>".to_string();
}
let mut keys: Vec<_> = stats.keys().collect();
keys.sort();
let rows: Vec<(&str, String)> = keys
.iter()
.map(|k| (k.as_str(), format_num(stats[*k], 4)))
.collect();
table_from_pairs(&rows)
}
fn trade_stats_html(trades: &DataFrame) -> String {
let height = trades.height();
if height == 0 {
return "<p>No trades recorded</p>".to_string();
}
let pnls = extract_f64_column(trades, "pnl_net");
let wins = pnls.iter().filter(|p| **p > 0.0).count();
let losses = pnls.iter().filter(|p| **p <= 0.0).count();
let best = pnls.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let worst = pnls.iter().copied().fold(f64::INFINITY, f64::min);
let sum: f64 = pnls.iter().sum();
let rows = [
("Closed trades", height.to_string()),
("Winning", wins.to_string()),
("Losing", losses.to_string()),
("Net PnL (sum)", format_num(sum, 2)),
("Best trade", format_num(best, 2)),
("Worst trade", format_num(worst, 2)),
];
table_from_pairs(&rows)
}
fn table_from_pairs(rows: &[(&str, String)]) -> String {
let body: String = rows
.iter()
.map(|(k, v)| format!("<tr><th>{}</th><td>{}</td></tr>", html_escape(k), html_escape(v)))
.collect();
format!("<table><tbody>{body}</tbody></table>")
}
fn format_num(v: f64, decimals: usize) -> String {
if !v.is_finite() {
return "—".to_string();
}
format!("{:.prec$}", v, prec = decimals)
}
fn format_pct(v: f64) -> String {
if !v.is_finite() {
return "—".to_string();
}
format!("{:.2}%", v * 100.0)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{BacktestConfig, BacktestEngine};
use polars::prelude::*;
fn mini_report() -> BacktestReport {
let df = DataFrame::new(vec![
Column::new("timestamp".into(), (0i64..6).collect::<Vec<_>>()),
Column::new("close".into(), vec![100.0, 101.0, 102.5, 103.0, 102.0, 101.0]),
Column::new("signal".into(), vec![0.0, 1.0, 1.0, 1.0, 0.0, 0.0]),
])
.unwrap();
BacktestEngine::new(BacktestConfig::default())
.backtest_with_report(df.lazy())
.unwrap()
}
#[test]
fn tearsheet_html_contains_core_sections() {
let html = render_tearsheet_html(&mini_report(), &TearsheetOptions::default());
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("Performance Metrics"));
assert!(html.contains("Equity Curve"));
assert!(html.contains("Drawdown"));
assert!(html.contains("Trade Summary"));
assert!(html.contains("<svg"));
}
}