1use chrono::NaiveDate;
2
3use crate::utils::ReturnSeries;
4
5#[derive(Clone, Debug)]
6pub struct PerformanceMetrics {
7 pub total_return: f64,
8 pub annualized_return: f64,
9 pub annualized_volatility: f64,
10 pub sharpe_ratio: f64,
11 pub max_drawdown: f64,
12 pub max_drawdown_duration: u32,
13 pub max_drawdown_start: Option<NaiveDate>,
14 pub max_drawdown_trough: Option<NaiveDate>,
15 pub max_drawdown_end: Option<NaiveDate>,
16 pub best_day: f64,
17 pub worst_day: f64,
18}
19
20pub fn compute_performance_metrics(
21 returns: &ReturnSeries,
22 rf: f64,
23 periods_per_year: u32,
24) -> PerformanceMetrics {
25 let n = returns.len() as f64;
26 let total_return = compounded_return(&returns.values);
27 let annualized_return = if n > 0.0 {
28 (1.0 + total_return).powf(periods_per_year as f64 / n) - 1.0
29 } else {
30 0.0
31 };
32
33 let volatility = annualized_volatility(&returns.values, periods_per_year);
36 let sharpe_ratio = sharpe_from_values(&returns.values, rf, periods_per_year);
37
38 let dd_segments = top_drawdowns(returns, 1);
41 let (max_drawdown, max_start, max_trough, max_end) = if let Some(dd) = dd_segments.first() {
42 (dd.depth, Some(dd.start), Some(dd.trough), Some(dd.end))
43 } else {
44 (0.0, None, None, None)
45 };
46
47 let all_dd_segments = all_drawdowns(returns);
48 let max_duration = all_dd_segments
49 .iter()
50 .map(|d| d.duration)
51 .max()
52 .unwrap_or(0);
53
54 let (best_day, worst_day) = best_and_worst(&returns.values);
55
56 PerformanceMetrics {
57 total_return,
58 annualized_return,
59 annualized_volatility: volatility,
60 sharpe_ratio,
61 max_drawdown,
62 max_drawdown_duration: max_duration,
63 max_drawdown_start: max_start,
64 max_drawdown_trough: max_trough,
65 max_drawdown_end: max_end,
66 best_day,
67 worst_day,
68 }
69}
70
71pub fn cagr_from_values(returns: &[f64], periods_per_year: u32) -> f64 {
78 if returns.is_empty() {
79 return 0.0;
80 }
81
82 let total = compounded_return(returns);
84
85 let n = returns.len() as f64;
87 let years = n / periods_per_year as f64;
88 if years <= 0.0 {
89 return 0.0;
90 }
91
92 (1.0 + total).abs().powf(1.0 / years) - 1.0
93}
94
95fn compounded_return(returns: &[f64]) -> f64 {
96 returns
97 .iter()
98 .filter(|v| !v.is_nan())
99 .fold(1.0, |acc, r| acc * (1.0 + r))
100 - 1.0
101}
102
103fn annualized_volatility(returns: &[f64], periods_per_year: u32) -> f64 {
104 let clean: Vec<f64> = returns.iter().copied().filter(|v| v.is_finite()).collect();
105
106 if clean.len() < 2 {
107 return 0.0;
108 }
109
110 let n = clean.len() as f64;
111 let mean = clean.iter().sum::<f64>() / n;
112 let var = clean
113 .iter()
114 .map(|r| {
115 let diff = r - mean;
116 diff * diff
117 })
118 .sum::<f64>()
119 / (n - 1.0);
120
121 var.sqrt() * (periods_per_year as f64).sqrt()
122}
123
124fn best_and_worst(returns: &[f64]) -> (f64, f64) {
125 let mut best = f64::NEG_INFINITY;
126 let mut worst = f64::INFINITY;
127
128 for r in returns.iter().copied().filter(|v| !v.is_nan()) {
129 if r > best {
130 best = r;
131 }
132 if r < worst {
133 worst = r;
134 }
135 }
136
137 if best == f64::NEG_INFINITY {
138 best = 0.0;
139 }
140 if worst == f64::INFINITY {
141 worst = 0.0;
142 }
143
144 (best, worst)
145}
146
147fn sharpe_from_values(returns: &[f64], rf: f64, periods_per_year: u32) -> f64 {
148 let vals: Vec<f64> = returns.iter().copied().filter(|v| v.is_finite()).collect();
149
150 if vals.len() < 2 {
151 return 0.0;
152 }
153
154 let n = vals.len() as f64;
155
156 let rf_per_period = if rf != 0.0 {
158 (1.0 + rf).powf(1.0 / periods_per_year as f64) - 1.0
159 } else {
160 0.0
161 };
162
163 let excess: Vec<f64> = vals.into_iter().map(|r| r - rf_per_period).collect();
164
165 let mean = excess.iter().sum::<f64>() / n;
166 let var = excess
167 .iter()
168 .map(|r| {
169 let diff = *r - mean;
170 diff * diff
171 })
172 .sum::<f64>()
173 / (n - 1.0);
174 let std = var.sqrt();
175
176 if std == 0.0 {
177 0.0
178 } else {
179 mean / std * (periods_per_year as f64).sqrt()
180 }
181}
182
183#[derive(Clone, Debug)]
184pub struct Drawdown {
185 pub start: NaiveDate,
186 pub trough: NaiveDate,
187 pub end: NaiveDate,
188 pub depth: f64,
190 pub duration: u32,
192}
193
194pub fn top_drawdowns(returns: &ReturnSeries, top_n: usize) -> Vec<Drawdown> {
200 let mut segments = compute_drawdown_segments(returns);
201
202 segments.sort_by(|a, b| {
204 a.depth
205 .partial_cmp(&b.depth)
206 .unwrap_or(std::cmp::Ordering::Equal)
207 });
208 segments.truncate(top_n);
209
210 segments
211}
212
213pub fn var_normal(returns: &ReturnSeries, sigma: f64, confidence: f64) -> f64 {
218 let vals: Vec<f64> = returns
219 .values
220 .iter()
221 .copied()
222 .filter(|v| v.is_finite())
223 .collect();
224 if vals.len() < 2 {
225 return 0.0;
226 }
227
228 let n = vals.len() as f64;
229 let mean = vals.iter().sum::<f64>() / n;
230 let var = vals
231 .iter()
232 .map(|r| {
233 let d = *r - mean;
234 d * d
235 })
236 .sum::<f64>()
237 / (n - 1.0);
238 let std = var.sqrt();
239
240 let mut conf = confidence;
241 if conf > 1.0 {
242 conf /= 100.0;
243 }
244 let z = normal_ppf(1.0 - conf);
245 mean + sigma * std * z
246}
247
248#[allow(dead_code)]
255pub fn cvar(returns: &ReturnSeries, sigma: f64, confidence: f64) -> f64 {
256 let vals: Vec<f64> = returns
257 .values
258 .iter()
259 .copied()
260 .filter(|v| v.is_finite())
261 .collect();
262 if vals.len() < 2 {
263 return 0.0;
264 }
265
266 let var_threshold = var_normal(returns, sigma, confidence);
267 let tail: Vec<f64> = vals.into_iter().filter(|v| *v < var_threshold).collect();
268
269 if tail.is_empty() {
270 var_threshold
271 } else {
272 tail.iter().sum::<f64>() / tail.len() as f64
273 }
274}
275
276pub fn kelly(returns: &ReturnSeries) -> f64 {
279 let vals: Vec<f64> = returns
280 .values
281 .iter()
282 .copied()
283 .filter(|v| v.is_finite())
284 .collect();
285 if vals.is_empty() {
286 return 0.0;
287 }
288
289 let wins: Vec<f64> = vals.iter().copied().filter(|v| *v > 0.0).collect();
290 let losses: Vec<f64> = vals.iter().copied().filter(|v| *v < 0.0).collect();
291
292 if wins.is_empty() || losses.is_empty() {
293 return 0.0;
294 }
295
296 let avg_win = wins.iter().sum::<f64>() / wins.len() as f64;
297 let avg_loss = losses.iter().sum::<f64>() / losses.len() as f64;
298 if avg_loss == 0.0 {
299 return 0.0;
300 }
301 let win_loss_ratio = avg_win / -avg_loss;
302
303 let non_zero: Vec<f64> = vals.iter().copied().filter(|v| *v != 0.0).collect();
305 if non_zero.is_empty() {
306 return 0.0;
307 }
308 let win_prob = non_zero.iter().filter(|v| **v > 0.0).count() as f64 / non_zero.len() as f64;
309 let lose_prob = 1.0 - win_prob;
310
311 if win_loss_ratio == 0.0 {
312 0.0
313 } else {
314 ((win_loss_ratio * win_prob) - lose_prob) / win_loss_ratio
315 }
316}
317
318pub fn risk_of_ruin(returns: &ReturnSeries) -> f64 {
321 let vals: Vec<f64> = returns
322 .values
323 .iter()
324 .copied()
325 .filter(|v| v.is_finite())
326 .collect();
327 if vals.is_empty() {
328 return 0.0;
329 }
330
331 let non_zero: Vec<f64> = vals.iter().copied().filter(|v| *v != 0.0).collect();
333 if non_zero.is_empty() {
334 return 0.0;
335 }
336 let win_prob = non_zero.iter().filter(|v| **v > 0.0).count() as f64 / non_zero.len() as f64;
337
338 if win_prob <= 0.0 {
339 return 1.0;
340 }
341
342 let ratio = (1.0 - win_prob) / (1.0 + win_prob);
343 ratio.powf(vals.len() as f64)
344}
345
346fn normal_cdf(x: f64) -> f64 {
347 0.5 * (1.0 + erf(x / std::f64::consts::SQRT_2))
348}
349
350fn normal_ppf(p: f64) -> f64 {
351 if p <= 0.0 {
353 return f64::NEG_INFINITY;
354 }
355 if p >= 1.0 {
356 return f64::INFINITY;
357 }
358
359 let mut lo = -10.0_f64;
360 let mut hi = 10.0_f64;
361 for _ in 0..80 {
362 let mid = 0.5 * (lo + hi);
363 let c = normal_cdf(mid);
364 if c < p {
365 lo = mid;
366 } else {
367 hi = mid;
368 }
369 }
370 0.5 * (lo + hi)
371}
372
373fn erf(x: f64) -> f64 {
374 let sign = if x < 0.0 { -1.0 } else { 1.0 };
376 let x = x.abs();
377 let t = 1.0 / (1.0 + 0.3275911 * x);
378 let y = 1.0
379 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t
380 + 0.254829592)
381 * t
382 * (-x * x).exp();
383 sign * y
384}
385
386pub fn all_drawdowns(returns: &ReturnSeries) -> Vec<Drawdown> {
388 compute_drawdown_segments(returns)
389}
390
391pub fn exposure(series: &ReturnSeries) -> f64 {
397 if series.values.is_empty() {
398 return 0.0;
399 }
400
401 let active = series
402 .values
403 .iter()
404 .filter(|v| v.is_finite() && **v != 0.0)
405 .count() as f64;
406 let total = series.values.len() as f64;
407 if total == 0.0 {
408 return 0.0;
409 }
410
411 let ex = active / total;
412 (ex * 100.0).ceil() / 100.0
413}
414
415fn compute_drawdown_segments(returns: &ReturnSeries) -> Vec<Drawdown> {
416 let n = returns.values.len();
417 if n == 0 {
418 return Vec::new();
419 }
420
421 let mut equity = Vec::with_capacity(n);
423 let mut eq = 1.0_f64;
424 for r in &returns.values {
425 if r.is_nan() {
426 equity.push(eq);
427 } else {
428 eq *= 1.0 + *r;
429 equity.push(eq);
430 }
431 }
432
433 let mut peak = equity[0];
435 let mut drawdowns = Vec::with_capacity(n);
436 for &e in &equity {
437 if e > peak {
438 peak = e;
439 }
440 let dd = e / peak - 1.0;
441 drawdowns.push(dd);
442 }
443
444 let mut starts: Vec<usize> = Vec::new();
447 let mut ends: Vec<usize> = Vec::new();
448
449 for i in 0..n {
450 let no_dd = drawdowns[i] >= 0.0;
451 let prev_no_dd = if i == 0 {
452 true
453 } else {
454 drawdowns[i - 1] >= 0.0
455 };
456
457 if !no_dd && prev_no_dd {
459 starts.push(i);
460 }
461
462 if no_dd && !prev_no_dd {
464 let end_idx = i.saturating_sub(1);
466 ends.push(end_idx);
467 }
468 }
469
470 if !ends.is_empty() && !starts.is_empty() && starts[0] > ends[0] {
472 starts.insert(0, 0);
473 }
474
475 if ends.is_empty() || (!starts.is_empty() && starts[starts.len() - 1] > ends[ends.len() - 1]) {
477 ends.push(n - 1);
478 }
479
480 let mut segments: Vec<Drawdown> = Vec::new();
481
482 for (s_idx, e_idx) in starts.into_iter().zip(ends.into_iter()) {
483 if s_idx > e_idx || s_idx >= n || e_idx >= n {
484 continue;
485 }
486
487 let mut trough_idx = s_idx;
489 let mut min_dd = drawdowns[s_idx];
490 for i in s_idx..=e_idx {
491 let dd = drawdowns[i];
492 if dd < min_dd {
493 min_dd = dd;
494 trough_idx = i;
495 }
496 }
497
498 let start_date = returns.dates[s_idx];
499 let end_date = returns.dates[e_idx];
500 let duration_days = (end_date - start_date).num_days() + 1;
501
502 segments.push(Drawdown {
503 start: start_date,
504 trough: returns.dates[trough_idx],
505 end: end_date,
506 depth: min_dd,
507 duration: duration_days as u32,
508 });
509 }
510
511 segments
512}