wickra_core/indicators/
ulcer_index.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
43pub struct UlcerIndex {
44 period: usize,
45 count: u64,
48 max_dq: VecDeque<(u64, f64)>,
51 drawdowns_sq: VecDeque<f64>,
53 sum_sq: f64,
54 last: Option<f64>,
55}
56
57impl UlcerIndex {
58 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 pub const fn period(&self) -> usize {
79 self.period
80 }
81
82 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 return self.last;
96 }
97 self.count += 1;
98 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 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 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 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 #[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 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 #[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 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 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 #[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 let increasing: Vec<f64> = (1..=50).map(f64::from).collect();
320 check(&increasing, 5);
321 check(&increasing, 14);
322
323 let decreasing: Vec<f64> = (1..=50).rev().map(f64::from).collect();
325 check(&decreasing, 5);
326 check(&decreasing, 14);
327
328 let constant = vec![42.0; 50];
331 check(&constant, 5);
332 check(&constant, 14);
333
334 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}