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