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 let dd_periods_svg = plots::drawdown_periods(&prepared_returns);
206 tpl = tpl.replace("{{dd_periods}}", &dd_periods_svg);
207
208 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 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 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 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 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 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 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 0.5 * (1.0 + erf(ratio / std::f64::consts::SQRT_2))
364 }
365
366 fn erf(x: f64) -> f64 {
367 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 html.push_str(&format!(r#"<tr><td colspan="{}"><hr></td></tr>"#, colspan));
1225
1226 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 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 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 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 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 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
1584use 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 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 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 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 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 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}