Skip to main content

nautilus_analysis/statistics/
max_drawdown.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Maximum Drawdown statistic.
17
18use std::collections::BTreeMap;
19
20use nautilus_core::UnixNanos;
21use nautilus_model::position::Position;
22
23use crate::statistic::PortfolioStatistic;
24
25/// Calculates the Maximum Drawdown for returns.
26///
27/// Maximum Drawdown is the maximum observed loss from a peak to a trough,
28/// before a new peak is attained. It is an indicator of downside risk over
29/// a specified time period.
30///
31/// Formula: Max((Peak - Trough) / Peak) for all peak-trough sequences
32///
33/// The equity curve compounds returns from a starting value of `1.0`, and the
34/// result is reported as a negative fraction (e.g. `-0.20` is a 20% drawdown).
35///
36/// # References
37///
38/// - Bacon, C. R. (2008). *Practical Portfolio Performance Measurement and Attribution*
39///   (2nd ed.). Wiley.
40#[repr(C)]
41#[derive(Debug, Clone, Default)]
42#[cfg_attr(
43    feature = "python",
44    pyo3::pyclass(module = "nautilus_trader.analysis", from_py_object)
45)]
46#[cfg_attr(
47    feature = "python",
48    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
49)]
50pub struct MaxDrawdown {}
51
52impl MaxDrawdown {
53    /// Creates a new [`MaxDrawdown`] instance.
54    #[must_use]
55    pub fn new() -> Self {
56        Self {}
57    }
58}
59
60impl PortfolioStatistic for MaxDrawdown {
61    type Item = f64;
62
63    fn name(&self) -> String {
64        "Max Drawdown".to_string()
65    }
66
67    fn calculate_from_returns(&self, returns: &BTreeMap<UnixNanos, f64>) -> Option<Self::Item> {
68        if returns.is_empty() {
69            return Some(0.0);
70        }
71
72        // Calculate cumulative returns starting from 1.0
73        let mut cumulative = 1.0;
74        let mut running_max = 1.0;
75        let mut max_drawdown = 0.0;
76
77        for &ret in returns.values() {
78            cumulative *= 1.0 + ret;
79
80            // Update running maximum
81            if cumulative > running_max {
82                running_max = cumulative;
83            }
84
85            // Calculate drawdown from running max
86            let drawdown = (running_max - cumulative) / running_max;
87
88            // Update maximum drawdown
89            if drawdown > max_drawdown {
90                max_drawdown = drawdown;
91            }
92        }
93
94        // Return as negative percentage
95        Some(-max_drawdown)
96    }
97    fn calculate_from_realized_pnls(&self, _realized_pnls: &[f64]) -> Option<Self::Item> {
98        None
99    }
100
101    fn calculate_from_positions(&self, _positions: &[Position]) -> Option<Self::Item> {
102        None
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use nautilus_core::approx_eq;
109    use rstest::rstest;
110
111    use super::*;
112
113    fn create_returns(values: &[f64]) -> BTreeMap<UnixNanos, f64> {
114        values
115            .iter()
116            .copied()
117            .enumerate()
118            .map(|(i, v)| (UnixNanos::from(i as u64), v))
119            .collect()
120    }
121
122    #[rstest]
123    fn test_name() {
124        let stat = MaxDrawdown::new();
125        assert_eq!(stat.name(), "Max Drawdown");
126    }
127
128    #[rstest]
129    fn test_empty_returns() {
130        let stat = MaxDrawdown::new();
131        let returns = BTreeMap::new();
132        let result = stat.calculate_from_returns(&returns);
133        assert_eq!(result, Some(0.0));
134    }
135
136    #[rstest]
137    fn test_no_drawdown() {
138        let stat = MaxDrawdown::new();
139        // Only positive returns, no drawdown
140        let returns = create_returns(&[0.01, 0.02, 0.01, 0.015]);
141        let result = stat.calculate_from_returns(&returns).unwrap();
142        assert_eq!(result, 0.0);
143    }
144
145    #[rstest]
146    fn test_simple_drawdown() {
147        let stat = MaxDrawdown::new();
148        // Start at 1.0, go to 1.1 (+10%), then drop to 0.99 (-10% from peak)
149        // Max DD = (1.1 - 0.99) / 1.1 = 0.11 / 1.1 = 0.10, reported as -0.10
150        let returns = create_returns(&[0.10, -0.10]);
151        let result = stat.calculate_from_returns(&returns).unwrap();
152
153        assert!(approx_eq!(f64, result, -0.10, epsilon = 1e-12));
154    }
155
156    #[rstest]
157    fn test_multiple_drawdowns() {
158        let stat = MaxDrawdown::new();
159        // equity = [1.1, 0.99, 1.485, 1.188, 1.3068]
160        // DD1: (1.1 - 0.99) / 1.1 = 0.10
161        // DD2: (1.485 - 1.188) / 1.485 = 0.20
162        let returns = create_returns(&[0.10, -0.10, 0.50, -0.20, 0.10]);
163        let result = stat.calculate_from_returns(&returns).unwrap();
164
165        // Max DD should be the larger one (20%)
166        assert!(approx_eq!(f64, result, -0.20, epsilon = 1e-12));
167    }
168
169    #[rstest]
170    fn test_initial_loss() {
171        let stat = MaxDrawdown::new();
172        // Start with 40% loss
173        let returns = create_returns(&[-0.40, -0.10]);
174        let result = stat.calculate_from_returns(&returns).unwrap();
175
176        // From 1.0 -> 0.6 -> 0.54
177        // Max DD from the initial 1.0 peak is (1.0 - 0.54) / 1.0 = 0.46
178        assert!(approx_eq!(f64, result, -0.46, epsilon = 1e-12));
179    }
180}