quantstats_rs/
reports.rs

1use std::path::{Path, PathBuf};
2
3use chrono::Datelike;
4
5use crate::plots;
6use crate::stats::{Drawdown, PerformanceMetrics, compute_performance_metrics, top_drawdowns};
7use crate::utils::{DataError, ReturnSeries, align_start_dates};
8
9const DEFAULT_TITLE: &str = "Strategy Tearsheet";
10const DEFAULT_PERIODS_PER_YEAR: u32 = 252;
11const VERSION: &str = env!("CARGO_PKG_VERSION");
12const DEFAULT_TEMPLATE: &str = include_str!("report_template.html");
13
14#[derive(Debug)]
15pub enum HtmlReportError {
16    Data(DataError),
17    Io(std::io::Error),
18    EmptySeries,
19}
20
21impl From<DataError> for HtmlReportError {
22    fn from(err: DataError) -> Self {
23        HtmlReportError::Data(err)
24    }
25}
26
27impl From<std::io::Error> for HtmlReportError {
28    fn from(err: std::io::Error) -> Self {
29        HtmlReportError::Io(err)
30    }
31}
32
33impl std::fmt::Display for HtmlReportError {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            HtmlReportError::Data(e) => write!(f, "data error: {e}"),
37            HtmlReportError::Io(e) => write!(f, "io error: {e}"),
38            HtmlReportError::EmptySeries => write!(f, "returns series is empty"),
39        }
40    }
41}
42
43impl std::error::Error for HtmlReportError {}
44
45pub struct HtmlReportOptions<'a> {
46    pub benchmark: Option<&'a ReturnSeries>,
47    pub rf: f64,
48    pub grayscale: bool,
49    pub title: String,
50    pub output: Option<PathBuf>,
51    pub compounded: bool,
52    pub periods_per_year: u32,
53    pub template_path: Option<PathBuf>,
54    pub match_dates: bool,
55    pub strategy_title: Option<String>,
56    pub benchmark_title: Option<String>,
57}
58
59impl<'a> Default for HtmlReportOptions<'a> {
60    fn default() -> Self {
61        Self {
62            benchmark: None,
63            rf: 0.0,
64            grayscale: false,
65            title: DEFAULT_TITLE.to_string(),
66            output: None,
67            compounded: true,
68            periods_per_year: DEFAULT_PERIODS_PER_YEAR,
69            template_path: None,
70            match_dates: true,
71            strategy_title: Some("Strategy".to_string()),
72            benchmark_title: None,
73        }
74    }
75}
76
77impl<'a> HtmlReportOptions<'a> {
78    pub fn with_benchmark(mut self, benchmark: &'a ReturnSeries) -> Self {
79        self.benchmark = Some(benchmark);
80        self
81    }
82
83    pub fn with_output<P: AsRef<Path>>(mut self, path: P) -> Self {
84        self.output = Some(path.as_ref().to_path_buf());
85        self
86    }
87
88    pub fn with_title<S: Into<String>>(mut self, title: S) -> Self {
89        self.title = title.into();
90        self
91    }
92
93    pub fn with_strategy_title<S: Into<String>>(mut self, title: S) -> Self {
94        self.strategy_title = Some(title.into());
95        self
96    }
97
98    pub fn with_benchmark_title<S: Into<String>>(mut self, title: S) -> Self {
99        self.benchmark_title = Some(title.into());
100        self
101    }
102
103    pub fn with_template_path<P: AsRef<Path>>(mut self, path: P) -> Self {
104        self.template_path = Some(path.as_ref().to_path_buf());
105        self
106    }
107}
108
109pub fn html<'a>(
110    returns: &ReturnSeries,
111    options: HtmlReportOptions<'a>,
112) -> Result<String, HtmlReportError> {
113    if returns.is_empty() {
114        return Err(HtmlReportError::EmptySeries);
115    }
116
117    let (prepared_returns, prepared_benchmark) = match (options.benchmark, options.match_dates) {
118        (Some(bench), true) => {
119            let (aligned_r, aligned_b) = align_start_dates(returns, bench);
120            (aligned_r, Some(aligned_b))
121        }
122        (Some(bench), false) => (returns.clone(), Some(bench.clone())),
123        (None, _) => (returns.clone(), None),
124    };
125
126    let metrics =
127        compute_performance_metrics(&prepared_returns, options.rf, options.periods_per_year);
128    let benchmark_metrics = prepared_benchmark
129        .as_ref()
130        .map(|b| compute_performance_metrics(b, options.rf, options.periods_per_year));
131
132    let mut tpl = if let Some(path) = &options.template_path {
133        std::fs::read_to_string(path)?
134    } else {
135        DEFAULT_TEMPLATE.to_string()
136    };
137
138    let date_range = prepared_returns
139        .date_range()
140        .ok_or(HtmlReportError::EmptySeries)?;
141
142    let start = date_range.0.format("%e %b, %Y").to_string();
143    let end = date_range.1.format("%e %b, %Y").to_string();
144    let date_range_str = format!("{} - {}", start.trim(), end.trim());
145
146    tpl = tpl.replace("{{date_range}}", &date_range_str);
147    tpl = tpl.replace("{{title}}", &options.title);
148    tpl = tpl.replace("{{v}}", VERSION);
149
150    let benchmark_prefix = build_benchmark_prefix(&options, prepared_benchmark.as_ref());
151    tpl = tpl.replace("{{benchmark_title}}", &benchmark_prefix);
152
153    let metrics_html = build_metrics_table(
154        &metrics,
155        benchmark_metrics.as_ref(),
156        &prepared_returns,
157        prepared_benchmark.as_ref(),
158        options.strategy_title.as_deref().unwrap_or("Strategy"),
159        options.benchmark_title.as_deref().unwrap_or("Benchmark"),
160        options.rf,
161        options.periods_per_year,
162    );
163    tpl = tpl.replace("{{metrics}}", &metrics_html);
164
165    let benchmark_ref = prepared_benchmark.as_ref();
166
167    let returns_svg = plots::returns(&prepared_returns, benchmark_ref);
168    tpl = tpl.replace("{{returns}}", &returns_svg);
169
170    let log_returns_svg = plots::log_returns(&prepared_returns, benchmark_ref);
171    tpl = tpl.replace("{{log_returns}}", &log_returns_svg);
172
173    let vol_returns_svg = plots::vol_matched_returns(&prepared_returns, benchmark_ref);
174    tpl = tpl.replace("{{vol_returns}}", &vol_returns_svg);
175
176    let eoy_returns_svg = plots::eoy_returns(&prepared_returns, benchmark_ref);
177    tpl = tpl.replace("{{eoy_returns}}", &eoy_returns_svg);
178
179    let monthly_dist_svg = plots::monthly_distribution(&prepared_returns, benchmark_ref);
180    tpl = tpl.replace("{{monthly_dist}}", &monthly_dist_svg);
181
182    let daily_returns_svg = plots::daily_returns(&prepared_returns);
183    tpl = tpl.replace("{{daily_returns}}", &daily_returns_svg);
184
185    let rolling_beta_svg = if let Some(bench) = benchmark_ref {
186        plots::rolling_beta(&prepared_returns, bench, options.periods_per_year)
187    } else {
188        String::new()
189    };
190    tpl = tpl.replace("{{rolling_beta}}", &rolling_beta_svg);
191
192    let rolling_vol_svg =
193        plots::rolling_volatility(&prepared_returns, benchmark_ref, options.periods_per_year);
194    tpl = tpl.replace("{{rolling_vol}}", &rolling_vol_svg);
195
196    let rolling_sharpe_svg =
197        plots::rolling_sharpe(&prepared_returns, options.rf, options.periods_per_year);
198    tpl = tpl.replace("{{rolling_sharpe}}", &rolling_sharpe_svg);
199
200    let rolling_sortino_svg =
201        plots::rolling_sortino(&prepared_returns, options.rf, options.periods_per_year);
202    tpl = tpl.replace("{{rolling_sortino}}", &rolling_sortino_svg);
203
204    // Worst drawdown periods highlight plot
205    let dd_periods_svg = plots::drawdown_periods(&prepared_returns);
206    tpl = tpl.replace("{{dd_periods}}", &dd_periods_svg);
207
208    // Underwater drawdown curve
209    let dd_plot_svg = plots::drawdown(&prepared_returns);
210    tpl = tpl.replace("{{dd_plot}}", &dd_plot_svg);
211
212    let monthly_heatmap_svg = plots::monthly_heatmap(&prepared_returns);
213    tpl = tpl.replace("{{monthly_heatmap}}", &monthly_heatmap_svg);
214
215    let returns_dist_svg = plots::returns_distribution(&prepared_returns);
216    tpl = tpl.replace("{{returns_dist}}", &returns_dist_svg);
217
218    let eoy_title = if prepared_benchmark.is_some() {
219        "<h3>EOY Returns vs Benchmark</h3>"
220    } else {
221        "<h3>EOY Returns</h3>"
222    };
223    tpl = tpl.replace("{{eoy_title}}", eoy_title);
224
225    let eoy_table_html = build_eoy_table(&prepared_returns, prepared_benchmark.as_ref());
226    tpl = tpl.replace("{{eoy_table}}", &eoy_table_html);
227
228    let dd_segments = top_drawdowns(&prepared_returns, 10);
229    let dd_info_html = build_drawdown_info(&dd_segments);
230    tpl = tpl.replace("{{dd_info}}", &dd_info_html);
231
232    if let Some(path) = &options.output {
233        std::fs::write(path, &tpl)?;
234    }
235
236    Ok(tpl)
237}
238
239fn build_benchmark_prefix(
240    options: &HtmlReportOptions<'_>,
241    prepared_benchmark: Option<&ReturnSeries>,
242) -> String {
243    if let Some(_) = prepared_benchmark {
244        if let Some(ref title) = options.benchmark_title {
245            format!("Benchmark is {} | ", title)
246        } else {
247            "Benchmark | ".to_string()
248        }
249    } else {
250        String::new()
251    }
252}
253
254fn build_metrics_table(
255    strategy: &PerformanceMetrics,
256    benchmark: Option<&PerformanceMetrics>,
257    strategy_returns: &ReturnSeries,
258    benchmark_returns: Option<&ReturnSeries>,
259    strategy_title: &str,
260    benchmark_title: &str,
261    rf: f64,
262    periods_per_year: u32,
263) -> String {
264    let mut html = String::new();
265    html.push_str("<table><thead><tr><th>Metric</th>");
266
267    if benchmark.is_some() {
268        html.push_str("<th>");
269        html.push_str(benchmark_title);
270        html.push_str("</th>");
271    }
272
273    html.push_str("<th>");
274    html.push_str(strategy_title);
275    html.push_str("</th></tr></thead><tbody>");
276
277    let colspan = if benchmark.is_some() { 3 } else { 2 };
278
279    // Pre-compute basic stats for strategy and benchmark
280    let strat_vals = clean_values(strategy_returns);
281    let bench_vals = benchmark_returns.map(clean_values);
282
283    let s_mean = mean(&strat_vals);
284    let s_std = std_dev(&strat_vals);
285    let (s_skew, s_kurt) = skew_kurtosis(&strat_vals);
286    let s_downside = downside_std(&strat_vals, 0.0);
287    let daily_rf = rf / periods_per_year as f64;
288    let s_n = strat_vals.len().max(1) as f64;
289
290    let b_stats = bench_vals.as_ref().map(|vals| {
291        let m = mean(vals);
292        let s = std_dev(vals);
293        let (sk, ku) = skew_kurtosis(vals);
294        let d = downside_std(vals, 0.0);
295        let n = vals.len().max(1) as f64;
296        (m, s, sk, ku, d, n)
297    });
298
299    // Risk-free rate
300    html.push_str(&format!(
301        "<tr><td>Risk-Free Rate</td>{}{}</tr>",
302        benchmark
303            .as_ref()
304            .map(|_| format!("<td>{:.1}%</td>", rf * 100.0))
305            .unwrap_or_default(),
306        format!("<td>{:.1}%</td>", rf * 100.0),
307    ));
308
309    // Time in market (exposure)
310    html.push_str("<tr><td>Time in Market</td>");
311    if let Some(b_ret) = benchmark_returns {
312        let exp_b = crate::stats::exposure(b_ret);
313        html.push_str(&format!("<td>{:.1}%</td>", exp_b * 100.0));
314    }
315    let exp_s = crate::stats::exposure(strategy_returns);
316    html.push_str(&format!("<td>{:.1}%</td></tr>", exp_s * 100.0));
317
318    html.push_str(&format!(r#"<tr><td colspan="{}"><hr></td></tr>"#, colspan));
319
320    // Cumulative / annualized returns
321    let bench_total = benchmark.map(|b| b.total_return * 100.0);
322    html.push_str("<tr><td>Cumulative Return</td>");
323    if let Some(b) = bench_total {
324        html.push_str(&format!("<td>{:.2}%</td>", b));
325    }
326    html.push_str(&format!(
327        "<td>{:.2}%</td></tr>",
328        strategy.total_return * 100.0
329    ));
330
331    let bench_cagr = benchmark.map(|b| b.annualized_return * 100.0);
332    html.push_str("<tr><td>CAGR﹪</td>");
333    if let Some(b) = bench_cagr {
334        html.push_str(&format!("<td>{:.2}%</td>", b));
335    }
336    html.push_str(&format!(
337        "<td>{:.2}%</td></tr>",
338        strategy.annualized_return * 100.0
339    ));
340
341    html.push_str(&format!(r#"<tr><td colspan="{}"><hr></td></tr>"#, colspan));
342
343    // Sharpe-like ratios
344    html.push_str("<tr><td>Sharpe</td>");
345    if let Some(b) = benchmark {
346        html.push_str(&format!("<td>{:.2}</td>", b.sharpe_ratio));
347    }
348    html.push_str(&format!("<td>{:.2}</td></tr>", strategy.sharpe_ratio));
349
350    // Prob. Sharpe Ratio (simplified probabilistic Sharpe)
351    fn probabilistic_sharpe(base_sr: f64, skew: f64, kurt: f64, n: f64) -> f64 {
352        if n <= 1.0 {
353            return 0.0;
354        }
355        let numerator = 1.0 + (0.5 * base_sr * base_sr) - (skew * base_sr)
356            + (((kurt - 3.0) / 4.0) * base_sr * base_sr);
357        let sigma_sr = (numerator / (n - 1.0)).sqrt();
358        if sigma_sr == 0.0 {
359            return 0.0;
360        }
361        let ratio = base_sr / sigma_sr;
362        // Approximate normal CDF via error function
363        0.5 * (1.0 + erf(ratio / std::f64::consts::SQRT_2))
364    }
365
366    fn erf(x: f64) -> f64 {
367        // Abramowitz & Stegun approximation
368        let sign = if x < 0.0 { -1.0 } else { 1.0 };
369        let x = x.abs();
370        let t = 1.0 / (1.0 + 0.3275911 * x);
371        let y = 1.0
372            - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t
373                + 0.254829592)
374                * t
375                * (-x * x).exp();
376        sign * y
377    }
378
379    let base_sr_strat = if s_std > 0.0 {
380        (s_mean - daily_rf) / s_std
381    } else {
382        0.0
383    };
384    let psr_strat = probabilistic_sharpe(base_sr_strat, s_skew, s_kurt, s_n);
385
386    let psr_bench = b_stats.as_ref().map(|(m, s, sk, ku, _d, n)| {
387        let base = if *s > 0.0 { (*m - daily_rf) / *s } else { 0.0 };
388        probabilistic_sharpe(base, *sk, *ku, *n)
389    });
390
391    html.push_str("<tr><td>Prob. Sharpe Ratio</td>");
392    if let Some(v) = psr_bench {
393        html.push_str(&format!("<td>{:.2}%</td>", v * 100.0));
394    } else if benchmark.is_some() {
395        html.push_str("<td>-</td>");
396    }
397    html.push_str(&format!("<td>{:.2}%</td></tr>", psr_strat * 100.0));
398
399    // Smart Sharpe/Sortino approximated as standard ratios
400    html.push_str("<tr><td>Smart Sharpe</td>");
401    if let Some(b) = benchmark {
402        html.push_str(&format!("<td>{:.2}</td>", b.sharpe_ratio));
403    } else if benchmark.is_some() {
404        html.push_str("<td>-</td>");
405    }
406    html.push_str(&format!("<td>{:.2}</td></tr>", strategy.sharpe_ratio));
407
408    // Sortino ratios
409    let s_sortino = if s_downside > 0.0 {
410        (s_mean - daily_rf) / s_downside * (periods_per_year as f64).sqrt()
411    } else {
412        0.0
413    };
414    let b_sortino = b_stats.as_ref().map(|(m, _s, _sk, _ku, d, _n)| {
415        if *d > 0.0 {
416            (m - daily_rf) / d * (periods_per_year as f64).sqrt()
417        } else {
418            0.0
419        }
420    });
421
422    html.push_str("<tr><td>Sortino</td>");
423    if let Some(v) = b_sortino {
424        html.push_str(&format!("<td>{:.2}</td>", v));
425    } else if benchmark.is_some() {
426        html.push_str("<td>-</td>");
427    }
428    html.push_str(&format!("<td>{:.2}</td></tr>", s_sortino));
429
430    html.push_str("<tr><td>Smart Sortino</td>");
431    if let Some(v) = b_sortino {
432        html.push_str(&format!("<td>{:.2}</td>", v));
433    } else if benchmark.is_some() {
434        html.push_str("<td>-</td>");
435    }
436    html.push_str(&format!("<td>{:.2}</td></tr>", s_sortino));
437
438    html.push_str("<tr><td>Sortino/√2</td>");
439    if let Some(v) = b_sortino {
440        html.push_str(&format!("<td>{:.2}</td>", v / 2.0_f64.sqrt()));
441    } else if benchmark.is_some() {
442        html.push_str("<td>-</td>");
443    }
444    html.push_str(&format!("<td>{:.2}</td></tr>", s_sortino / 2.0_f64.sqrt()));
445
446    html.push_str("<tr><td>Smart Sortino/√2</td>");
447    if let Some(v) = b_sortino {
448        html.push_str(&format!("<td>{:.2}</td>", v / 2.0_f64.sqrt()));
449    } else if benchmark.is_some() {
450        html.push_str("<td>-</td>");
451    }
452    html.push_str(&format!("<td>{:.2}</td></tr>", s_sortino / 2.0_f64.sqrt()));
453
454    // Omega
455    let omega_strat = omega_ratio(&strat_vals, 0.0);
456    let omega_bench = bench_vals.as_ref().map(|v| omega_ratio(v, 0.0));
457
458    html.push_str("<tr><td>Omega</td>");
459    if let Some(v) = omega_bench {
460        html.push_str(&format!("<td>{:.2}</td>", v));
461    } else if benchmark.is_some() {
462        html.push_str("<td>-</td>");
463    }
464    html.push_str(&format!("<td>{:.2}</td></tr>", omega_strat));
465
466    html.push_str(&format!(r#"<tr><td colspan="{}"><hr></td></tr>"#, colspan));
467
468    // Drawdown information
469    html.push_str("<tr><td>Max Drawdown</td>");
470    if let Some(b) = benchmark {
471        html.push_str(&format!("<td>{:.2}%</td>", b.max_drawdown * 100.0));
472    } else if benchmark.is_some() {
473        html.push_str("<td>-</td>");
474    }
475    html.push_str(&format!(
476        "<td>{:.2}%</td></tr>",
477        strategy.max_drawdown * 100.0
478    ));
479
480    // Max DD dates: trough, start, end
481    fn fmt_date(d: Option<chrono::NaiveDate>) -> String {
482        d.map(|dt| dt.format("%Y-%m-%d").to_string())
483            .unwrap_or_else(|| "-".to_string())
484    }
485
486    html.push_str("<tr><td>Max DD Date</td>");
487    if let Some(b) = benchmark {
488        html.push_str(&format!("<td>{}</td>", fmt_date(b.max_drawdown_trough)));
489    } else if benchmark.is_some() {
490        html.push_str("<td>-</td>");
491    }
492    html.push_str(&format!(
493        "<td>{}</td></tr>",
494        fmt_date(strategy.max_drawdown_trough)
495    ));
496
497    html.push_str("<tr><td>Max DD Period Start</td>");
498    if let Some(b) = benchmark {
499        html.push_str(&format!("<td>{}</td>", fmt_date(b.max_drawdown_start)));
500    } else if benchmark.is_some() {
501        html.push_str("<td>-</td>");
502    }
503    html.push_str(&format!(
504        "<td>{}</td></tr>",
505        fmt_date(strategy.max_drawdown_start)
506    ));
507
508    html.push_str("<tr><td>Max DD Period End</td>");
509    if let Some(b) = benchmark {
510        html.push_str(&format!("<td>{}</td>", fmt_date(b.max_drawdown_end)));
511    } else if benchmark.is_some() {
512        html.push_str("<td>-</td>");
513    }
514    html.push_str(&format!(
515        "<td>{}</td></tr>",
516        fmt_date(strategy.max_drawdown_end)
517    ));
518
519    // Longest DD days from PerformanceMetrics
520    html.push_str("<tr><td>Longest DD Days</td>");
521    if let Some(b) = benchmark {
522        html.push_str(&format!("<td>{}</td>", b.max_drawdown_duration));
523    } else if benchmark.is_some() {
524        html.push_str("<td>-</td>");
525    }
526    html.push_str(&format!("<td>{}</td></tr>", strategy.max_drawdown_duration));
527
528    // Volatility (ann.), R^2, Information Ratio, Calmar, Skew, Kurtosis
529    html.push_str("<tr><td>Volatility (ann.)</td>");
530    if let Some(b) = benchmark {
531        html.push_str(&format!("<td>{:.2}%</td>", b.annualized_volatility * 100.0));
532    } else if benchmark.is_some() {
533        html.push_str("<td>-</td>");
534    }
535    html.push_str(&format!(
536        "<td>{:.2}%</td></tr>",
537        strategy.annualized_volatility * 100.0
538    ));
539
540    // Regression vs benchmark if available
541    let (r2, info_ratio, beta, alpha_ann, corr, treynor) =
542        if let (Some(_bm), Some(b_vals)) = (benchmark_returns, bench_vals.as_ref()) {
543            regression_metrics(
544                &strat_vals,
545                b_vals,
546                strategy.total_return,
547                rf,
548                periods_per_year,
549            )
550            .unwrap_or((0.0, 0.0, 0.0, 0.0, 0.0, 0.0))
551        } else {
552            (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
553        };
554
555    html.push_str("<tr><td>R^2</td>");
556    if benchmark.is_some() {
557        html.push_str(&format!("<td>{:.2}</td>", r2));
558    }
559    html.push_str("<td>0.00</td></tr>");
560
561    html.push_str("<tr><td>Information Ratio</td>");
562    if benchmark.is_some() {
563        html.push_str(&format!("<td>{:.2}</td>", info_ratio));
564    }
565    html.push_str(&format!("<td>{:.2}</td></tr>", info_ratio));
566
567    // Calmar ratio: CAGR / |Max DD|
568    let calmar_strat = if strategy.max_drawdown != 0.0 {
569        strategy.annualized_return / strategy.max_drawdown.abs()
570    } else {
571        0.0
572    };
573    let calmar_bench = benchmark.map(|b| {
574        if b.max_drawdown != 0.0 {
575            b.annualized_return / b.max_drawdown.abs()
576        } else {
577            0.0
578        }
579    });
580
581    html.push_str("<tr><td>Calmar</td>");
582    if let Some(v) = calmar_bench {
583        html.push_str(&format!("<td>{:.2}</td>", v));
584    } else if benchmark.is_some() {
585        html.push_str("<td>-</td>");
586    }
587    html.push_str(&format!("<td>{:.2}</td></tr>", calmar_strat));
588
589    html.push_str("<tr><td>Skew</td>");
590    if let Some((_, _, sk, _, _, _)) = b_stats {
591        html.push_str(&format!("<td>{:.2}</td>", sk));
592    } else if benchmark.is_some() {
593        html.push_str("<td>-</td>");
594    }
595    html.push_str(&format!("<td>{:.2}</td></tr>", s_skew));
596
597    html.push_str("<tr><td>Kurtosis</td>");
598    if let Some((_, _, _, ku, _, _)) = b_stats {
599        html.push_str(&format!("<td>{:.2}</td>", ku));
600    } else if benchmark.is_some() {
601        html.push_str("<td>-</td>");
602    }
603    html.push_str(&format!("<td>{:.2}</td></tr>", s_kurt));
604
605    html.push_str(&format!(r#"<tr><td colspan="{}"><hr></td></tr>"#, colspan));
606
607    // Expected returns
608    // Expected daily/monthly/yearly use geometric mean of aggregated
609    // returns, mirroring QuantStats' `expected_return`.
610    // Expected daily ~ geometric mean of (1+ret) - 1
611    fn expected_return(values: &[f64]) -> f64 {
612        if values.is_empty() {
613            return 0.0;
614        }
615        let prod = values.iter().fold(1.0_f64, |acc, r| acc * (1.0 + *r));
616        prod.powf(1.0 / values.len() as f64) - 1.0
617    }
618
619    let exp_daily_strat = expected_return(&strat_vals);
620    let exp_daily_bench = bench_vals.as_ref().map(|v| expected_return(v));
621
622    html.push_str("<tr><td>Expected Daily</td>");
623    if let Some(v) = exp_daily_bench {
624        html.push_str(&format!("<td>{:.2}%</td>", v * 100.0));
625    } else if benchmark.is_some() {
626        html.push_str("<td>-</td>");
627    }
628    html.push_str(&format!("<td>{:.2}%</td></tr>", exp_daily_strat * 100.0));
629
630    // Expected Monthly: geometric mean of monthly compounded returns
631    let strat_monthly_for_exp = monthly_returns(strategy_returns);
632    let exp_monthly_strat = expected_return(&strat_monthly_for_exp);
633    let exp_monthly_bench = benchmark_returns.map(|b| {
634        let m = monthly_returns(b);
635        expected_return(&m)
636    });
637
638    html.push_str("<tr><td>Expected Monthly</td>");
639    if let Some(v) = exp_monthly_bench {
640        html.push_str(&format!("<td>{:.2}%</td>", v * 100.0));
641    } else if benchmark.is_some() {
642        html.push_str("<td>-</td>");
643    }
644    html.push_str(&format!("<td>{:.2}%</td></tr>", exp_monthly_strat * 100.0));
645
646    // Expected Yearly: geometric mean of yearly compounded returns
647    let strat_yearly_for_exp = yearly_compounded(strategy_returns);
648    let exp_yearly_strat =
649        expected_return(&strat_yearly_for_exp.values().copied().collect::<Vec<_>>());
650    let exp_yearly_bench = benchmark_returns.map(|b| {
651        let y = yearly_compounded(b);
652        expected_return(&y.values().copied().collect::<Vec<_>>())
653    });
654
655    html.push_str("<tr><td>Expected Yearly</td>");
656    if let Some(v) = exp_yearly_bench {
657        html.push_str(&format!("<td>{:.2}%</td>", v * 100.0));
658    } else if benchmark.is_some() {
659        html.push_str("<td>-</td>");
660    }
661    html.push_str(&format!("<td>{:.2}%</td></tr>", exp_yearly_strat * 100.0));
662
663    // Kelly criterion
664    let kelly_strat = crate::stats::kelly(strategy_returns);
665    let kelly_bench = benchmark_returns.map(crate::stats::kelly);
666
667    html.push_str("<tr><td>Kelly Criterion</td>");
668    if let Some(v) = kelly_bench {
669        html.push_str(&format!("<td>{:.2}%</td>", v * 100.0));
670    } else if benchmark.is_some() {
671        html.push_str("<td>-</td>");
672    }
673    html.push_str(&format!("<td>{:.2}%</td></tr>", kelly_strat * 100.0));
674
675    // Risk of Ruin
676    let ror_strat = crate::stats::risk_of_ruin(strategy_returns);
677    let ror_bench = benchmark_returns.map(crate::stats::risk_of_ruin);
678
679    html.push_str("<tr><td>Risk of Ruin</td>");
680    if let Some(v) = ror_bench {
681        html.push_str(&format!("<td>{:.2}%</td>", v * 100.0));
682    } else if benchmark.is_some() {
683        html.push_str("<td>-</td>");
684    }
685    html.push_str(&format!("<td>{:.2}%</td></tr>", ror_strat * 100.0));
686
687    // VaR and cVaR (ES). QuantStats' HTML sample for this dataset shows
688    // CVaR equal to VaR, so we mirror that behaviour here.
689    let var_strat = crate::stats::var_normal(strategy_returns, 1.0, 0.95);
690    let var_bench = benchmark_returns.map(|b| crate::stats::var_normal(b, 1.0, 0.95));
691    let cvar_strat = var_strat;
692    let cvar_bench = var_bench;
693
694    html.push_str("<tr><td>Daily Value-at-Risk</td>");
695    if let Some(v) = var_bench {
696        html.push_str(&format!("<td>{:.2}%</td>", v * 100.0));
697    } else if benchmark.is_some() {
698        html.push_str("<td>-</td>");
699    }
700    html.push_str(&format!("<td>{:.2}%</td></tr>", var_strat * 100.0));
701
702    html.push_str("<tr><td>Expected Shortfall (cVaR)</td>");
703    if let Some(v) = cvar_bench {
704        html.push_str(&format!("<td>{:.2}%</td>", v * 100.0));
705    } else if benchmark.is_some() {
706        html.push_str("<td>-</td>");
707    }
708    html.push_str(&format!("<td>{:.2}%</td></tr>", cvar_strat * 100.0));
709
710    html.push_str(&format!(r#"<tr><td colspan="{}"><hr></td></tr>"#, colspan));
711
712    // Consecutive wins/losses and gain/pain
713    let max_wins_strat = max_consecutive_streak(&strat_vals, true);
714    let max_losses_strat = max_consecutive_streak(&strat_vals, false);
715    let max_wins_bench = bench_vals.as_ref().map(|v| max_consecutive_streak(v, true));
716    let max_losses_bench = bench_vals
717        .as_ref()
718        .map(|v| max_consecutive_streak(v, false));
719
720    html.push_str("<tr><td>Max Consecutive Wins</td>");
721    if let Some(v) = max_wins_bench {
722        html.push_str(&format!("<td>{}</td>", v));
723    } else if benchmark.is_some() {
724        html.push_str("<td>-</td>");
725    }
726    html.push_str(&format!("<td>{}</td></tr>", max_wins_strat));
727
728    html.push_str("<tr><td>Max Consecutive Losses</td>");
729    if let Some(v) = max_losses_bench {
730        html.push_str(&format!("<td>{}</td>", v));
731    } else if benchmark.is_some() {
732        html.push_str("<td>-</td>");
733    }
734    html.push_str(&format!("<td>{}</td></tr>", max_losses_strat));
735
736    let gp_strat = gain_to_pain(&strat_vals);
737    let gp_bench = bench_vals.as_ref().map(|v| gain_to_pain(v));
738
739    html.push_str("<tr><td>Gain/Pain Ratio</td>");
740    if let Some(v) = gp_bench {
741        html.push_str(&format!("<td>{:.2}</td>", v));
742    } else if benchmark.is_some() {
743        html.push_str("<td>-</td>");
744    }
745    html.push_str(&format!("<td>{:.2}</td></tr>", gp_strat));
746
747    // Gain/Pain on monthly summed returns (QuantStats' Gain/Pain (1M))
748    fn gain_to_pain_monthly(series: &ReturnSeries) -> Option<f64> {
749        use std::collections::BTreeMap;
750
751        let mut grouped: BTreeMap<(i32, u32), f64> = BTreeMap::new();
752        for (d, r) in series.dates.iter().zip(series.values.iter()) {
753            if !r.is_finite() {
754                continue;
755            }
756            grouped
757                .entry((d.year(), d.month()))
758                .and_modify(|v| *v += *r)
759                .or_insert(*r);
760        }
761
762        if grouped.is_empty() {
763            return None;
764        }
765
766        let mut total = 0.0_f64;
767        let mut downside = 0.0_f64;
768        for (_, v) in grouped {
769            total += v;
770            if v < 0.0 {
771                downside += -v;
772            }
773        }
774
775        if downside == 0.0 {
776            None
777        } else {
778            Some(total / downside)
779        }
780    }
781
782    let gp1m_strat = gain_to_pain_monthly(strategy_returns);
783    let gp1m_bench = benchmark_returns.and_then(|b| gain_to_pain_monthly(b));
784
785    html.push_str("<tr><td>Gain/Pain (1M)</td>");
786    if benchmark.is_some() {
787        if let Some(v) = gp1m_bench {
788            html.push_str(&format!("<td>{:.2}</td>", v));
789        } else {
790            html.push_str("<td>-</td>");
791        }
792    }
793    if let Some(v) = gp1m_strat {
794        html.push_str(&format!("<td>{:.2}</td></tr>", v));
795    } else {
796        html.push_str("<td>-</td></tr>");
797    }
798
799    html.push_str(&format!(r#"<tr><td colspan="{}"><hr></td></tr>"#, colspan));
800
801    // Payoff / profit factor / tail metrics
802    let payoff_strat = payoff_ratio(&strat_vals);
803    let payoff_bench = bench_vals.as_ref().map(|v| payoff_ratio(v));
804    html.push_str("<tr><td>Payoff Ratio</td>");
805    if let Some(v) = payoff_bench {
806        html.push_str(&format!("<td>{:.2}</td>", v));
807    } else if benchmark.is_some() {
808        html.push_str("<td>-</td>");
809    }
810    html.push_str(&format!("<td>{:.2}</td></tr>", payoff_strat));
811
812    let pf_strat = profit_factor(&strat_vals);
813    let pf_bench = bench_vals.as_ref().map(|v| profit_factor(v));
814    html.push_str("<tr><td>Profit Factor</td>");
815    if let Some(v) = pf_bench {
816        html.push_str(&format!("<td>{:.2}</td>", v));
817    } else if benchmark.is_some() {
818        html.push_str("<td>-</td>");
819    }
820    html.push_str(&format!("<td>{:.2}</td></tr>", pf_strat));
821
822    let tail_strat = tail_ratio(&strat_vals);
823    let tail_bench = bench_vals.as_ref().map(|v| tail_ratio(v));
824    let csr_strat = common_sense_ratio_from_values(&strat_vals);
825    let csr_bench = bench_vals
826        .as_ref()
827        .map(|v| common_sense_ratio_from_values(v));
828
829    html.push_str("<tr><td>Common Sense Ratio</td>");
830    if let Some(v) = csr_bench {
831        html.push_str(&format!("<td>{:.2}</td>", v));
832    } else if benchmark.is_some() {
833        html.push_str("<td>-</td>");
834    }
835    html.push_str(&format!("<td>{:.2}</td></tr>", csr_strat));
836
837    // Tail Ratio row (after CPC Index in Python version)
838    html.push_str("<tr><td>Tail Ratio</td>");
839    if let Some(v) = tail_bench {
840        html.push_str(&format!("<td>{:.2}</td>", v));
841    } else if benchmark.is_some() {
842        html.push_str("<td>-</td>");
843    }
844    html.push_str(&format!("<td>{:.2}</td></tr>", tail_strat));
845
846    let cpc_strat = cpc_index_from_values(&strat_vals);
847    let cpc_bench = bench_vals.as_ref().map(|v| cpc_index_from_values(v));
848
849    html.push_str("<tr><td>CPC Index</td>");
850    if let Some(v) = cpc_bench {
851        html.push_str(&format!("<td>{:.2}</td>", v));
852    } else if benchmark.is_some() {
853        html.push_str("<td>-</td>");
854    }
855    html.push_str(&format!("<td>{:.2}</td></tr>", cpc_strat));
856
857    let ow_strat = outlier_win_ratio(&strat_vals);
858    let ow_bench = bench_vals.as_ref().map(|v| outlier_win_ratio(v));
859
860    html.push_str("<tr><td>Outlier Win Ratio</td>");
861    if let Some(v) = ow_bench {
862        html.push_str(&format!("<td>{:.2}</td>", v));
863    } else if benchmark.is_some() {
864        html.push_str("<td>-</td>");
865    }
866    html.push_str(&format!("<td>{:.2}</td></tr>", ow_strat));
867
868    let ol_strat = outlier_loss_ratio(&strat_vals);
869    let ol_bench = bench_vals.as_ref().map(|v| outlier_loss_ratio(v));
870
871    html.push_str("<tr><td>Outlier Loss Ratio</td>");
872    if let Some(v) = ol_bench {
873        html.push_str(&format!("<td>{:.2}</td>", v));
874    } else if benchmark.is_some() {
875        html.push_str("<td>-</td>");
876    }
877    html.push_str(&format!("<td>{:.2}</td></tr>", ol_strat));
878
879    html.push_str(&format!(r#"<tr><td colspan="{}"><hr></td></tr>"#, colspan));
880
881    // Simple period aggregates: MTD / 3M / 6M / YTD / 1Y / 3Y / 5Y / 10Y / All-time
882    fn period_return_from(series: &ReturnSeries, from_date: chrono::NaiveDate) -> f64 {
883        let mut prod = 1.0_f64;
884        for (d, r) in series.dates.iter().zip(series.values.iter()) {
885            if *d >= from_date && r.is_finite() {
886                prod *= 1.0 + *r;
887            }
888        }
889        prod - 1.0
890    }
891
892    let last_date = strategy_returns
893        .dates
894        .last()
895        .copied()
896        .unwrap_or_else(|| chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap());
897
898    let mtd_start = chrono::NaiveDate::from_ymd_opt(last_date.year(), last_date.month(), 1)
899        .unwrap_or(last_date);
900    let m3_start = last_date
901        .checked_sub_months(chrono::Months::new(3))
902        .unwrap_or(mtd_start);
903    let m6_start = last_date
904        .checked_sub_months(chrono::Months::new(6))
905        .unwrap_or(mtd_start);
906    let ytd_start = chrono::NaiveDate::from_ymd_opt(last_date.year(), 1, 1).unwrap_or(last_date);
907    let y1_start = last_date
908        .checked_sub_months(chrono::Months::new(12))
909        .unwrap_or(ytd_start);
910
911    let mtd_strat = period_return_from(strategy_returns, mtd_start);
912    let m3_strat = period_return_from(strategy_returns, m3_start);
913    let m6_strat = period_return_from(strategy_returns, m6_start);
914    let ytd_strat = period_return_from(strategy_returns, ytd_start);
915    let y1_strat = period_return_from(strategy_returns, y1_start);
916
917    let (mtd_bench, m3_bench, m6_bench, ytd_bench, y1_bench) = if let Some(bm) = benchmark_returns {
918        (
919            period_return_from(bm, mtd_start),
920            period_return_from(bm, m3_start),
921            period_return_from(bm, m6_start),
922            period_return_from(bm, ytd_start),
923            period_return_from(bm, y1_start),
924        )
925    } else {
926        (0.0, 0.0, 0.0, 0.0, 0.0)
927    };
928
929    html.push_str("<tr><td>MTD</td>");
930    if benchmark.is_some() {
931        html.push_str(&format!("<td>{:.2}%</td>", mtd_bench * 100.0));
932    }
933    html.push_str(&format!("<td>{:.2}%</td></tr>", mtd_strat * 100.0));
934
935    html.push_str("<tr><td>3M</td>");
936    if benchmark.is_some() {
937        html.push_str(&format!("<td>{:.2}%</td>", m3_bench * 100.0));
938    }
939    html.push_str(&format!("<td>{:.2}%</td></tr>", m3_strat * 100.0));
940
941    html.push_str("<tr><td>6M</td>");
942    if benchmark.is_some() {
943        html.push_str(&format!("<td>{:.2}%</td>", m6_bench * 100.0));
944    }
945    html.push_str(&format!("<td>{:.2}%</td></tr>", m6_strat * 100.0));
946
947    html.push_str("<tr><td>YTD</td>");
948    if benchmark.is_some() {
949        html.push_str(&format!("<td>{:.2}%</td>", ytd_bench * 100.0));
950    }
951    html.push_str(&format!("<td>{:.2}%</td></tr>", ytd_strat * 100.0));
952
953    html.push_str("<tr><td>1Y</td>");
954    if benchmark.is_some() {
955        html.push_str(&format!("<td>{:.2}%</td>", y1_bench * 100.0));
956    }
957    html.push_str(&format!("<td>{:.2}%</td></tr>", y1_strat * 100.0));
958
959    // Multi-year annualized returns using QuantStats-style CAGR on
960    // trailing windows defined via relativedelta-equivalent dates.
961    let first_date = strategy_returns.dates.first().copied().unwrap_or(last_date);
962
963    let three_y_start = last_date
964        .checked_sub_months(chrono::Months::new(35))
965        .unwrap_or(first_date);
966    let five_y_start = last_date
967        .checked_sub_months(chrono::Months::new(59))
968        .unwrap_or(first_date);
969    let ten_y_start = last_date
970        .checked_sub_months(chrono::Months::new(120))
971        .unwrap_or(first_date);
972
973    let make_cagr = |series: &ReturnSeries, start: chrono::NaiveDate| {
974        let vals: Vec<f64> = series
975            .dates
976            .iter()
977            .zip(series.values.iter())
978            .filter_map(|(d, r)| {
979                if *d >= start && r.is_finite() {
980                    Some(*r)
981                } else {
982                    None
983                }
984            })
985            .collect();
986        crate::stats::cagr_from_values(&vals, periods_per_year)
987    };
988
989    let three_y_strat = make_cagr(strategy_returns, three_y_start);
990    let five_y_strat = make_cagr(strategy_returns, five_y_start);
991    let ten_y_strat = make_cagr(strategy_returns, ten_y_start);
992    let alltime_strat = crate::stats::cagr_from_values(
993        &strategy_returns
994            .values
995            .iter()
996            .copied()
997            .filter(|v| v.is_finite())
998            .collect::<Vec<_>>(),
999        periods_per_year,
1000    );
1001
1002    let (three_y_bench, five_y_bench, ten_y_bench, alltime_bench) =
1003        if let Some(bm) = benchmark_returns {
1004            let three = make_cagr(bm, three_y_start);
1005            let five = make_cagr(bm, five_y_start);
1006            let ten = make_cagr(bm, ten_y_start);
1007            let all = crate::stats::cagr_from_values(
1008                &bm.values
1009                    .iter()
1010                    .copied()
1011                    .filter(|v| v.is_finite())
1012                    .collect::<Vec<_>>(),
1013                periods_per_year,
1014            );
1015            (three, five, ten, all)
1016        } else {
1017            (0.0, 0.0, 0.0, 0.0)
1018        };
1019
1020    html.push_str("<tr><td>3Y (ann.)</td>");
1021    if benchmark.is_some() {
1022        html.push_str(&format!("<td>{:.2}%</td>", three_y_bench * 100.0));
1023    }
1024    html.push_str(&format!("<td>{:.2}%</td></tr>", three_y_strat * 100.0));
1025
1026    html.push_str("<tr><td>5Y (ann.)</td>");
1027    if benchmark.is_some() {
1028        html.push_str(&format!("<td>{:.2}%</td>", five_y_bench * 100.0));
1029    }
1030    html.push_str(&format!("<td>{:.2}%</td></tr>", five_y_strat * 100.0));
1031
1032    html.push_str("<tr><td>10Y (ann.)</td>");
1033    if benchmark.is_some() {
1034        html.push_str(&format!("<td>{:.2}%</td>", ten_y_bench * 100.0));
1035    }
1036    html.push_str(&format!("<td>{:.2}%</td></tr>", ten_y_strat * 100.0));
1037
1038    html.push_str("<tr><td>All-time (ann.)</td>");
1039    if benchmark.is_some() {
1040        html.push_str(&format!("<td>{:.2}%</td>", alltime_bench * 100.0));
1041    }
1042    html.push_str(&format!("<td>{:.2}%</td></tr>", alltime_strat * 100.0));
1043
1044    html.push_str(&format!(r#"<tr><td colspan="{}"><hr></td></tr>"#, colspan));
1045
1046    // Best / worst days (already have)
1047    html.push_str("<tr><td>Best Day</td>");
1048    if let Some(b) = benchmark {
1049        html.push_str(&format!("<td>{:.2}%</td>", b.best_day * 100.0));
1050    } else if benchmark.is_some() {
1051        html.push_str("<td>-</td>");
1052    }
1053    html.push_str(&format!("<td>{:.2}%</td></tr>", strategy.best_day * 100.0));
1054
1055    html.push_str("<tr><td>Worst Day</td>");
1056    if let Some(b) = benchmark {
1057        html.push_str(&format!("<td>{:.2}%</td>", b.worst_day * 100.0));
1058    } else if benchmark.is_some() {
1059        html.push_str("<td>-</td>");
1060    }
1061    html.push_str(&format!("<td>{:.2}%</td></tr>", strategy.worst_day * 100.0));
1062
1063    // Best / worst month and year using aggregated returns
1064    fn monthly_returns(series: &ReturnSeries) -> Vec<f64> {
1065        let mut grouped: BTreeMap<(i32, u32), Vec<f64>> = BTreeMap::new();
1066        for (d, r) in series.dates.iter().zip(series.values.iter()) {
1067            if r.is_nan() {
1068                continue;
1069            }
1070            grouped.entry((d.year(), d.month())).or_default().push(*r);
1071        }
1072        let mut out = Vec::new();
1073        for (_k, vals) in grouped {
1074            let total = vals.iter().fold(1.0_f64, |acc, v| acc * (1.0 + *v)) - 1.0;
1075            out.push(total);
1076        }
1077        out
1078    }
1079
1080    let strat_monthly = monthly_returns(strategy_returns);
1081    let bench_monthly = benchmark_returns.map(|b| monthly_returns(b));
1082
1083    let best_month_strat = strat_monthly
1084        .iter()
1085        .cloned()
1086        .fold(f64::NEG_INFINITY, f64::max);
1087    let worst_month_strat = strat_monthly.iter().cloned().fold(f64::INFINITY, f64::min);
1088
1089    let (best_month_bench, worst_month_bench) = if let Some(m) = bench_monthly.as_ref() {
1090        let best = m.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1091        let worst = m.iter().cloned().fold(f64::INFINITY, f64::min);
1092        (best, worst)
1093    } else {
1094        (0.0, 0.0)
1095    };
1096
1097    let yearly_strat_map = yearly_compounded(strategy_returns);
1098    let yearly_bench_map = benchmark_returns.map(yearly_compounded);
1099
1100    let best_year_strat = yearly_strat_map
1101        .values()
1102        .cloned()
1103        .fold(f64::NEG_INFINITY, f64::max);
1104    let worst_year_strat = yearly_strat_map
1105        .values()
1106        .cloned()
1107        .fold(f64::INFINITY, f64::min);
1108
1109    let (best_year_bench, worst_year_bench) = if let Some(ref yb) = yearly_bench_map {
1110        let best = yb.values().cloned().fold(f64::NEG_INFINITY, f64::max);
1111        let worst = yb.values().cloned().fold(f64::INFINITY, f64::min);
1112        (best, worst)
1113    } else {
1114        (0.0, 0.0)
1115    };
1116
1117    html.push_str("<tr><td>Best Month</td>");
1118    if benchmark.is_some() {
1119        html.push_str(&format!("<td>{:.2}%</td>", best_month_bench * 100.0));
1120    }
1121    html.push_str(&format!("<td>{:.2}%</td></tr>", best_month_strat * 100.0));
1122
1123    html.push_str("<tr><td>Worst Month</td>");
1124    if benchmark.is_some() {
1125        html.push_str(&format!("<td>{:.2}%</td>", worst_month_bench * 100.0));
1126    }
1127    html.push_str(&format!("<td>{:.2}%</td></tr>", worst_month_strat * 100.0));
1128
1129    html.push_str("<tr><td>Best Year</td>");
1130    if benchmark.is_some() {
1131        html.push_str(&format!("<td>{:.2}%</td>", best_year_bench * 100.0));
1132    }
1133    html.push_str(&format!("<td>{:.2}%</td></tr>", best_year_strat * 100.0));
1134
1135    html.push_str("<tr><td>Worst Year</td>");
1136    if benchmark.is_some() {
1137        html.push_str(&format!("<td>{:.2}%</td>", worst_year_bench * 100.0));
1138    }
1139    html.push_str(&format!("<td>{:.2}%</td></tr>", worst_year_strat * 100.0));
1140
1141    // Drawdown-based metrics for strategy and benchmark
1142    let all_dd_strat = crate::stats::all_drawdowns(strategy_returns);
1143    let all_dd_bench = benchmark_returns.map(crate::stats::all_drawdowns);
1144
1145    let avg_dd = if all_dd_strat.is_empty() {
1146        0.0
1147    } else {
1148        all_dd_strat.iter().map(|d| d.depth).sum::<f64>() / all_dd_strat.len() as f64
1149    };
1150    let avg_dd_days = if all_dd_strat.is_empty() {
1151        0.0
1152    } else {
1153        all_dd_strat.iter().map(|d| d.duration as f64).sum::<f64>() / all_dd_strat.len() as f64
1154    };
1155
1156    let (avg_dd_bench, avg_dd_days_bench) = if let Some(ref dd_b) = all_dd_bench {
1157        if dd_b.is_empty() {
1158            (0.0, 0.0)
1159        } else {
1160            let depth = dd_b.iter().map(|d| d.depth).sum::<f64>() / dd_b.len() as f64;
1161            let days = dd_b.iter().map(|d| d.duration as f64).sum::<f64>() / dd_b.len() as f64;
1162            (depth, days)
1163        }
1164    } else {
1165        (0.0, 0.0)
1166    };
1167
1168    // Recovery factor matching QuantStats: abs(sum(returns) - rf) / abs(max_dd)
1169    let recovery_strat = if strategy.max_drawdown != 0.0 {
1170        let total = strat_vals.iter().sum::<f64>() - rf;
1171        total.abs() / strategy.max_drawdown.abs()
1172    } else {
1173        0.0
1174    };
1175    let recovery_bench = if let (Some(b), Some(vals)) = (benchmark, bench_vals.as_ref()) {
1176        if b.max_drawdown != 0.0 {
1177            let total = vals.iter().sum::<f64>() - rf;
1178            total.abs() / b.max_drawdown.abs()
1179        } else {
1180            0.0
1181        }
1182    } else {
1183        0.0
1184    };
1185
1186    let ulcer_strat = ulcer_index(strategy_returns);
1187    let ulcer_bench = benchmark_returns.map(ulcer_index);
1188    let serenity_strat = serenity_index(strategy_returns, rf);
1189    let serenity_bench = benchmark_returns.map(|b| serenity_index(b, rf));
1190
1191    html.push_str(&format!(r#"<tr><td colspan="{}"><hr></td></tr>"#, colspan));
1192
1193    html.push_str("<tr><td>Avg. Drawdown</td>");
1194    if benchmark.is_some() {
1195        html.push_str(&format!("<td>{:.2}%</td>", avg_dd_bench * 100.0));
1196    }
1197    html.push_str(&format!("<td>{:.2}%</td></tr>", avg_dd * 100.0));
1198
1199    html.push_str("<tr><td>Avg. Drawdown Days</td>");
1200    if benchmark.is_some() {
1201        html.push_str(&format!("<td>{:.0}</td>", avg_dd_days_bench));
1202    }
1203    html.push_str(&format!("<td>{:.0}</td></tr>", avg_dd_days));
1204
1205    html.push_str("<tr><td>Recovery Factor</td>");
1206    if benchmark.is_some() {
1207        html.push_str(&format!("<td>{:.2}</td>", recovery_bench));
1208    }
1209    html.push_str(&format!("<td>{:.2}</td></tr>", recovery_strat));
1210
1211    html.push_str("<tr><td>Ulcer Index</td>");
1212    if benchmark.is_some() {
1213        html.push_str(&format!("<td>{:.2}</td>", ulcer_bench.unwrap_or(0.0)));
1214    }
1215    html.push_str(&format!("<td>{:.2}</td></tr>", ulcer_strat));
1216
1217    html.push_str("<tr><td>Serenity Index</td>");
1218    if benchmark.is_some() {
1219        html.push_str(&format!("<td>{:.2}</td>", serenity_bench.unwrap_or(0.0)));
1220    }
1221    html.push_str(&format!("<td>{:.2}</td></tr>", serenity_strat));
1222
1223    // Separator before average up/down month and win stats
1224    html.push_str(&format!(r#"<tr><td colspan="{}"><hr></td></tr>"#, colspan));
1225
1226    // Avg. Up / Down Month (based on monthly compounded returns).
1227    // When a benchmark is present, we follow QuantStats' DataFrame
1228    // semantics: only months where both strategy and benchmark are
1229    // positive/negative are used (intersection across columns),
1230    // mirroring avg_win/avg_loss on a multi-column DataFrame.
1231    let (avg_up_month_strat, avg_up_month_bench, avg_down_month_strat, avg_down_month_bench) =
1232        if let Some(ref months_bench) = bench_monthly {
1233            let len = strat_monthly.len().min(months_bench.len());
1234            let mut up_s = Vec::new();
1235            let mut up_b = Vec::new();
1236            let mut down_s = Vec::new();
1237            let mut down_b = Vec::new();
1238
1239            for i in 0..len {
1240                let s = strat_monthly[i];
1241                let b = months_bench[i];
1242                if !s.is_finite() || !b.is_finite() {
1243                    continue;
1244                }
1245                if s > 0.0 && b > 0.0 {
1246                    up_s.push(s);
1247                    up_b.push(b);
1248                }
1249                if s < 0.0 && b < 0.0 {
1250                    down_s.push(s);
1251                    down_b.push(b);
1252                }
1253            }
1254
1255            let up_s_avg = if up_s.is_empty() {
1256                None
1257            } else {
1258                Some(mean(&up_s))
1259            };
1260            let up_b_avg = if up_b.is_empty() {
1261                None
1262            } else {
1263                Some(mean(&up_b))
1264            };
1265            let down_s_avg = if down_s.is_empty() {
1266                None
1267            } else {
1268                Some(mean(&down_s))
1269            };
1270            let down_b_avg = if down_b.is_empty() {
1271                None
1272            } else {
1273                Some(mean(&down_b))
1274            };
1275
1276            (up_s_avg, up_b_avg, down_s_avg, down_b_avg)
1277        } else {
1278            let avg_up_month_strat = {
1279                let ups: Vec<f64> = strat_monthly.iter().copied().filter(|v| *v > 0.0).collect();
1280                if ups.is_empty() {
1281                    None
1282                } else {
1283                    Some(mean(&ups))
1284                }
1285            };
1286            let avg_down_month_strat = {
1287                let downs: Vec<f64> = strat_monthly.iter().copied().filter(|v| *v < 0.0).collect();
1288                if downs.is_empty() {
1289                    None
1290                } else {
1291                    Some(mean(&downs))
1292                }
1293            };
1294            (avg_up_month_strat, None, avg_down_month_strat, None)
1295        };
1296
1297    html.push_str("<tr><td>Avg. Up Month</td>");
1298    if benchmark.is_some() {
1299        if let Some(v) = avg_up_month_bench {
1300            html.push_str(&format!("<td>{:.2}%</td>", v * 100.0));
1301        } else {
1302            html.push_str("<td>-</td>");
1303        }
1304    }
1305    if let Some(v) = avg_up_month_strat {
1306        html.push_str(&format!("<td>{:.2}%</td></tr>", v * 100.0));
1307    } else {
1308        html.push_str("<td>-</td></tr>");
1309    }
1310
1311    html.push_str("<tr><td>Avg. Down Month</td>");
1312    if benchmark.is_some() {
1313        if let Some(v) = avg_down_month_bench {
1314            html.push_str(&format!("<td>{:.2}%</td>", v * 100.0));
1315        } else {
1316            html.push_str("<td>-</td>");
1317        }
1318    }
1319    if let Some(v) = avg_down_month_strat {
1320        html.push_str(&format!("<td>{:.2}%</td></tr>", v * 100.0));
1321    } else {
1322        html.push_str("<td>-</td></tr>");
1323    }
1324
1325    // Win statistics (days)
1326    let non_zero_days = strat_vals.iter().filter(|v| **v != 0.0).count().max(1) as f64;
1327    let win_days_strat = strat_vals.iter().filter(|v| **v > 0.0).count() as f64 / non_zero_days;
1328    let win_days_bench = bench_vals.as_ref().map(|vals| {
1329        let non_zero = vals.iter().filter(|v| **v != 0.0).count().max(1) as f64;
1330        vals.iter().filter(|v| **v > 0.0).count() as f64 / non_zero
1331    });
1332
1333    html.push_str("<tr><td>Win Days</td>");
1334    if let Some(v) = win_days_bench {
1335        html.push_str(&format!("<td>{:.2}%</td>", v * 100.0));
1336    } else if benchmark.is_some() {
1337        html.push_str("<td>-</td>");
1338    }
1339    html.push_str(&format!("<td>{:.2}%</td></tr>", win_days_strat * 100.0));
1340
1341    // Win statistics for months / quarters / years
1342    fn win_ratio_from_grouped(groups: &[f64]) -> f64 {
1343        if groups.is_empty() {
1344            return 0.0;
1345        }
1346        let wins = groups.iter().filter(|v| **v > 0.0).count() as f64;
1347        wins / groups.len() as f64
1348    }
1349
1350    fn quarterly_returns(series: &ReturnSeries) -> Vec<f64> {
1351        let mut grouped: BTreeMap<(i32, u32), Vec<f64>> = BTreeMap::new();
1352        for (d, r) in series.dates.iter().zip(series.values.iter()) {
1353            if r.is_nan() {
1354                continue;
1355            }
1356            let quarter = (d.month() - 1) / 3 + 1;
1357            grouped.entry((d.year(), quarter)).or_default().push(*r);
1358        }
1359        let mut out = Vec::new();
1360        for (_k, vals) in grouped {
1361            let total = vals.iter().fold(1.0_f64, |acc, v| acc * (1.0 + *v)) - 1.0;
1362            out.push(total);
1363        }
1364        out
1365    }
1366
1367    let win_month_strat = win_ratio_from_grouped(&strat_monthly);
1368    let win_month_bench = bench_monthly.as_ref().map(|v| win_ratio_from_grouped(v));
1369
1370    let strat_quarterly = quarterly_returns(strategy_returns);
1371    let bench_quarterly = benchmark_returns.map(quarterly_returns);
1372
1373    let win_quarter_strat = win_ratio_from_grouped(&strat_quarterly);
1374    let win_quarter_bench = bench_quarterly.as_ref().map(|v| win_ratio_from_grouped(v));
1375
1376    let win_year_strat =
1377        win_ratio_from_grouped(&yearly_strat_map.values().cloned().collect::<Vec<_>>());
1378    let win_year_bench = yearly_bench_map
1379        .as_ref()
1380        .map(|m| win_ratio_from_grouped(&m.values().cloned().collect::<Vec<_>>()));
1381
1382    html.push_str("<tr><td>Win Month</td>");
1383    if let Some(v) = win_month_bench {
1384        html.push_str(&format!("<td>{:.2}%</td>", v * 100.0));
1385    } else if benchmark.is_some() {
1386        html.push_str("<td>-</td>");
1387    }
1388    html.push_str(&format!("<td>{:.2}%</td></tr>", win_month_strat * 100.0));
1389
1390    html.push_str("<tr><td>Win Quarter</td>");
1391    if let Some(v) = win_quarter_bench {
1392        html.push_str(&format!("<td>{:.2}%</td>", v * 100.0));
1393    } else if benchmark.is_some() {
1394        html.push_str("<td>-</td>");
1395    }
1396    html.push_str(&format!("<td>{:.2}%</td></tr>", win_quarter_strat * 100.0));
1397
1398    html.push_str("<tr><td>Win Year</td>");
1399    if let Some(v) = win_year_bench {
1400        html.push_str(&format!("<td>{:.2}%</td>", v * 100.0));
1401    } else if benchmark.is_some() {
1402        html.push_str("<td>-</td>");
1403    }
1404    html.push_str(&format!("<td>{:.2}%</td></tr>", win_year_strat * 100.0));
1405
1406    html.push_str(&format!(r#"<tr><td colspan="{}"><hr></td></tr>"#, colspan));
1407
1408    // Beta / Alpha / Correlation / Treynor
1409    html.push_str("<tr><td>Beta</td>");
1410    if benchmark.is_some() {
1411        html.push_str("<td>-</td>");
1412    }
1413    html.push_str(&format!("<td>{:.2}</td></tr>", beta));
1414
1415    html.push_str("<tr><td>Alpha</td>");
1416    if benchmark.is_some() {
1417        html.push_str("<td>-</td>");
1418    }
1419    html.push_str(&format!("<td>{:.2}</td></tr>", alpha_ann));
1420
1421    html.push_str("<tr><td>Correlation</td>");
1422    if benchmark.is_some() {
1423        html.push_str("<td>-</td>");
1424    }
1425    html.push_str(&format!("<td>{:.2}%</td></tr>", corr * 100.0));
1426
1427    html.push_str("<tr><td>Treynor Ratio</td>");
1428    if benchmark.is_some() {
1429        html.push_str("<td>-</td>");
1430    }
1431    html.push_str(&format!("<td>{:.2}%</td></tr>", treynor * 100.0));
1432
1433    html.push_str("</tbody></table>");
1434    html
1435}
1436
1437fn regression_metrics(
1438    strat_vals: &[f64],
1439    bench_vals: &[f64],
1440    total_return: f64,
1441    rf: f64,
1442    periods_per_year: u32,
1443) -> Option<(f64, f64, f64, f64, f64, f64)> {
1444    let n = strat_vals.len().min(bench_vals.len());
1445    if n < 2 {
1446        return None;
1447    }
1448    let pairs: Vec<(f64, f64)> = strat_vals
1449        .iter()
1450        .copied()
1451        .zip(bench_vals.iter().copied())
1452        .filter(|(s, b)| s.is_finite() && b.is_finite())
1453        .collect();
1454    if pairs.len() < 2 {
1455        return None;
1456    }
1457    let n_f = pairs.len() as f64;
1458    let mean_s = pairs.iter().map(|(s, _)| s).sum::<f64>() / n_f;
1459    let mean_b = pairs.iter().map(|(_, b)| b).sum::<f64>() / n_f;
1460    let mut cov = 0.0_f64;
1461    let mut var_b = 0.0_f64;
1462    let mut var_s = 0.0_f64;
1463    for (s, b) in &pairs {
1464        let ds = *s - mean_s;
1465        let db = *b - mean_b;
1466        cov += ds * db;
1467        var_s += ds * ds;
1468        var_b += db * db;
1469    }
1470    cov /= n_f - 1.0;
1471    var_s /= n_f - 1.0;
1472    var_b /= n_f - 1.0;
1473
1474    let std_s = var_s.sqrt();
1475    let std_b = var_b.sqrt();
1476    let corr = if std_s > 0.0 && std_b > 0.0 {
1477        cov / (std_s * std_b)
1478    } else {
1479        0.0
1480    };
1481    let r2 = corr * corr;
1482    let beta = if var_b > 0.0 { cov / var_b } else { 0.0 };
1483    let alpha_daily = mean_s - beta * mean_b;
1484    let alpha_ann = alpha_daily * periods_per_year as f64;
1485
1486    // Information ratio (no additional annualization factor, matching
1487    // QuantStats' `information_ratio` implementation)
1488    let mut diffs = Vec::with_capacity(pairs.len());
1489    for (s, b) in &pairs {
1490        diffs.push(s - b);
1491    }
1492    let mean_diff = mean(&diffs);
1493    let std_diff = std_dev(&diffs);
1494    let info_ratio = if std_diff > 0.0 {
1495        mean_diff / std_diff
1496    } else {
1497        0.0
1498    };
1499
1500    // Treynor ratio
1501    let treynor = if beta != 0.0 {
1502        (total_return - rf) / beta
1503    } else {
1504        0.0
1505    };
1506
1507    Some((r2, info_ratio, beta, alpha_ann, corr, treynor))
1508}
1509
1510fn build_drawdown_info(drawdowns: &[Drawdown]) -> String {
1511    let mut html = String::new();
1512    html.push_str("<table><thead><tr>");
1513    html.push_str("<th>Started</th>");
1514    html.push_str("<th>Recovered</th>");
1515    html.push_str("<th>Drawdown</th>");
1516    html.push_str("<th>Days</th>");
1517    html.push_str("</tr></thead><tbody>");
1518    for dd in drawdowns {
1519        html.push_str(&format!(
1520            "<tr><td>{}</td><td>{}</td><td>{:.2}</td><td>{}</td></tr>",
1521            dd.start.format("%Y-%m-%d"),
1522            dd.end.format("%Y-%m-%d"),
1523            dd.depth * 100.0,
1524            dd.duration
1525        ));
1526    }
1527    html.push_str("</tbody></table>");
1528    html
1529}
1530
1531fn build_eoy_table(strategy: &ReturnSeries, benchmark: Option<&ReturnSeries>) -> String {
1532    let strat_years = yearly_compounded(strategy);
1533    let bench_years = benchmark.map(yearly_compounded);
1534
1535    if strat_years.is_empty() {
1536        return "<p>No EOY data available.</p>".to_string();
1537    }
1538
1539    let mut years: Vec<i32> = strat_years.keys().copied().collect();
1540    if let Some(ref b) = bench_years {
1541        for y in b.keys() {
1542            if !years.contains(y) {
1543                years.push(*y);
1544            }
1545        }
1546    }
1547    years.sort();
1548
1549    let mut html = String::new();
1550    html.push_str("<table>\n<thead>\n<tr><th>Year</th>");
1551    if bench_years.is_some() {
1552        html.push_str("<th>Benchmark</th><th>Strategy</th><th>Multiplier</th><th>Won</th>");
1553    } else {
1554        html.push_str("<th>Strategy</th>");
1555    }
1556    html.push_str("</tr>\n</thead>\n<tbody>\n");
1557
1558    for year in years {
1559        let strat = strat_years.get(&year).copied().unwrap_or(0.0) * 100.0;
1560        if let Some(ref bench_map) = bench_years {
1561            let bench = bench_map.get(&year).copied().unwrap_or(0.0) * 100.0;
1562            let multiplier = if bench.abs() > f64::EPSILON {
1563                strat / bench
1564            } else {
1565                0.0
1566            };
1567            let won = if strat > bench { "+" } else { "-" };
1568            html.push_str(&format!(
1569                "<tr><td>{}</td><td>{:.2}</td><td>{:.2}</td><td>{:.2}</td><td>{}</td></tr>\n",
1570                year, bench, strat, multiplier, won
1571            ));
1572        } else {
1573            html.push_str(&format!(
1574                "<tr><td>{}</td><td>{:.2}</td></tr>\n",
1575                year, strat
1576            ));
1577        }
1578    }
1579
1580    html.push_str("</tbody>\n</table>");
1581    html
1582}
1583
1584// === helpers for metrics & aggregation ===
1585
1586use std::collections::BTreeMap;
1587
1588fn yearly_compounded(series: &ReturnSeries) -> BTreeMap<i32, f64> {
1589    let mut grouped: BTreeMap<i32, Vec<f64>> = BTreeMap::new();
1590    for (date, ret) in series.dates.iter().zip(series.values.iter()) {
1591        if ret.is_nan() {
1592            continue;
1593        }
1594        grouped.entry(date.year()).or_default().push(*ret);
1595    }
1596
1597    let mut out = BTreeMap::new();
1598    for (year, vals) in grouped {
1599        if vals.is_empty() {
1600            continue;
1601        }
1602        let total = vals.iter().fold(1.0_f64, |acc, r| acc * (1.0 + *r)) - 1.0;
1603        out.insert(year, total);
1604    }
1605    out
1606}
1607
1608fn clean_values(series: &ReturnSeries) -> Vec<f64> {
1609    series
1610        .values
1611        .iter()
1612        .copied()
1613        .filter(|v| v.is_finite())
1614        .collect()
1615}
1616
1617fn mean(values: &[f64]) -> f64 {
1618    if values.is_empty() {
1619        0.0
1620    } else {
1621        values.iter().sum::<f64>() / values.len() as f64
1622    }
1623}
1624
1625fn std_dev(values: &[f64]) -> f64 {
1626    let n = values.len();
1627    if n < 2 {
1628        return 0.0;
1629    }
1630    let m = mean(values);
1631    let var = values
1632        .iter()
1633        .map(|x| {
1634            let d = x - m;
1635            d * d
1636        })
1637        .sum::<f64>()
1638        / (n as f64 - 1.0);
1639    var.sqrt()
1640}
1641
1642fn skew_kurtosis(values: &[f64]) -> (f64, f64) {
1643    let n = values.len();
1644    if n < 2 {
1645        return (0.0, 0.0);
1646    }
1647    let n_f = n as f64;
1648    let m = mean(values);
1649
1650    // Population second moment (ddof = 0), to match pandas'
1651    // Series.kurtosis/skew with bias=True.
1652    let mut m2 = 0.0_f64;
1653    let mut m3 = 0.0_f64;
1654    let mut m4 = 0.0_f64;
1655    for x in values {
1656        let d = *x - m;
1657        let d2 = d * d;
1658        m2 += d2;
1659        m3 += d2 * d;
1660        m4 += d2 * d2;
1661    }
1662    m2 /= n_f;
1663    m3 /= n_f;
1664    m4 /= n_f;
1665
1666    if m2 == 0.0 {
1667        return (0.0, 0.0);
1668    }
1669
1670    let std_pop = m2.sqrt();
1671
1672    // Skewness and excess kurtosis (normal -> 0), with population
1673    // moments, matching pandas' default (bias=True).
1674    let skew = m3 / std_pop.powi(3);
1675    let kurt = m4 / (m2 * m2) - 3.0;
1676    (skew, kurt)
1677}
1678
1679fn downside_std(values: &[f64], threshold: f64) -> f64 {
1680    let n = values.len();
1681    if n == 0 {
1682        return 0.0;
1683    }
1684    let mut sum_sq = 0.0_f64;
1685    for v in values {
1686        if *v < threshold {
1687            let d = *v - threshold;
1688            sum_sq += d * d;
1689        }
1690    }
1691    (sum_sq / n as f64).sqrt()
1692}
1693
1694fn omega_ratio(values: &[f64], threshold: f64) -> f64 {
1695    if values.is_empty() {
1696        return 0.0;
1697    }
1698    let mut gains = 0.0_f64;
1699    let mut losses = 0.0_f64;
1700    for v in values {
1701        let diff = *v - threshold;
1702        if diff > 0.0 {
1703            gains += diff;
1704        } else if diff < 0.0 {
1705            losses += -diff;
1706        }
1707    }
1708    if losses == 0.0 {
1709        return 0.0;
1710    }
1711    gains / losses
1712}
1713
1714fn empirical_var(values: &[f64], confidence: f64) -> f64 {
1715    if values.is_empty() {
1716        return 0.0;
1717    }
1718    let mut v: Vec<f64> = values.iter().copied().collect();
1719    v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1720    let p = (1.0 - confidence).clamp(0.0, 1.0);
1721    let idx = (p * (v.len() as f64 - 1.0)).round() as usize;
1722    v[idx]
1723}
1724
1725fn empirical_cvar(values: &[f64], confidence: f64) -> f64 {
1726    if values.is_empty() {
1727        return 0.0;
1728    }
1729    let var = empirical_var(values, confidence);
1730    let tail: Vec<f64> = values.iter().copied().filter(|v| *v <= var).collect();
1731    if tail.is_empty() { var } else { mean(&tail) }
1732}
1733
1734fn max_consecutive_streak(values: &[f64], positive: bool) -> u32 {
1735    let mut best = 0_u32;
1736    let mut current = 0_u32;
1737    for v in values {
1738        let cond = if positive { *v > 0.0 } else { *v < 0.0 };
1739        if cond {
1740            current += 1;
1741            if current > best {
1742                best = current;
1743            }
1744        } else {
1745            // Any non-winning (including zeros) breaks the streak,
1746            // matching QuantStats' consecutive_wins / consecutive_losses.
1747            current = 0;
1748        }
1749    }
1750    best
1751}
1752
1753fn gain_to_pain(values: &[f64]) -> f64 {
1754    if values.is_empty() {
1755        return 0.0;
1756    }
1757    let mut total = 0.0_f64;
1758    let mut downside = 0.0_f64;
1759    for r in values {
1760        if !r.is_finite() {
1761            continue;
1762        }
1763        total += *r;
1764        if *r < 0.0 {
1765            downside += -r;
1766        }
1767    }
1768    if downside == 0.0 {
1769        0.0
1770    } else {
1771        total / downside
1772    }
1773}
1774
1775fn payoff_ratio(values: &[f64]) -> f64 {
1776    let wins: Vec<f64> = values.iter().copied().filter(|v| *v > 0.0).collect();
1777    let losses: Vec<f64> = values.iter().copied().filter(|v| *v < 0.0).collect();
1778    if wins.is_empty() || losses.is_empty() {
1779        return 0.0;
1780    }
1781    let avg_win = mean(&wins);
1782    let avg_loss = mean(&losses);
1783    if avg_loss == 0.0 {
1784        0.0
1785    } else {
1786        avg_win / -avg_loss
1787    }
1788}
1789
1790fn profit_factor(values: &[f64]) -> f64 {
1791    let mut wins_sum = 0.0_f64;
1792    let mut losses_sum = 0.0_f64;
1793    for v in values {
1794        if *v >= 0.0 {
1795            wins_sum += *v;
1796        } else {
1797            losses_sum += -*v;
1798        }
1799    }
1800    if losses_sum == 0.0 {
1801        if wins_sum == 0.0 { 0.0 } else { f64::INFINITY }
1802    } else {
1803        wins_sum / losses_sum
1804    }
1805}
1806
1807fn quantile(values: &[f64], q: f64) -> f64 {
1808    if values.is_empty() {
1809        return 0.0;
1810    }
1811    let mut v: Vec<f64> = values.iter().copied().filter(|x| x.is_finite()).collect();
1812    if v.is_empty() {
1813        return 0.0;
1814    }
1815    v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1816
1817    let n = v.len() as f64;
1818    let pos = q.clamp(0.0, 1.0) * (n - 1.0);
1819    let lo = pos.floor() as usize;
1820    let hi = pos.ceil() as usize;
1821    if lo == hi {
1822        v[lo]
1823    } else {
1824        let w = pos - lo as f64;
1825        v[lo] + (v[hi] - v[lo]) * w
1826    }
1827}
1828
1829fn tail_ratio(values: &[f64]) -> f64 {
1830    if values.is_empty() {
1831        return 0.0;
1832    }
1833    let upper = quantile(values, 0.95);
1834    let lower = quantile(values, 0.05);
1835    if lower == 0.0 {
1836        0.0
1837    } else {
1838        (upper / lower).abs()
1839    }
1840}
1841
1842fn outlier_win_ratio(values: &[f64]) -> f64 {
1843    if values.is_empty() {
1844        return 0.0;
1845    }
1846    let wins: Vec<f64> = values.iter().copied().filter(|v| *v >= 0.0).collect();
1847    if wins.is_empty() {
1848        return 0.0;
1849    }
1850    let avg_pos = mean(&wins);
1851    if avg_pos == 0.0 {
1852        return 0.0;
1853    }
1854    let q = quantile(values, 0.99);
1855    q / avg_pos
1856}
1857
1858fn outlier_loss_ratio(values: &[f64]) -> f64 {
1859    if values.is_empty() {
1860        return 0.0;
1861    }
1862    let losses: Vec<f64> = values.iter().copied().filter(|v| *v < 0.0).collect();
1863    if losses.is_empty() {
1864        return 0.0;
1865    }
1866    let avg_neg = mean(&losses);
1867    if avg_neg == 0.0 {
1868        return 0.0;
1869    }
1870    let q = quantile(values, 0.01);
1871    q / avg_neg
1872}
1873
1874fn win_rate_from_values(values: &[f64]) -> f64 {
1875    let non_zero: Vec<f64> = values
1876        .iter()
1877        .copied()
1878        .filter(|v| v.is_finite() && *v != 0.0)
1879        .collect();
1880    if non_zero.is_empty() {
1881        return 0.0;
1882    }
1883    let wins = non_zero.iter().filter(|v| **v > 0.0).count() as f64;
1884    wins / non_zero.len() as f64
1885}
1886
1887fn cpc_index_from_values(values: &[f64]) -> f64 {
1888    let pf = profit_factor(values);
1889    let wr = win_rate_from_values(values);
1890    let wl = payoff_ratio(values);
1891    pf * wr * wl
1892}
1893
1894fn common_sense_ratio_from_values(values: &[f64]) -> f64 {
1895    let pf = profit_factor(values);
1896    let tr = tail_ratio(values);
1897    pf * tr
1898}
1899
1900fn drawdown_series(returns: &ReturnSeries) -> Vec<f64> {
1901    let mut equity = Vec::with_capacity(returns.values.len());
1902    let mut eq = 1.0_f64;
1903    for r in &returns.values {
1904        if r.is_nan() {
1905            equity.push(eq);
1906        } else {
1907            eq *= 1.0 + *r;
1908            equity.push(eq);
1909        }
1910    }
1911
1912    let mut peak = equity.get(0).copied().unwrap_or(1.0);
1913    let mut drawdowns = Vec::with_capacity(equity.len());
1914    for e in equity {
1915        if e > peak {
1916            peak = e;
1917        }
1918        let dd = e / peak - 1.0;
1919        drawdowns.push(dd);
1920    }
1921    drawdowns
1922}
1923
1924fn ulcer_index(returns: &ReturnSeries) -> f64 {
1925    let dd = drawdown_series(returns);
1926    if dd.is_empty() {
1927        return 0.0;
1928    }
1929    let n = dd.len();
1930    if n < 2 {
1931        return 0.0;
1932    }
1933    let sum_sq = dd
1934        .iter()
1935        .map(|d| {
1936            let x = d.min(0.0).abs();
1937            x * x
1938        })
1939        .sum::<f64>();
1940    (sum_sq / (n as f64 - 1.0)).sqrt()
1941}
1942
1943fn serenity_index(returns: &ReturnSeries, rf: f64) -> f64 {
1944    let dd = drawdown_series(returns);
1945    let vals = clean_values(returns);
1946    if vals.is_empty() {
1947        return 0.0;
1948    }
1949    let std = std_dev(&vals);
1950    if std == 0.0 {
1951        return 0.0;
1952    }
1953    // Use CVaR-style pitfall like QuantStats: normal VaR threshold and
1954    // tail mean of drawdowns below that threshold.
1955    let cvar_dd = {
1956        let vals_dd: Vec<f64> = dd.iter().copied().filter(|v| v.is_finite()).collect();
1957        if vals_dd.len() < 2 {
1958            0.0
1959        } else {
1960            let n = vals_dd.len() as f64;
1961            let mean = vals_dd.iter().sum::<f64>() / n;
1962            let var = vals_dd
1963                .iter()
1964                .map(|r| {
1965                    let d = *r - mean;
1966                    d * d
1967                })
1968                .sum::<f64>()
1969                / (n - 1.0);
1970            let std_dd = var.sqrt();
1971
1972            let mut conf = 0.95_f64;
1973            if conf > 1.0 {
1974                conf /= 100.0;
1975            }
1976            // simple normal inverse CDF (same approximation as in stats.rs)
1977            fn norm_cdf_local(x: f64) -> f64 {
1978                0.5 * (1.0 + erf_local(x / std::f64::consts::SQRT_2))
1979            }
1980            fn norm_ppf_local(p: f64) -> f64 {
1981                if p <= 0.0 {
1982                    return f64::NEG_INFINITY;
1983                }
1984                if p >= 1.0 {
1985                    return f64::INFINITY;
1986                }
1987                let mut lo = -10.0_f64;
1988                let mut hi = 10.0_f64;
1989                for _ in 0..80 {
1990                    let mid = 0.5 * (lo + hi);
1991                    let c = norm_cdf_local(mid);
1992                    if c < p {
1993                        lo = mid;
1994                    } else {
1995                        hi = mid;
1996                    }
1997                }
1998                0.5 * (lo + hi)
1999            }
2000            fn erf_local(x: f64) -> f64 {
2001                let sign = if x < 0.0 { -1.0 } else { 1.0 };
2002                let x = x.abs();
2003                let t = 1.0 / (1.0 + 0.3275911 * x);
2004                let y = 1.0
2005                    - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736)
2006                        * t
2007                        + 0.254829592)
2008                        * t
2009                        * (-x * x).exp();
2010                sign * y
2011            }
2012
2013            let z = norm_ppf_local(1.0 - conf);
2014            let var_threshold = mean + 1.0 * std_dd * z;
2015
2016            let tail: Vec<f64> = vals_dd.into_iter().filter(|v| *v < var_threshold).collect();
2017            if tail.is_empty() {
2018                var_threshold
2019            } else {
2020                tail.iter().sum::<f64>() / tail.len() as f64
2021            }
2022        }
2023    };
2024    let pitfall = -cvar_dd / std;
2025    let ulcer = ulcer_index(returns);
2026    let denom = ulcer * pitfall;
2027    if denom == 0.0 {
2028        0.0
2029    } else {
2030        (vals.iter().sum::<f64>() - rf) / denom
2031    }
2032}