aprender/monte_carlo/risk/
drawdown.rs1use crate::monte_carlo::engine::{percentile, SimulationPath};
9
10#[derive(Debug, Clone)]
12pub struct DrawdownAnalysis;
13
14impl DrawdownAnalysis {
15 #[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 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Default)]
187pub struct DrawdownStatistics {
188 pub mean: f64,
190 pub median: f64,
192 pub std: f64,
194 pub p5: f64,
196 pub p25: f64,
198 pub p75: f64,
200 pub p95: f64,
202 pub p99: f64,
204 pub worst: f64,
206 pub best: f64,
208}
209
210impl DrawdownStatistics {
211 #[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 #[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 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); assert!(series[1].abs() < 1e-10); assert!((series[2] - (110.0 - 90.0) / 110.0).abs() < 1e-10); assert!((series[3] - (110.0 - 100.0) / 110.0).abs() < 1e-10); }
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 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 let values = vec![100.0, 120.0, 100.0, 150.0];
335 let rf = DrawdownAnalysis::recovery_factor(&values);
336
337 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 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 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 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 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 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 let values = vec![-10.0, -5.0, -2.0];
484 let series = DrawdownAnalysis::drawdown_series(&values);
485 assert_eq!(series.len(), 3);
486 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 let values = vec![0.0, -5.0, -10.0];
500 let dd = DrawdownAnalysis::max_drawdown(&values);
501 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 assert!(stats.exceeds_threshold(0.01, 0.99));
527
528 assert!(stats.exceeds_threshold(0.01, 0.96));
530
531 assert!(stats.exceeds_threshold(0.01, 0.80));
533
534 assert!(stats.exceeds_threshold(0.01, 0.60));
536
537 assert!(stats.exceeds_threshold(0.01, 0.30));
539
540 assert!(!stats.exceeds_threshold(0.99, 0.30));
542 }
543
544 #[test]
545 fn test_max_drawdown_duration_no_drawdown() {
546 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 #[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 prop_assert!(pi <= ui + 0.001, "Pain {pi} should be <= Ulcer {ui}");
604 }
605 }
606 }
607}