quantstats_rs/
stats.rs

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    // Annualized volatility (used for display), but Sharpe is computed
34    // directly from per-period returns to match QuantStats' definition.
35    let volatility = annualized_volatility(&returns.values, periods_per_year);
36    let sharpe_ratio = sharpe_from_values(&returns.values, rf, periods_per_year);
37
38    // Use detailed drawdown analysis for max drawdown stats
39    // and longest drawdown duration (may be a different period).
40    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
71/// Compound annual growth rate (CAGR) from a slice of return values,
72/// matching QuantStats' `cagr` implementation for rf = 0 and
73/// compounded returns.
74///
75/// This is used for multi-year annualized metrics (3Y/5Y/10Y/All-time)
76/// in the HTML report.
77pub fn cagr_from_values(returns: &[f64], periods_per_year: u32) -> f64 {
78    if returns.is_empty() {
79        return 0.0;
80    }
81
82    // Total compounded return over the slice
83    let total = compounded_return(returns);
84
85    // Time in years expressed in trading periods
86    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    // Convert annual risk-free rate to per-period
157    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    /// Depth as a negative fraction (e.g. -0.25 for -25%)
189    pub depth: f64,
190    /// Duration in days (number of data points in the drawdown)
191    pub duration: u32,
192}
193
194/// Compute the worst drawdown segments for a return series.
195///
196/// This is a simplified implementation that scans the equity curve,
197/// identifies drawdown periods (from a new peak until recovery),
198/// and returns the `top_n` deepest ones.
199pub fn top_drawdowns(returns: &ReturnSeries, top_n: usize) -> Vec<Drawdown> {
200    let mut segments = compute_drawdown_segments(returns);
201
202    // Sort by depth (most negative first) and take top_n
203    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
213/// Compute daily Value-at-Risk (normal approximation) at given confidence.
214///
215/// Matches QuantStats' `value_at_risk` when called with rf=0,
216/// `sigma` multiplier = 1 and `confidence = 0.95`.
217pub 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/// Conditional Value-at-Risk (Expected Shortfall) using the same logic as
249/// QuantStats' `conditional_value_at_risk`.
250///
251/// This is currently not used directly in the HTML report (which mirrors
252/// the reference QuantStats HTML where CVaR equals VaR), but is kept
253/// available for library consumers.
254#[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
276/// Kelly criterion based on average win/loss and win rate, matching
277/// QuantStats' `kelly_criterion` behavior for a single return series.
278pub 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    // win rate uses only non-zero returns in the denominator
304    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
318/// Risk of Ruin using the gambler's ruin formula as in QuantStats'
319/// `risk_of_ruin`.
320pub 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    // win rate over non-zero returns
332    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    // Simple binary search inversion of the CDF on a bounded interval.
352    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    // Abramowitz & Stegun approximation.
375    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
386/// Compute all drawdown segments (from each peak to full recovery or series end).
387pub fn all_drawdowns(returns: &ReturnSeries) -> Vec<Drawdown> {
388    compute_drawdown_segments(returns)
389}
390
391/// Market exposure / time in market, matching QuantStats' `exposure`
392/// (with prepare_returns = false).
393///
394/// Counts non-NaN, non-zero returns and divides by total periods,
395/// then rounds up to nearest percent: ceil(ex * 100) / 100.
396pub 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    // Build equity curve
422    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    // Drawdown series relative to running peak
434    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    // Identify start and end indices following QuantStats'
445    // drawdown_details() semantics.
446    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        // Start when we enter a drawdown region
458        if !no_dd && prev_no_dd {
459            starts.push(i);
460        }
461
462        // Candidate end when we exit a drawdown region (dd becomes >= 0)
463        if no_dd && !prev_no_dd {
464            // Python shifts the end marker back by one, so store i-1
465            let end_idx = i.saturating_sub(1);
466            ends.push(end_idx);
467        }
468    }
469
470    // Handle edge case: drawdown series begins in a drawdown
471    if !ends.is_empty() && !starts.is_empty() && starts[0] > ends[0] {
472        starts.insert(0, 0);
473    }
474
475    // Handle edge case: series ends in a drawdown
476    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        // Find trough (minimum drawdown) within [s_idx, e_idx]
488        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}