Skip to main content

wickra_core/indicators/
ulcer_index.rs

1//! Ulcer Index.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Ulcer Index — Peter Martin's downside-only volatility / risk measure.
9///
10/// Standard deviation punishes upside and downside moves equally; the Ulcer
11/// Index measures only the **pain of drawdowns**. For each bar it computes the
12/// percentage drop from the highest price of the trailing window, squares it,
13/// and reports the root-mean-square over the window:
14///
15/// ```text
16/// drawdown_t = 100 · (price_t − max(price, period)_t) / max(price, period)_t
17/// UlcerIndex = √( mean( drawdown² over period ) )
18/// ```
19///
20/// A pure up-trend never trades below its own running high, so its Ulcer Index
21/// is `0`; the deeper and longer the drawdowns, the higher the reading. It is
22/// the volatility measure of choice for risk-adjusted return ratios (the
23/// "Martin ratio" / UPI).
24///
25/// Each `update` is amortised O(1): the trailing maximum is tracked with a
26/// monotonically-decreasing deque of `(index, price)` pairs, so the indicator
27/// honours the `Indicator` trait's O(1)-per-tick contract even for long
28/// windows.
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{Indicator, UlcerIndex};
34///
35/// let mut indicator = UlcerIndex::new(14).unwrap();
36/// let mut last = None;
37/// for i in 0..80 {
38///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 8.0);
39/// }
40/// assert!(last.is_some());
41/// ```
42#[derive(Debug, Clone)]
43pub struct UlcerIndex {
44    period: usize,
45    /// 1-based count of finite inputs seen so far; used as the monotonic index
46    /// that expires entries from `max_dq`.
47    count: u64,
48    /// Monotonically-decreasing deque of `(index, price)` over the trailing
49    /// `period` inputs. The front holds the current trailing maximum in O(1).
50    max_dq: VecDeque<(u64, f64)>,
51    /// Rolling window of the last `period` squared percentage drawdowns.
52    drawdowns_sq: VecDeque<f64>,
53    sum_sq: f64,
54    last: Option<f64>,
55}
56
57impl UlcerIndex {
58    /// Construct a new Ulcer Index with the given period.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`Error::PeriodZero`] if `period == 0`.
63    pub fn new(period: usize) -> Result<Self> {
64        if period == 0 {
65            return Err(Error::PeriodZero);
66        }
67        Ok(Self {
68            period,
69            count: 0,
70            max_dq: VecDeque::with_capacity(period),
71            drawdowns_sq: VecDeque::with_capacity(period),
72            sum_sq: 0.0,
73            last: None,
74        })
75    }
76
77    /// Configured period.
78    pub const fn period(&self) -> usize {
79        self.period
80    }
81
82    /// Current value if available.
83    pub const fn value(&self) -> Option<f64> {
84        self.last
85    }
86}
87
88impl Indicator for UlcerIndex {
89    type Input = f64;
90    type Output = f64;
91
92    fn update(&mut self, input: f64) -> Option<f64> {
93        if !input.is_finite() {
94            // Non-finite input is ignored; state is left untouched.
95            return self.last;
96        }
97        self.count += 1;
98        // Drop tail entries that can never be the trailing max again — every
99        // entry `≤ input` is dominated by `input` and at least as old.
100        while let Some(&(_, back)) = self.max_dq.back() {
101            if back <= input {
102                self.max_dq.pop_back();
103            } else {
104                break;
105            }
106        }
107        self.max_dq.push_back((self.count, input));
108        // Expire the head once it falls out of the trailing `period`-window.
109        let window_lo = self.count.saturating_sub(self.period as u64 - 1);
110        while let Some(&(idx, _)) = self.max_dq.front() {
111            if idx < window_lo {
112                self.max_dq.pop_front();
113            } else {
114                break;
115            }
116        }
117        if self.count < self.period as u64 {
118            return None;
119        }
120        // Front is the trailing max in O(1).
121        let max_price = self.max_dq.front().expect("non-empty").1;
122        let drawdown = if max_price == 0.0 {
123            0.0
124        } else {
125            100.0 * (input - max_price) / max_price
126        };
127        let sq = drawdown * drawdown;
128
129        if self.drawdowns_sq.len() == self.period {
130            self.sum_sq -= self.drawdowns_sq.pop_front().expect("window is non-empty");
131        }
132        self.drawdowns_sq.push_back(sq);
133        self.sum_sq += sq;
134        if self.drawdowns_sq.len() < self.period {
135            return None;
136        }
137        let ui = (self.sum_sq / self.period as f64).sqrt();
138        self.last = Some(ui);
139        Some(ui)
140    }
141
142    fn reset(&mut self) {
143        self.count = 0;
144        self.max_dq.clear();
145        self.drawdowns_sq.clear();
146        self.sum_sq = 0.0;
147        self.last = None;
148    }
149
150    fn warmup_period(&self) -> usize {
151        // `period` inputs fill the trailing-max window; the first drawdown is
152        // computable on bar `period` (the window is full for the first time);
153        // another `period - 1` drawdowns then fill the RMS window. The two
154        // windows overlap by one bar, so `warmup_period() == 2 * period - 1`.
155        2 * self.period - 1
156    }
157
158    fn is_ready(&self) -> bool {
159        self.last.is_some()
160    }
161
162    fn name(&self) -> &'static str {
163        "UlcerIndex"
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::traits::BatchExt;
171    use approx::assert_relative_eq;
172
173    #[test]
174    fn new_rejects_zero_period() {
175        assert!(matches!(UlcerIndex::new(0), Err(Error::PeriodZero)));
176    }
177
178    /// Cover the const accessors `period` / `value` (lines 77-85) and the
179    /// Indicator-impl `name` body (162-164). `warmup_period` is covered
180    /// already by `reference_values`.
181    #[test]
182    fn accessors_and_metadata() {
183        let mut ui = UlcerIndex::new(14).unwrap();
184        assert_eq!(ui.period(), 14);
185        assert_eq!(ui.name(), "UlcerIndex");
186        assert_eq!(ui.value(), None);
187        // Drive past warmup so value() flips to Some.
188        for i in 0..ui.warmup_period() {
189            ui.update(100.0 + (i as f64).sin() * 5.0);
190        }
191        assert!(ui.value().is_some());
192    }
193
194    /// Cover the `max_price == 0.0` defensive branch (line 123). All
195    /// other tests use prices > 0, so the trailing-max divisor is always
196    /// positive. Feed a stream of zeros — the trailing max is exactly
197    /// 0.0 and the drawdown computation would otherwise hit a 0/0 NaN.
198    /// The indicator must emit exactly 0.0 (drawdown is 0% by convention).
199    #[test]
200    fn zero_max_price_yields_zero_drawdown() {
201        let mut ui = UlcerIndex::new(3).unwrap();
202        let out = ui.batch(&[0.0_f64; 10]);
203        let last = out.into_iter().flatten().last().expect("emits");
204        assert_eq!(last, 0.0);
205    }
206
207    #[test]
208    fn reference_values() {
209        // UlcerIndex(2): warmup = 3.
210        // [10, 8, 12, 9]:
211        //   bar 3: window [8,12], max 12, drawdown 0; sq window [400, 0]
212        //          -> UI = sqrt(200).
213        //   bar 4: window [12,9], max 12, drawdown -25, sq 625; sq window [0, 625]
214        //          -> UI = sqrt(312.5).
215        let mut ui = UlcerIndex::new(2).unwrap();
216        let out = ui.batch(&[10.0, 8.0, 12.0, 9.0]);
217        assert_eq!(ui.warmup_period(), 3);
218        assert_eq!(out[0], None);
219        assert_eq!(out[1], None);
220        assert_relative_eq!(out[2].unwrap(), 200.0_f64.sqrt(), epsilon = 1e-12);
221        assert_relative_eq!(out[3].unwrap(), 312.5_f64.sqrt(), epsilon = 1e-12);
222    }
223
224    #[test]
225    fn pure_uptrend_yields_zero() {
226        // Price never trades below its own running high: no drawdown at all.
227        let mut ui = UlcerIndex::new(5).unwrap();
228        let out = ui.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
229        for v in out.iter().skip(ui.warmup_period() - 1).flatten() {
230            assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
231        }
232    }
233
234    #[test]
235    fn constant_series_yields_zero() {
236        let mut ui = UlcerIndex::new(5).unwrap();
237        let out = ui.batch(&[50.0; 30]);
238        for v in out.iter().skip(ui.warmup_period() - 1).flatten() {
239            assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
240        }
241    }
242
243    #[test]
244    fn output_is_non_negative() {
245        let mut ui = UlcerIndex::new(14).unwrap();
246        let prices: Vec<f64> = (1..=120)
247            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 15.0)
248            .collect();
249        for v in ui.batch(&prices).into_iter().flatten() {
250            assert!(v >= 0.0, "Ulcer Index must be non-negative, got {v}");
251        }
252    }
253
254    #[test]
255    fn ignores_non_finite_input() {
256        let mut ui = UlcerIndex::new(2).unwrap();
257        let out = ui.batch(&[10.0, 8.0, 12.0, 9.0]);
258        let last = *out.last().unwrap();
259        assert!(last.is_some());
260        assert_eq!(ui.update(f64::NAN), last);
261        assert_eq!(ui.update(f64::INFINITY), last);
262    }
263
264    #[test]
265    fn reset_clears_state() {
266        let mut ui = UlcerIndex::new(3).unwrap();
267        ui.batch(&[10.0, 8.0, 12.0, 9.0, 11.0, 7.0]);
268        assert!(ui.is_ready());
269        ui.reset();
270        assert!(!ui.is_ready());
271        assert_eq!(ui.update(10.0), None);
272    }
273
274    #[test]
275    fn batch_equals_streaming() {
276        let prices: Vec<f64> = (1..=80)
277            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
278            .collect();
279        let batch = UlcerIndex::new(14).unwrap().batch(&prices);
280        let mut b = UlcerIndex::new(14).unwrap();
281        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
282        assert_eq!(batch, streamed);
283    }
284
285    /// Monotone-deque equivalence: the O(1) implementation must produce exactly
286    /// the same per-tick values as a naive O(n) trailing-max scan, on inputs
287    /// chosen to exercise every deque-maintenance path:
288    /// strictly increasing (everything is dominated and gets popped),
289    /// strictly decreasing (nothing is popped, head expires when the window
290    /// slides),
291    /// and constants (ties — the `<= input` pop rule keeps a single newest
292    /// entry).
293    #[test]
294    fn monotone_deque_matches_naive_max_on_adversarial_inputs() {
295        fn naive_max(prices: &[f64], period: usize, t: usize) -> f64 {
296            let lo = t + 1 - period;
297            prices[lo..=t]
298                .iter()
299                .copied()
300                .fold(f64::NEG_INFINITY, f64::max)
301        }
302
303        fn check(prices: &[f64], period: usize) {
304            let mut ui = UlcerIndex::new(period).unwrap();
305            for (i, p) in prices.iter().enumerate() {
306                let _ = ui.update(*p);
307                if i + 1 >= period {
308                    let trailing_max = ui.max_dq.front().expect("non-empty").1;
309                    let naive = naive_max(prices, period, i);
310                    assert!(
311                        (trailing_max - naive).abs() < 1e-12,
312                        "trailing max diverges at t={i}: deque={trailing_max}, naive={naive}",
313                    );
314                }
315            }
316        }
317
318        // Strictly increasing — every push pops the entire deque tail.
319        let increasing: Vec<f64> = (1..=50).map(f64::from).collect();
320        check(&increasing, 5);
321        check(&increasing, 14);
322
323        // Strictly decreasing — pushes never pop the tail; the head expires.
324        let decreasing: Vec<f64> = (1..=50).rev().map(f64::from).collect();
325        check(&decreasing, 5);
326        check(&decreasing, 14);
327
328        // All-equal — `back <= input` pops on equality, leaving a length-1
329        // deque containing only the most recent index.
330        let constant = vec![42.0; 50];
331        check(&constant, 5);
332        check(&constant, 14);
333
334        // Mixed sawtooth — exercises every code path.
335        let mixed: Vec<f64> = (0..120)
336            .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 20.0)
337            .collect();
338        check(&mixed, 7);
339        check(&mixed, 30);
340    }
341}