Skip to main content

quant_metrics/
drawdown.rs

1//! Drawdown calculations.
2
3use rust_decimal::Decimal;
4
5use crate::MetricsError;
6
7/// Calculate maximum drawdown as a percentage.
8///
9/// Drawdown is the peak-to-trough decline during a specific period.
10/// Max drawdown is the largest such decline.
11///
12/// Formula: min((trough - peak) / peak * 100) for all peaks
13///
14/// # Arguments
15/// * `equity` - Equity curve (NAV or portfolio value over time)
16///
17/// # Returns
18/// Maximum drawdown as a negative percentage (e.g., -15.5 for 15.5% drawdown).
19/// Returns 0 if no drawdown occurred.
20///
21/// # Example
22/// ```
23/// use quant_metrics::max_drawdown;
24/// use rust_decimal_macros::dec;
25///
26/// let equity = vec![dec!(100), dec!(110), dec!(90), dec!(105)];
27/// // Peak was 110, trough was 90 -> -18.18%
28/// let dd = max_drawdown(&equity).unwrap();
29/// assert!(dd < dec!(0));
30/// ```
31pub fn max_drawdown(equity: &[Decimal]) -> Result<Decimal, MetricsError> {
32    if equity.len() < 2 {
33        return Err(MetricsError::InsufficientData {
34            required: 2,
35            actual: equity.len(),
36        });
37    }
38
39    let dd_series = drawdown_series(equity)?;
40    Ok(dd_series.into_iter().min().unwrap_or(Decimal::ZERO))
41}
42
43/// Calculate drawdown at each point in the equity curve.
44///
45/// Returns a series of drawdown values (negative percentages from peak).
46///
47/// # Arguments
48/// * `equity` - Equity curve
49///
50/// # Returns
51/// Vector of drawdown percentages at each point.
52pub fn drawdown_series(equity: &[Decimal]) -> Result<Vec<Decimal>, MetricsError> {
53    if equity.is_empty() {
54        return Err(MetricsError::InsufficientData {
55            required: 1,
56            actual: 0,
57        });
58    }
59
60    let mut peak = equity[0];
61    let mut drawdowns = Vec::with_capacity(equity.len());
62
63    for &value in equity {
64        if value > peak {
65            peak = value;
66        }
67
68        if peak == Decimal::ZERO {
69            drawdowns.push(Decimal::ZERO);
70        } else {
71            let dd = ((value - peak) / peak) * Decimal::from(100);
72            drawdowns.push(dd);
73        }
74    }
75
76    Ok(drawdowns)
77}
78
79/// Calculate maximum drawdown duration in periods.
80///
81/// Duration is the number of periods from peak to recovery (or end if not recovered).
82///
83/// # Arguments
84/// * `equity` - Equity curve
85///
86/// # Returns
87/// Maximum number of periods spent in drawdown.
88pub fn max_drawdown_duration(equity: &[Decimal]) -> Result<usize, MetricsError> {
89    if equity.len() < 2 {
90        return Err(MetricsError::InsufficientData {
91            required: 2,
92            actual: equity.len(),
93        });
94    }
95
96    let mut peak = equity[0];
97    let mut peak_idx = 0;
98    let mut max_duration = 0;
99    let mut current_duration = 0;
100
101    for (i, &value) in equity.iter().enumerate() {
102        if value >= peak {
103            // New peak or recovery
104            if current_duration > max_duration {
105                max_duration = current_duration;
106            }
107            peak = value;
108            peak_idx = i;
109            current_duration = 0;
110        } else {
111            // In drawdown
112            current_duration = i - peak_idx;
113        }
114    }
115
116    // Check if still in drawdown at end
117    if current_duration > max_duration {
118        max_duration = current_duration;
119    }
120
121    Ok(max_duration)
122}
123
124/// Calculate time to recover from maximum drawdown in periods.
125///
126/// Returns the number of periods from the max drawdown trough to full recovery,
127/// or None if recovery hasn't occurred yet.
128///
129/// # Arguments
130/// * `equity` - Equity curve
131///
132/// # Returns
133/// Number of periods to recover, or None if still in drawdown.
134pub fn recovery_time(equity: &[Decimal]) -> Result<Option<usize>, MetricsError> {
135    if equity.len() < 2 {
136        return Err(MetricsError::InsufficientData {
137            required: 2,
138            actual: equity.len(),
139        });
140    }
141
142    let Some((trough_idx, trough_peak)) = find_max_drawdown_trough(equity) else {
143        // No drawdown occurred
144        return Ok(Some(0));
145    };
146
147    // Find recovery point (first time equity >= peak after trough)
148    for (i, &value) in equity.iter().enumerate().skip(trough_idx + 1) {
149        if value >= trough_peak {
150            return Ok(Some(i - trough_idx));
151        }
152    }
153
154    // Not recovered yet
155    Ok(None)
156}
157
158/// Locate the index and peak value of the maximum drawdown trough.
159///
160/// Returns `None` if no drawdown occurred (equity never falls below a prior peak).
161fn find_max_drawdown_trough(equity: &[Decimal]) -> Option<(usize, Decimal)> {
162    let mut peak = equity[0];
163    let mut max_dd = Decimal::ZERO;
164    let mut max_dd_trough_idx = 0;
165    let mut max_dd_peak = equity[0];
166
167    for (i, &value) in equity.iter().enumerate() {
168        if value > peak {
169            peak = value;
170        }
171        if peak != Decimal::ZERO {
172            let dd = (value - peak) / peak;
173            if dd < max_dd {
174                max_dd = dd;
175                max_dd_trough_idx = i;
176                max_dd_peak = peak;
177            }
178        }
179    }
180
181    if max_dd == Decimal::ZERO {
182        None
183    } else {
184        Some((max_dd_trough_idx, max_dd_peak))
185    }
186}
187
188#[cfg(test)]
189#[path = "drawdown_tests.rs"]
190mod tests;