1use crate::{BacktestReport, PerformanceMetrics};
7use polars::prelude::*;
8
9#[derive(Debug, Clone)]
11pub struct TearsheetOptions {
12 pub title: String,
13 pub width: u32,
14 pub height: u32,
15}
16
17impl Default for TearsheetOptions {
18 fn default() -> Self {
19 Self {
20 title: "QuantWave Backtest Report".to_string(),
21 width: 720,
22 height: 220,
23 }
24 }
25}
26
27pub fn render_tearsheet_html(report: &BacktestReport, options: &TearsheetOptions) -> String {
29 let equity = extract_f64_column(&report.result.equity_curve, "equity");
30 let drawdown = compute_drawdown_pct(&equity);
31 let metrics = &report.metrics;
32
33 let equity_svg = line_chart_svg(&equity, options.width, options.height, "#2563eb", "Equity");
34 let dd_svg = line_chart_svg(
35 &drawdown,
36 options.width,
37 options.height,
38 "#dc2626",
39 "Drawdown %",
40 );
41
42 let metrics_table = metrics_table_html(metrics);
43 let trade_table = trade_stats_html(&report.result.trades);
44 let stats_extra = stats_kv_html(&report.result.stats);
45
46 format!(
47 r##"<!DOCTYPE html>
48<html lang="en">
49<head>
50<meta charset="utf-8"/>
51<meta name="viewport" content="width=device-width, initial-scale=1"/>
52<title>{title}</title>
53<style>
54 body {{ font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 24px; color: #111; background: #fafafa; }}
55 h1 {{ font-size: 1.5rem; margin-bottom: 0.25rem; }}
56 .subtitle {{ color: #555; margin-bottom: 1.5rem; font-size: 0.9rem; }}
57 .grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; }}
58 .card {{ background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; }}
59 .card h2 {{ font-size: 1rem; margin: 0 0 12px; color: #374151; }}
60 table {{ width: 100%; border-collapse: collapse; font-size: 0.875rem; }}
61 th, td {{ text-align: left; padding: 6px 8px; border-bottom: 1px solid #f3f4f6; }}
62 th {{ color: #6b7280; font-weight: 600; }}
63 svg {{ max-width: 100%; height: auto; }}
64 .footer {{ margin-top: 24px; font-size: 0.75rem; color: #9ca3af; }}
65</style>
66</head>
67<body>
68 <h1>{title}</h1>
69 <p class="subtitle">Generated by QuantWave backtest engine</p>
70 <div class="grid">
71 <div class="card"><h2>Performance Metrics</h2>{metrics_table}</div>
72 <div class="card"><h2>Run Stats</h2>{stats_extra}</div>
73 </div>
74 <div class="grid" style="margin-top:16px">
75 <div class="card"><h2>Equity Curve</h2>{equity_svg}</div>
76 <div class="card"><h2>Drawdown</h2>{dd_svg}</div>
77 </div>
78 <div class="card" style="margin-top:16px"><h2>Trade Summary</h2>{trade_table}</div>
79 <p class="footer">QuantWave · batch/streaming parity backtest · not investment advice</p>
80</body>
81</html>"##,
82 title = html_escape(&options.title),
83 metrics_table = metrics_table,
84 stats_extra = stats_extra,
85 equity_svg = equity_svg,
86 dd_svg = dd_svg,
87 trade_table = trade_table,
88 )
89}
90
91fn extract_f64_column(df: &DataFrame, name: &str) -> Vec<f64> {
92 df.column(name)
93 .ok()
94 .and_then(|c| c.f64().ok())
95 .map(|ca| ca.into_iter().map(|v| v.unwrap_or(f64::NAN)).collect())
96 .unwrap_or_default()
97}
98
99fn compute_drawdown_pct(equity: &[f64]) -> Vec<f64> {
100 let mut peak = f64::NEG_INFINITY;
101 equity
102 .iter()
103 .map(|&e| {
104 if e.is_nan() {
105 return f64::NAN;
106 }
107 if e > peak {
108 peak = e;
109 }
110 if peak <= 0.0 {
111 0.0
112 } else {
113 (peak - e) / peak * 100.0
114 }
115 })
116 .collect()
117}
118
119fn line_chart_svg(values: &[f64], width: u32, height: u32, color: &str, label: &str) -> String {
120 let valid: Vec<f64> = values.iter().copied().filter(|v| v.is_finite()).collect();
121 if valid.is_empty() {
122 return format!("<p>No {label} data</p>");
123 }
124 let min_v = valid.iter().copied().fold(f64::INFINITY, f64::min);
125 let max_v = valid.iter().copied().fold(f64::NEG_INFINITY, f64::max);
126 let range = (max_v - min_v).max(1e-12);
127 let w = width as f64;
128 let h = height as f64;
129 let pad = 8.0;
130 let n = valid.len().max(2);
131
132 let points: String = valid
133 .iter()
134 .enumerate()
135 .map(|(i, &v)| {
136 let x = pad + (i as f64 / (n - 1) as f64) * (w - 2.0 * pad);
137 let y = pad + (1.0 - (v - min_v) / range) * (h - 2.0 * pad);
138 format!("{x:.1},{y:.1}")
139 })
140 .collect::<Vec<_>>()
141 .join(" ");
142
143 format!(
144 r##"<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="{label}">
145 <rect width="100%" height="100%" fill="#f9fafb"/>
146 <polyline fill="none" stroke="{color}" stroke-width="2" points="{points}"/>
147</svg>"##,
148 width = width,
149 height = height,
150 color = color,
151 label = html_escape(label),
152 points = points,
153 )
154}
155
156fn metrics_table_html(m: &PerformanceMetrics) -> String {
157 let rows = [
158 ("Trades", format_num(m.num_trades, 0)),
159 ("Win rate", format_pct(m.win_rate)),
160 ("Profit factor", format_num(m.profit_factor, 2)),
161 ("Max drawdown", format_pct(m.max_drawdown_pct)),
162 ("CAGR", format_pct(m.cagr)),
163 ("Sharpe", format_num(m.sharpe_ratio, 2)),
164 ("Sortino", format_num(m.sortino_ratio, 2)),
165 ("Total return", format_pct(m.total_return)),
166 ("Final equity", format_num(m.final_equity, 2)),
167 ("Avg trade PnL", format_num(m.avg_trade_pnl, 2)),
168 ];
169 table_from_pairs(&rows)
170}
171
172fn stats_kv_html(stats: &std::collections::HashMap<String, f64>) -> String {
173 if stats.is_empty() {
174 return "<p>No run stats</p>".to_string();
175 }
176 let mut keys: Vec<_> = stats.keys().collect();
177 keys.sort();
178 let rows: Vec<(&str, String)> = keys
179 .iter()
180 .map(|k| (k.as_str(), format_num(stats[*k], 4)))
181 .collect();
182 table_from_pairs(&rows)
183}
184
185fn trade_stats_html(trades: &DataFrame) -> String {
186 let height = trades.height();
187 if height == 0 {
188 return "<p>No trades recorded</p>".to_string();
189 }
190 let pnls = extract_f64_column(trades, "pnl_net");
191 let wins = pnls.iter().filter(|p| **p > 0.0).count();
192 let losses = pnls.iter().filter(|p| **p <= 0.0).count();
193 let best = pnls.iter().copied().fold(f64::NEG_INFINITY, f64::max);
194 let worst = pnls.iter().copied().fold(f64::INFINITY, f64::min);
195 let sum: f64 = pnls.iter().sum();
196
197 let rows = [
198 ("Closed trades", height.to_string()),
199 ("Winning", wins.to_string()),
200 ("Losing", losses.to_string()),
201 ("Net PnL (sum)", format_num(sum, 2)),
202 ("Best trade", format_num(best, 2)),
203 ("Worst trade", format_num(worst, 2)),
204 ];
205 table_from_pairs(&rows)
206}
207
208fn table_from_pairs(rows: &[(&str, String)]) -> String {
209 let body: String = rows
210 .iter()
211 .map(|(k, v)| {
212 format!(
213 "<tr><th>{}</th><td>{}</td></tr>",
214 html_escape(k),
215 html_escape(v)
216 )
217 })
218 .collect();
219 format!("<table><tbody>{body}</tbody></table>")
220}
221
222fn format_num(v: f64, decimals: usize) -> String {
223 if !v.is_finite() {
224 return "—".to_string();
225 }
226 format!("{:.prec$}", v, prec = decimals)
227}
228
229fn format_pct(v: f64) -> String {
230 if !v.is_finite() {
231 return "—".to_string();
232 }
233 format!("{:.2}%", v * 100.0)
234}
235
236fn html_escape(s: &str) -> String {
237 s.replace('&', "&")
238 .replace('<', "<")
239 .replace('>', ">")
240 .replace('"', """)
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use crate::{BacktestConfig, BacktestEngine};
247
248 fn mini_report() -> BacktestReport {
249 let df = DataFrame::new(vec![
250 Column::new("timestamp".into(), (0i64..6).collect::<Vec<_>>()),
251 Column::new(
252 "close".into(),
253 vec![100.0, 101.0, 102.5, 103.0, 102.0, 101.0],
254 ),
255 Column::new("signal".into(), vec![0.0, 1.0, 1.0, 1.0, 0.0, 0.0]),
256 ])
257 .unwrap();
258 BacktestEngine::new(BacktestConfig::default())
259 .backtest_with_report(df.lazy())
260 .unwrap()
261 }
262
263 #[test]
264 fn tearsheet_html_contains_core_sections() {
265 let html = render_tearsheet_html(&mini_report(), &TearsheetOptions::default());
266 assert!(html.contains("<!DOCTYPE html>"));
267 assert!(html.contains("Performance Metrics"));
268 assert!(html.contains("Equity Curve"));
269 assert!(html.contains("Drawdown"));
270 assert!(html.contains("Trade Summary"));
271 assert!(html.contains("<svg"));
272 }
273}