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}