use std::collections::BTreeMap;
use nautilus_core::UnixNanos;
use nautilus_model::position::Position;
use crate::statistic::PortfolioStatistic;
#[expect(
clippy::doc_markdown,
reason = "citation contains proper nouns with intra-word capitals"
)]
#[repr(C)]
#[derive(Debug, Clone, Default)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(module = "nautilus_trader.analysis", from_py_object)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
)]
pub struct UlcerIndex {}
impl UlcerIndex {
#[must_use]
pub fn new() -> Self {
Self {}
}
}
impl PortfolioStatistic for UlcerIndex {
type Item = f64;
fn name(&self) -> String {
"Ulcer Index".to_string()
}
fn calculate_from_returns(&self, returns: &BTreeMap<UnixNanos, f64>) -> Option<Self::Item> {
if returns.is_empty() {
return Some(0.0);
}
let mut cumulative = 1.0;
let mut running_max = 1.0;
let mut sum_squared_drawdown = 0.0;
for &ret in returns.values() {
cumulative *= 1.0 + ret;
if cumulative > running_max {
running_max = cumulative;
}
let drawdown = (running_max - cumulative) / running_max;
sum_squared_drawdown += drawdown * drawdown;
}
Some((sum_squared_drawdown / returns.len() as f64).sqrt())
}
fn calculate_from_realized_pnls(&self, _realized_pnls: &[f64]) -> Option<Self::Item> {
None
}
fn calculate_from_positions(&self, _positions: &[Position]) -> Option<Self::Item> {
None
}
}
#[cfg(test)]
mod tests {
use nautilus_core::approx_eq;
use rstest::rstest;
use super::*;
fn create_returns(values: &[f64]) -> BTreeMap<UnixNanos, f64> {
values
.iter()
.copied()
.enumerate()
.map(|(i, v)| (UnixNanos::from(i as u64), v))
.collect()
}
#[rstest]
fn test_name() {
let stat = UlcerIndex::new();
assert_eq!(stat.name(), "Ulcer Index");
}
#[rstest]
fn test_empty_returns() {
let stat = UlcerIndex::new();
let returns = BTreeMap::new();
assert_eq!(stat.calculate_from_returns(&returns), Some(0.0));
}
#[rstest]
fn test_no_drawdown_is_zero() {
let stat = UlcerIndex::new();
let returns = create_returns(&[0.01, 0.02, 0.01, 0.015]);
let result = stat.calculate_from_returns(&returns).unwrap();
assert!(approx_eq!(f64, result, 0.0, epsilon = 1e-12));
}
#[rstest]
fn test_ulcer_index_calculation() {
let stat = UlcerIndex::new();
let returns = create_returns(&[0.10, -0.10, 0.50, -0.20, 0.10]);
let result = stat.calculate_from_returns(&returns).unwrap();
assert!(approx_eq!(
f64,
result,
0.11349008767288883,
epsilon = 1e-12
));
}
#[rstest]
fn test_persistent_drawdown_hand_example() {
let stat = UlcerIndex::new();
let returns = create_returns(&[0.0, -0.1, 0.0]);
let result = stat.calculate_from_returns(&returns).unwrap();
assert!(approx_eq!(
f64,
result,
(0.02_f64 / 3.0).sqrt(),
epsilon = 1e-12
));
}
}