Skip to main content

kestrel_chartkit/indicator/
ulcer.rs

1use std::collections::VecDeque;
2
3use crate::model::Bar;
4
5use super::{Indicator, IndicatorOutput};
6
7/// Streaming Ulcer Index over any series of positive values.
8///
9/// Two questions, kept apart:
10///
11/// 1. **How far below its own high was the series at each point?** For every value the running
12///    maximum of the last `len` values *up to and including that point* is taken, and the
13///    percentage below it recorded: `100 * (value - running_max) / running_max`, at most zero.
14///    A past drawdown is never recomputed against a later high — what it felt like then is what
15///    it was, and rewriting it against today's peak would flatter or worsen history depending on
16///    what happened afterwards.
17/// 2. **How deep and how persistent were those drawdowns?** The squared percentages over the last
18///    `len` points are averaged and the root taken.
19///
20/// Squaring is what separates this from an average drawdown: it weighs one deep, long decline
21/// more heavily than a series of shallow dips, which is the property the measure exists for.
22///
23/// Unit: percent. Zero means the series never traded below its running high in the window; there
24/// is no upper bound.
25///
26/// First value: after `2 * len - 1` observations — `len` to fill the running-maximum window, then
27/// another `len - 1` so that every squared drawdown being averaged is itself fully formed.
28///
29/// The series must be positive: a percentage below a non-positive high has no meaning. Such a
30/// value is refused rather than folded in, and the state is left untouched.
31///
32/// Works on prices, on an equity curve, or on any other positive series — nothing here is
33/// specific to bars.
34#[derive(Debug, Clone)]
35pub struct UlcerIndexCore {
36    len: usize,
37    window: VecDeque<f64>,
38    squared_drawdowns: VecDeque<f64>,
39}
40
41impl UlcerIndexCore {
42    pub fn new(len: usize) -> Self {
43        let len = len.max(1);
44        Self {
45            len,
46            window: VecDeque::with_capacity(len),
47            squared_drawdowns: VecDeque::with_capacity(len),
48        }
49    }
50
51    /// Observations needed before [`UlcerIndexCore::update`] first returns `Some`.
52    pub fn warmup_period(&self) -> usize {
53        2 * self.len - 1
54    }
55
56    pub fn update(&mut self, value: f64) -> Option<f64> {
57        if !value.is_finite() || value <= 0.0 {
58            return None;
59        }
60
61        self.window.push_back(value);
62        if self.window.len() > self.len {
63            self.window.pop_front();
64        }
65        if self.window.len() < self.len {
66            return None;
67        }
68
69        let running_max = self
70            .window
71            .iter()
72            .copied()
73            .fold(f64::NEG_INFINITY, f64::max);
74        let drawdown_pct = 100.0 * (value - running_max) / running_max;
75
76        self.squared_drawdowns
77            .push_back(drawdown_pct * drawdown_pct);
78        if self.squared_drawdowns.len() > self.len {
79            self.squared_drawdowns.pop_front();
80        }
81        if self.squared_drawdowns.len() < self.len {
82            return None;
83        }
84
85        let mean = self.squared_drawdowns.iter().sum::<f64>() / self.len as f64;
86        Some(mean.sqrt())
87    }
88
89    pub fn reset(&mut self) {
90        self.window.clear();
91        self.squared_drawdowns.clear();
92    }
93}
94
95/// The Ulcer Index of a complete series, or `None` if it is shorter than the warmup.
96///
97/// The batch counterpart to [`UlcerIndexCore`], for an equity curve or a return series that is
98/// already in hand rather than arriving bar by bar. Returns the value at the end of the series.
99pub fn ulcer_index(values: &[f64], len: usize) -> Option<f64> {
100    let mut core = UlcerIndexCore::new(len);
101    values.iter().filter_map(|value| core.update(*value)).last()
102}
103
104/// Ulcer Index over the closing price; see [`UlcerIndexCore`] for the definition.
105///
106/// This measures the same thing as [`crate::portfolio::compute_drawdown`] does not: that one
107/// reports the single worst peak-to-trough decline of a whole series, this one how much time the
108/// series spent below its high and how far. Both stay.
109#[derive(Debug, Clone)]
110pub struct UlcerIndexEngine {
111    core: UlcerIndexCore,
112}
113
114impl UlcerIndexEngine {
115    pub fn new(len: usize) -> Self {
116        Self {
117            core: UlcerIndexCore::new(len),
118        }
119    }
120
121    pub fn with_defaults() -> Self {
122        Self::new(14)
123    }
124}
125
126impl Indicator for UlcerIndexEngine {
127    fn name(&self) -> &str {
128        "ulcer_index"
129    }
130
131    fn warmup_period(&self) -> usize {
132        self.core.warmup_period()
133    }
134
135    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
136        self.core.update(bar.close).map(IndicatorOutput::new)
137    }
138
139    fn reset(&mut self) {
140        self.core.reset();
141    }
142}