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