Skip to main content

aprender/monte_carlo/risk/
drawdown.rs

1//! Maximum Drawdown Analysis
2//!
3//! Measures peak-to-trough decline during a specific period.
4//! Critical for understanding tail risk and recovery requirements.
5//!
6//! Reference: Magdon-Ismail & Atiya (2004), "Maximum Drawdown"
7
8use crate::monte_carlo::engine::{percentile, SimulationPath};
9
10/// Maximum drawdown calculator for a single path
11#[derive(Debug, Clone)]
12pub struct DrawdownAnalysis;
13
14impl DrawdownAnalysis {
15    /// Calculate maximum drawdown from a value series
16    ///
17    /// Maximum drawdown = max((peak - trough) / peak)
18    ///
19    /// # Arguments
20    /// * `values` - Time series of portfolio values
21    ///
22    /// # Returns
23    /// Maximum drawdown as a positive fraction (0.1 = 10% drawdown)
24    ///
25    /// # Example
26    /// ```
27    /// use aprender::monte_carlo::risk::DrawdownAnalysis;
28    ///
29    /// let values = vec![100.0, 110.0, 90.0, 95.0, 85.0, 100.0];
30    /// let max_dd = DrawdownAnalysis::max_drawdown(&values);
31    /// // Peak was 110, trough was 85: (110-85)/110 ≈ 0.227
32    /// assert!(max_dd > 0.22 && max_dd < 0.24);
33    /// ```
34    #[must_use]
35    pub fn max_drawdown(values: &[f64]) -> f64 {
36        if values.len() < 2 {
37            return 0.0;
38        }
39
40        let mut max_drawdown = 0.0;
41        let mut peak = values[0];
42
43        for &value in values.iter().skip(1) {
44            if value > peak {
45                peak = value;
46            } else if peak > 0.0 {
47                let drawdown = (peak - value) / peak;
48                if drawdown > max_drawdown {
49                    max_drawdown = drawdown;
50                }
51            }
52        }
53
54        max_drawdown
55    }
56
57    /// Calculate drawdown series from values
58    ///
59    /// Returns the drawdown at each time point relative to the running peak
60    #[must_use]
61    pub fn drawdown_series(values: &[f64]) -> Vec<f64> {
62        if values.is_empty() {
63            return Vec::new();
64        }
65
66        let mut drawdowns = Vec::with_capacity(values.len());
67        let mut peak = values[0];
68
69        for &value in values {
70            if value > peak {
71                peak = value;
72            }
73            let dd = if peak > 0.0 {
74                (peak - value) / peak
75            } else {
76                0.0
77            };
78            drawdowns.push(dd);
79        }
80
81        drawdowns
82    }
83
84    /// Calculate drawdown duration (time to recovery)
85    ///
86    /// Returns the maximum number of periods spent in drawdown
87    #[must_use]
88    pub fn max_drawdown_duration(values: &[f64]) -> usize {
89        if values.len() < 2 {
90            return 0;
91        }
92
93        let mut max_duration = 0;
94        let mut current_duration = 0;
95        let mut peak = values[0];
96
97        for &value in values.iter().skip(1) {
98            if value >= peak {
99                peak = value;
100                current_duration = 0;
101            } else {
102                current_duration += 1;
103                if current_duration > max_duration {
104                    max_duration = current_duration;
105                }
106            }
107        }
108
109        max_duration
110    }
111
112    /// Calculate Ulcer Index (quadratic mean of drawdowns)
113    ///
114    /// UI = sqrt(mean(drawdown^2))
115    ///
116    /// Penalizes deep drawdowns more than shallow ones
117    #[must_use]
118    pub fn ulcer_index(values: &[f64]) -> f64 {
119        let drawdowns = Self::drawdown_series(values);
120        if drawdowns.is_empty() {
121            return 0.0;
122        }
123
124        let sum_sq: f64 = drawdowns.iter().map(|d| d * d).sum();
125        (sum_sq / drawdowns.len() as f64).sqrt()
126    }
127
128    /// Calculate Pain Index (average drawdown)
129    #[must_use]
130    pub fn pain_index(values: &[f64]) -> f64 {
131        let drawdowns = Self::drawdown_series(values);
132        if drawdowns.is_empty() {
133            return 0.0;
134        }
135
136        drawdowns.iter().sum::<f64>() / drawdowns.len() as f64
137    }
138
139    /// Calculate drawdown statistics from simulation paths
140    #[must_use]
141    pub fn from_paths(paths: &[SimulationPath]) -> DrawdownStatistics {
142        if paths.is_empty() {
143            return DrawdownStatistics::default();
144        }
145
146        let max_drawdowns: Vec<f64> = paths
147            .iter()
148            .map(|p| Self::max_drawdown(&p.values))
149            .collect();
150
151        DrawdownStatistics::from_drawdowns(&max_drawdowns)
152    }
153
154    /// Calculate recovery factor
155    ///
156    /// Recovery Factor = Total Return / Max Drawdown
157    ///
158    /// Higher is better - shows how well returns compensate for risk
159    #[must_use]
160    pub fn recovery_factor(values: &[f64]) -> f64 {
161        if values.len() < 2 {
162            return 0.0;
163        }
164
165        let first = values[0];
166        let last = values[values.len() - 1];
167
168        if first <= 0.0 {
169            return 0.0;
170        }
171
172        let total_return = (last - first) / first;
173        let max_dd = Self::max_drawdown(values);
174
175        if max_dd > 0.0 {
176            total_return / max_dd
177        } else if total_return > 0.0 {
178            f64::INFINITY
179        } else {
180            0.0
181        }
182    }
183}
184
185/// Statistics on maximum drawdowns across simulation paths
186#[derive(Debug, Clone, Default)]
187pub struct DrawdownStatistics {
188    /// Mean of maximum drawdowns
189    pub mean: f64,
190    /// Median of maximum drawdowns
191    pub median: f64,
192    /// Standard deviation of maximum drawdowns
193    pub std: f64,
194    /// 5th percentile (best case)
195    pub p5: f64,
196    /// 25th percentile
197    pub p25: f64,
198    /// 75th percentile
199    pub p75: f64,
200    /// 95th percentile (stress scenario)
201    pub p95: f64,
202    /// 99th percentile (extreme stress)
203    pub p99: f64,
204    /// Maximum (worst case observed)
205    pub worst: f64,
206    /// Minimum (best case observed)
207    pub best: f64,
208}
209
210impl DrawdownStatistics {
211    /// Create statistics from a collection of max drawdowns
212    #[must_use]
213    pub fn from_drawdowns(drawdowns: &[f64]) -> Self {
214        if drawdowns.is_empty() {
215            return Self::default();
216        }
217
218        let n = drawdowns.len() as f64;
219        let mean = drawdowns.iter().sum::<f64>() / n;
220
221        let variance = drawdowns.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / n;
222        let std = variance.sqrt();
223
224        let worst = drawdowns.iter().copied().fold(f64::NEG_INFINITY, f64::max);
225        let best = drawdowns.iter().copied().fold(f64::INFINITY, f64::min);
226
227        Self {
228            mean,
229            median: percentile(drawdowns, 0.5),
230            std,
231            p5: percentile(drawdowns, 0.05),
232            p25: percentile(drawdowns, 0.25),
233            p75: percentile(drawdowns, 0.75),
234            p95: percentile(drawdowns, 0.95),
235            p99: percentile(drawdowns, 0.99),
236            worst,
237            best,
238        }
239    }
240
241    /// Check if drawdown exceeds threshold at given confidence
242    #[must_use]
243    pub fn exceeds_threshold(&self, threshold: f64, confidence: f64) -> bool {
244        let percentile_value = match confidence {
245            c if c >= 0.99 => self.p99,
246            c if c >= 0.95 => self.p95,
247            c if c >= 0.75 => self.p75,
248            c if c >= 0.50 => self.median,
249            _ => self.p25,
250        };
251        percentile_value > threshold
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::monte_carlo::engine::PathMetadata;
259
260    #[test]
261    fn test_max_drawdown_basic() {
262        let values = vec![100.0, 110.0, 90.0, 95.0, 85.0, 100.0];
263        let max_dd = DrawdownAnalysis::max_drawdown(&values);
264
265        // Peak 110, trough 85: (110-85)/110 = 0.2273
266        assert!(
267            (max_dd - 0.2273).abs() < 0.01,
268            "Max drawdown = {max_dd}, expected ~0.227"
269        );
270    }
271
272    #[test]
273    fn test_max_drawdown_no_drawdown() {
274        let values = vec![100.0, 101.0, 102.0, 103.0, 104.0];
275        let max_dd = DrawdownAnalysis::max_drawdown(&values);
276
277        assert!(max_dd.abs() < 1e-10, "No drawdown for monotonic increase");
278    }
279
280    #[test]
281    fn test_max_drawdown_complete_loss() {
282        let values = vec![100.0, 50.0, 25.0, 10.0, 0.0];
283        let max_dd = DrawdownAnalysis::max_drawdown(&values);
284
285        assert!(
286            (max_dd - 1.0).abs() < 1e-10,
287            "100% drawdown expected: {max_dd}"
288        );
289    }
290
291    #[test]
292    fn test_drawdown_series() {
293        let values = vec![100.0, 110.0, 90.0, 100.0];
294        let series = DrawdownAnalysis::drawdown_series(&values);
295
296        assert_eq!(series.len(), 4);
297        assert!(series[0].abs() < 1e-10); // No drawdown at start
298        assert!(series[1].abs() < 1e-10); // New peak
299        assert!((series[2] - (110.0 - 90.0) / 110.0).abs() < 1e-10); // ~0.182
300        assert!((series[3] - (110.0 - 100.0) / 110.0).abs() < 1e-10); // ~0.091
301    }
302
303    #[test]
304    fn test_max_drawdown_duration() {
305        let values = vec![100.0, 110.0, 90.0, 95.0, 100.0, 110.0, 115.0];
306        let duration = DrawdownAnalysis::max_drawdown_duration(&values);
307
308        // In drawdown from index 2 to 5 (3 periods: 90->95->100->110 recovery)
309        // Peak=110 at index 1, drawdown starts at index 2, recovers at index 5
310        assert_eq!(duration, 3);
311    }
312
313    #[test]
314    fn test_ulcer_index() {
315        let values = vec![100.0, 110.0, 90.0, 100.0, 110.0];
316        let ui = DrawdownAnalysis::ulcer_index(&values);
317
318        assert!(ui >= 0.0);
319        assert!(ui.is_finite());
320    }
321
322    #[test]
323    fn test_pain_index() {
324        let values = vec![100.0, 110.0, 90.0, 100.0, 110.0];
325        let pi = DrawdownAnalysis::pain_index(&values);
326
327        assert!(pi >= 0.0);
328        assert!(pi.is_finite());
329    }
330
331    #[test]
332    fn test_recovery_factor() {
333        // 50% return with 20% max drawdown = 2.5 recovery factor
334        let values = vec![100.0, 120.0, 100.0, 150.0];
335        let rf = DrawdownAnalysis::recovery_factor(&values);
336
337        // Return: (150-100)/100 = 0.5
338        // Max DD: (120-100)/120 = 0.167
339        // RF = 0.5 / 0.167 ≈ 3.0
340        assert!(rf > 2.0 && rf < 4.0, "Recovery factor = {rf}");
341    }
342
343    #[test]
344    fn test_drawdown_statistics() {
345        let drawdowns = vec![0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.12, 0.08, 0.18, 0.22];
346        let stats = DrawdownStatistics::from_drawdowns(&drawdowns);
347
348        assert!(stats.mean > 0.0);
349        assert!(stats.median > 0.0);
350        assert!(stats.std >= 0.0);
351        assert!(stats.worst >= stats.p99);
352        assert!(stats.best <= stats.p5);
353    }
354
355    #[test]
356    fn test_from_paths() {
357        let paths: Vec<SimulationPath> = (0..100)
358            .map(|i| {
359                let values = vec![100.0, 105.0, 95.0, 100.0 + (i as f64 * 0.5)];
360                SimulationPath::new(
361                    vec![0.0, 0.25, 0.5, 1.0],
362                    values,
363                    PathMetadata {
364                        path_id: i,
365                        seed: 42,
366                        is_antithetic: false,
367                    },
368                )
369            })
370            .collect();
371
372        let stats = DrawdownAnalysis::from_paths(&paths);
373
374        assert!(stats.mean > 0.0);
375        assert!(stats.mean < 1.0);
376    }
377
378    #[test]
379    fn test_exceeds_threshold() {
380        let drawdowns = vec![0.05, 0.10, 0.15, 0.20, 0.25];
381        let stats = DrawdownStatistics::from_drawdowns(&drawdowns);
382
383        // At 95% confidence, check if drawdown exceeds 0.10
384        // p95 should be around 0.24
385        assert!(stats.exceeds_threshold(0.10, 0.95));
386        assert!(!stats.exceeds_threshold(0.30, 0.95));
387    }
388
389    #[test]
390    fn test_empty_inputs() {
391        assert!(DrawdownAnalysis::max_drawdown(&[]).abs() < 1e-10);
392        assert!(DrawdownAnalysis::drawdown_series(&[]).is_empty());
393        assert_eq!(DrawdownAnalysis::max_drawdown_duration(&[]), 0);
394        assert!(DrawdownAnalysis::ulcer_index(&[]).abs() < 1e-10);
395    }
396
397    #[test]
398    fn test_single_value() {
399        let values = vec![100.0];
400        assert!(DrawdownAnalysis::max_drawdown(&values).abs() < 1e-10);
401        assert_eq!(DrawdownAnalysis::drawdown_series(&values).len(), 1);
402    }
403
404    #[test]
405    fn test_recovery_factor_initial_zero() {
406        // first <= 0.0 should return 0.0
407        let values = vec![0.0, 50.0, 100.0];
408        let rf = DrawdownAnalysis::recovery_factor(&values);
409        assert!(
410            rf.abs() < 1e-10,
411            "Recovery factor with zero initial should be 0: {rf}"
412        );
413    }
414
415    #[test]
416    fn test_recovery_factor_initial_negative() {
417        let values = vec![-10.0, 50.0, 100.0];
418        let rf = DrawdownAnalysis::recovery_factor(&values);
419        assert!(
420            rf.abs() < 1e-10,
421            "Recovery factor with negative initial should be 0: {rf}"
422        );
423    }
424
425    #[test]
426    fn test_recovery_factor_no_drawdown_positive_return() {
427        // Monotonically increasing: max_dd = 0, total_return > 0 => Infinity
428        let values = vec![100.0, 110.0, 120.0, 130.0];
429        let rf = DrawdownAnalysis::recovery_factor(&values);
430        assert!(
431            rf.is_infinite() && rf > 0.0,
432            "Recovery factor with no drawdown and positive return should be Infinity: {rf}"
433        );
434    }
435
436    #[test]
437    fn test_recovery_factor_no_drawdown_no_return() {
438        // Flat values: max_dd = 0, total_return = 0 => 0.0
439        let values = vec![100.0, 100.0, 100.0];
440        let rf = DrawdownAnalysis::recovery_factor(&values);
441        assert!(
442            rf.abs() < 1e-10,
443            "Recovery factor with no drawdown and no return should be 0: {rf}"
444        );
445    }
446
447    #[test]
448    fn test_recovery_factor_single_value() {
449        let values = vec![100.0];
450        let rf = DrawdownAnalysis::recovery_factor(&values);
451        assert!(
452            rf.abs() < 1e-10,
453            "Recovery factor with single value should be 0: {rf}"
454        );
455    }
456
457    #[test]
458    fn test_recovery_factor_empty() {
459        let rf = DrawdownAnalysis::recovery_factor(&[]);
460        assert!(
461            rf.abs() < 1e-10,
462            "Recovery factor with empty should be 0: {rf}"
463        );
464    }
465
466    #[test]
467    fn test_drawdown_series_zero_peak() {
468        // peak <= 0 triggers the else branch returning 0.0 for drawdown
469        let values = vec![0.0, 0.0, 0.0];
470        let series = DrawdownAnalysis::drawdown_series(&values);
471        assert_eq!(series.len(), 3);
472        for dd in &series {
473            assert!(
474                dd.abs() < 1e-10,
475                "Drawdown with zero peak should be 0: {dd}"
476            );
477        }
478    }
479
480    #[test]
481    fn test_drawdown_series_negative_values() {
482        // Negative starting values: peak stays at initial negative value
483        let values = vec![-10.0, -5.0, -2.0];
484        let series = DrawdownAnalysis::drawdown_series(&values);
485        assert_eq!(series.len(), 3);
486        // Peak starts at -10, then goes to -5 (new peak), then -2 (new peak)
487        // All are new peaks, so all drawdowns should be 0
488        for dd in &series {
489            assert!(
490                dd.abs() < 1e-10,
491                "Drawdown with rising negative values: {dd}"
492            );
493        }
494    }
495
496    #[test]
497    fn test_max_drawdown_zero_peak() {
498        // Peak at 0: the else-if branch `peak > 0.0` is false, so drawdown not computed
499        let values = vec![0.0, -5.0, -10.0];
500        let dd = DrawdownAnalysis::max_drawdown(&values);
501        // Peak=0 at start, values go negative but peak > 0.0 check fails
502        assert!(dd.abs() < 1e-10, "Max drawdown with zero peak: {dd}");
503    }
504
505    #[test]
506    fn test_from_paths_empty() {
507        let stats = DrawdownAnalysis::from_paths(&[]);
508        assert!(stats.mean.abs() < 1e-10);
509        assert!(stats.worst.abs() < 1e-10 || stats.worst == f64::NEG_INFINITY);
510    }
511
512    #[test]
513    fn test_drawdown_statistics_empty() {
514        let stats = DrawdownStatistics::from_drawdowns(&[]);
515        assert!(stats.mean.abs() < 1e-10);
516        assert!(stats.median.abs() < 1e-10);
517        assert!(stats.std.abs() < 1e-10);
518    }
519
520    #[test]
521    fn test_exceeds_threshold_various_confidence_levels() {
522        let drawdowns = vec![0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40];
523        let stats = DrawdownStatistics::from_drawdowns(&drawdowns);
524
525        // Test confidence >= 0.99 => uses p99
526        assert!(stats.exceeds_threshold(0.01, 0.99));
527
528        // Test confidence >= 0.95 but < 0.99 => uses p95
529        assert!(stats.exceeds_threshold(0.01, 0.96));
530
531        // Test confidence >= 0.75 but < 0.95 => uses p75
532        assert!(stats.exceeds_threshold(0.01, 0.80));
533
534        // Test confidence >= 0.50 but < 0.75 => uses median
535        assert!(stats.exceeds_threshold(0.01, 0.60));
536
537        // Test confidence < 0.50 => uses p25
538        assert!(stats.exceeds_threshold(0.01, 0.30));
539
540        // Test threshold too high for each level
541        assert!(!stats.exceeds_threshold(0.99, 0.30));
542    }
543
544    #[test]
545    fn test_max_drawdown_duration_no_drawdown() {
546        // Monotonically increasing: never in drawdown
547        let values = vec![100.0, 110.0, 120.0, 130.0];
548        let duration = DrawdownAnalysis::max_drawdown_duration(&values);
549        assert_eq!(duration, 0);
550    }
551
552    #[test]
553    fn test_max_drawdown_duration_single() {
554        let values = vec![100.0];
555        let duration = DrawdownAnalysis::max_drawdown_duration(&values);
556        assert_eq!(duration, 0);
557    }
558
559    #[test]
560    fn test_pain_index_empty() {
561        assert!(DrawdownAnalysis::pain_index(&[]).abs() < 1e-10);
562    }
563
564    // Property-based tests
565    #[cfg(test)]
566    mod proptests {
567        use super::*;
568        use proptest::prelude::*;
569
570        proptest! {
571            #[test]
572            fn prop_max_drawdown_bounded(values in prop::collection::vec(1.0..1000.0f64, 10..100)) {
573                let dd = DrawdownAnalysis::max_drawdown(&values);
574                prop_assert!(dd >= 0.0 && dd <= 1.0, "Drawdown must be in [0, 1]: {dd}");
575            }
576
577            #[test]
578            fn prop_drawdown_series_same_length(values in prop::collection::vec(1.0..1000.0f64, 1..100)) {
579                let series = DrawdownAnalysis::drawdown_series(&values);
580                prop_assert_eq!(series.len(), values.len());
581            }
582
583            #[test]
584            fn prop_drawdown_series_non_negative(values in prop::collection::vec(1.0..1000.0f64, 1..100)) {
585                let series = DrawdownAnalysis::drawdown_series(&values);
586                for dd in series {
587                    prop_assert!(dd >= 0.0, "Drawdown must be non-negative: {dd}");
588                }
589            }
590
591            #[test]
592            fn prop_ulcer_index_non_negative(values in prop::collection::vec(1.0..1000.0f64, 10..100)) {
593                let ui = DrawdownAnalysis::ulcer_index(&values);
594                prop_assert!(ui >= 0.0 && ui.is_finite());
595            }
596
597            #[test]
598            fn prop_pain_leq_ulcer(values in prop::collection::vec(1.0..1000.0f64, 10..100)) {
599                let pi = DrawdownAnalysis::pain_index(&values);
600                let ui = DrawdownAnalysis::ulcer_index(&values);
601                // Pain (linear mean) <= Ulcer (quadratic mean) by Jensen's inequality
602                // With small tolerance for numerical errors
603                prop_assert!(pi <= ui + 0.001, "Pain {pi} should be <= Ulcer {ui}");
604            }
605        }
606    }
607}