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#[repr(C)]
33#[derive(Debug, Clone, Default)]
34#[cfg_attr(
35    feature = "python",
36    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.analysis", from_py_object)
37)]
38#[cfg_attr(
39    feature = "python",
40    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
41)]
42pub struct MaxDrawdown {}
43
44impl MaxDrawdown {
45    /// Creates a new [`MaxDrawdown`] instance.
46    #[must_use]
47    pub fn new() -> Self {
48        Self {}
49    }
50}
51
52impl PortfolioStatistic for MaxDrawdown {
53    type Item = f64;
54
55    fn name(&self) -> String {
56        "Max Drawdown".to_string()
57    }
58
59    fn calculate_from_returns(&self, returns: &BTreeMap<UnixNanos, f64>) -> Option<Self::Item> {
60        if returns.is_empty() {
61            return Some(0.0);
62        }
63
64        // Calculate cumulative returns starting from 1.0
65        let mut cumulative = 1.0;
66        let mut running_max = 1.0;
67        let mut max_drawdown = 0.0;
68
69        for &ret in returns.values() {
70            cumulative *= 1.0 + ret;
71
72            // Update running maximum
73            if cumulative > running_max {
74                running_max = cumulative;
75            }
76
77            // Calculate drawdown from running max
78            let drawdown = (running_max - cumulative) / running_max;
79
80            // Update maximum drawdown
81            if drawdown > max_drawdown {
82                max_drawdown = drawdown;
83            }
84        }
85
86        // Return as negative percentage
87        Some(-max_drawdown)
88    }
89    fn calculate_from_realized_pnls(&self, _realized_pnls: &[f64]) -> Option<Self::Item> {
90        None
91    }
92
93    fn calculate_from_positions(&self, _positions: &[Position]) -> Option<Self::Item> {
94        None
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use rstest::rstest;
101
102    use super::*;
103
104    fn create_returns(values: &[f64]) -> BTreeMap<UnixNanos, f64> {
105        values
106            .iter()
107            .copied()
108            .enumerate()
109            .map(|(i, v)| (UnixNanos::from(i as u64), v))
110            .collect()
111    }
112
113    #[rstest]
114    fn test_name() {
115        let stat = MaxDrawdown::new();
116        assert_eq!(stat.name(), "Max Drawdown");
117    }
118
119    #[rstest]
120    fn test_empty_returns() {
121        let stat = MaxDrawdown::new();
122        let returns = BTreeMap::new();
123        let result = stat.calculate_from_returns(&returns);
124        assert_eq!(result, Some(0.0));
125    }
126
127    #[rstest]
128    fn test_no_drawdown() {
129        let stat = MaxDrawdown::new();
130        // Only positive returns, no drawdown
131        let returns = create_returns(&[0.01, 0.02, 0.01, 0.015]);
132        let result = stat.calculate_from_returns(&returns).unwrap();
133        assert_eq!(result, 0.0);
134    }
135
136    #[rstest]
137    fn test_simple_drawdown() {
138        let stat = MaxDrawdown::new();
139        // Start at 1.0, go to 1.1 (+10%), then drop to 0.99 (-10% from peak)
140        // Max DD = (1.1 - 0.99) / 1.1 = 0.1 / 1.1 = 0.0909 (9.09%)
141        let returns = create_returns(&[0.10, -0.10]);
142        let result = stat.calculate_from_returns(&returns).unwrap();
143
144        // Should be approximately -0.10 (reported as negative)
145        assert!((result + 0.10).abs() < 0.01);
146    }
147
148    #[rstest]
149    fn test_multiple_drawdowns() {
150        let stat = MaxDrawdown::new();
151        // Peak at 1.5, trough at 1.0
152        // DD1: 10% from 1.0
153        // DD2: 20% from 1.5
154        let returns = create_returns(&[0.10, -0.10, 0.50, -0.20, 0.10]);
155        let result = stat.calculate_from_returns(&returns).unwrap();
156
157        // Max DD should be the larger one (20%)
158        assert!((result + 0.20).abs() < 0.01);
159    }
160
161    #[rstest]
162    fn test_initial_loss() {
163        let stat = MaxDrawdown::new();
164        // Start with 40% loss
165        let returns = create_returns(&[-0.40, -0.10]);
166        let result = stat.calculate_from_returns(&returns).unwrap();
167
168        // From 1.0 -> 0.6 -> 0.54
169        // Max DD from initial 1.0 is 46%
170        assert!((result + 0.46).abs() < 0.01);
171    }
172}