Skip to main content

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}