wickra_core/indicators/
ulcer_index.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::RollingSum;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
44pub struct UlcerIndex {
45 period: usize,
46 count: u64,
49 max_dq: VecDeque<(u64, f64)>,
52 drawdowns_sq: VecDeque<f64>,
54 sum_sq: RollingSum,
55 last: Option<f64>,
56}
57
58impl UlcerIndex {
59 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 pub const fn period(&self) -> usize {
85 self.period
86 }
87
88 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 return None;
102 }
103 self.count += 1;
104 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 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 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 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 #[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 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 #[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 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 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 #[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 let increasing: Vec<f64> = (1..=50).map(f64::from).collect();
333 check(&increasing, 5);
334 check(&increasing, 14);
335
336 let decreasing: Vec<f64> = (1..=50).rev().map(f64::from).collect();
338 check(&decreasing, 5);
339 check(&decreasing, 14);
340
341 let constant = vec![42.0; 50];
344 check(&constant, 5);
345 check(&constant, 14);
346
347 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}