nautilus_analysis/statistics/ulcer_index.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//! Ulcer Index 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 Ulcer Index of portfolio returns.
26///
27/// The Ulcer Index measures downside risk as the root-mean-square of the
28/// percentage drawdowns of the cumulative-return equity curve. Unlike volatility
29/// it only penalizes downside deviations, and unlike maximum drawdown it accounts
30/// for both the depth and the duration of drawdowns.
31///
32/// The equity curve compounds returns from a starting value of `1.0`, and each
33/// drawdown is measured against the running peak (matching the convention used by
34/// [`MaxDrawdown`](super::max_drawdown::MaxDrawdown)):
35///
36/// `UI = sqrt( mean( D_i^2 ) )`, where `D_i = (peak_i - equity_i) / peak_i`
37///
38/// Drawdowns are expressed as fractions (`0.05` = 5%), so the result is on the
39/// same scale as `MaxDrawdown` (the original definition uses percentage points).
40/// Returns `0.0` for an empty series.
41///
42/// # References
43///
44/// - Martin, P. G., & McCann, B. B. (1989). *The Investor's Guide to Fidelity Funds*. Wiley.
45/// - Peter Martin's Ulcer Index page (<https://www.tangotools.com/ui/ui.htm>).
46#[expect(
47 clippy::doc_markdown,
48 reason = "citation contains proper nouns with intra-word capitals"
49)]
50#[repr(C)]
51#[derive(Debug, Clone, Default)]
52#[cfg_attr(
53 feature = "python",
54 pyo3::pyclass(module = "nautilus_trader.analysis", from_py_object)
55)]
56#[cfg_attr(
57 feature = "python",
58 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
59)]
60pub struct UlcerIndex {}
61
62impl UlcerIndex {
63 /// Creates a new [`UlcerIndex`] instance.
64 #[must_use]
65 pub fn new() -> Self {
66 Self {}
67 }
68}
69
70impl PortfolioStatistic for UlcerIndex {
71 type Item = f64;
72
73 fn name(&self) -> String {
74 "Ulcer Index".to_string()
75 }
76
77 fn calculate_from_returns(&self, returns: &BTreeMap<UnixNanos, f64>) -> Option<Self::Item> {
78 if returns.is_empty() {
79 return Some(0.0);
80 }
81
82 // Compound returns into an equity curve starting from 1.0 and accumulate
83 // the squared percentage drawdown from the running peak at each step.
84 let mut cumulative = 1.0;
85 let mut running_max = 1.0;
86 let mut sum_squared_drawdown = 0.0;
87
88 for &ret in returns.values() {
89 cumulative *= 1.0 + ret;
90
91 if cumulative > running_max {
92 running_max = cumulative;
93 }
94
95 let drawdown = (running_max - cumulative) / running_max;
96 sum_squared_drawdown += drawdown * drawdown;
97 }
98
99 Some((sum_squared_drawdown / returns.len() as f64).sqrt())
100 }
101
102 fn calculate_from_realized_pnls(&self, _realized_pnls: &[f64]) -> Option<Self::Item> {
103 None
104 }
105
106 fn calculate_from_positions(&self, _positions: &[Position]) -> Option<Self::Item> {
107 None
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use nautilus_core::approx_eq;
114 use rstest::rstest;
115
116 use super::*;
117
118 fn create_returns(values: &[f64]) -> BTreeMap<UnixNanos, f64> {
119 values
120 .iter()
121 .copied()
122 .enumerate()
123 .map(|(i, v)| (UnixNanos::from(i as u64), v))
124 .collect()
125 }
126
127 #[rstest]
128 fn test_name() {
129 let stat = UlcerIndex::new();
130 assert_eq!(stat.name(), "Ulcer Index");
131 }
132
133 #[rstest]
134 fn test_empty_returns() {
135 let stat = UlcerIndex::new();
136 let returns = BTreeMap::new();
137 assert_eq!(stat.calculate_from_returns(&returns), Some(0.0));
138 }
139
140 #[rstest]
141 fn test_no_drawdown_is_zero() {
142 // Monotonically rising equity has no drawdown, so the Ulcer Index is 0.
143 let stat = UlcerIndex::new();
144 let returns = create_returns(&[0.01, 0.02, 0.01, 0.015]);
145 let result = stat.calculate_from_returns(&returns).unwrap();
146 assert!(approx_eq!(f64, result, 0.0, epsilon = 1e-12));
147 }
148
149 #[rstest]
150 fn test_ulcer_index_calculation() {
151 // Reference value cross-checked against numpy:
152 // equity = cumprod(1 + r), dd = (peak - equity) / peak,
153 // UI = sqrt(mean(dd^2)) with the 1.0 starting-capital baseline.
154 let stat = UlcerIndex::new();
155 let returns = create_returns(&[0.10, -0.10, 0.50, -0.20, 0.10]);
156 let result = stat.calculate_from_returns(&returns).unwrap();
157 assert!(approx_eq!(
158 f64,
159 result,
160 0.11349008767288883,
161 epsilon = 1e-12
162 ));
163 }
164
165 #[rstest]
166 fn test_persistent_drawdown_hand_example() {
167 // Hand-checkable: equity = [1.0, 0.9, 0.9], drawdowns = [0, 0.1, 0.1],
168 // UI = sqrt((0 + 0.01 + 0.01) / 3) = sqrt(0.02 / 3). Unlike max drawdown,
169 // the Ulcer Index keeps penalizing a drawdown for as long as it persists.
170 let stat = UlcerIndex::new();
171 let returns = create_returns(&[0.0, -0.1, 0.0]);
172 let result = stat.calculate_from_returns(&returns).unwrap();
173 assert!(approx_eq!(
174 f64,
175 result,
176 (0.02_f64 / 3.0).sqrt(),
177 epsilon = 1e-12
178 ));
179 }
180}