wickra_core/indicators/
detrended_std_dev.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
53pub struct DetrendedStdDev {
54 period: usize,
55 window: VecDeque<f64>,
56 denom: f64,
58}
59
60impl DetrendedStdDev {
61 pub fn new(period: usize) -> Result<Self> {
67 if period < 2 {
68 return Err(Error::InvalidPeriod {
69 message: "detrended stddev needs period >= 2",
70 });
71 }
72 if period > crate::error::MAX_PERIOD {
73 return Err(Error::InvalidPeriod {
74 message: crate::error::PERIOD_ABOVE_MAX,
75 });
76 }
77 let n = period as f64;
78 let sum_x = n * (n - 1.0) / 2.0;
79 let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
80 Ok(Self {
81 period,
82 window: VecDeque::with_capacity(period),
83 denom: n * sum_xx - sum_x * sum_x,
84 })
85 }
86
87 pub const fn period(&self) -> usize {
89 self.period
90 }
91}
92
93impl Indicator for DetrendedStdDev {
94 type Input = f64;
95 type Output = f64;
96
97 #[inline]
98 fn update(&mut self, value: f64) -> Option<f64> {
99 if !value.is_finite() {
100 return None;
101 }
102 if self.window.len() == self.period {
103 self.window.pop_front();
104 }
105 self.window.push_back(value);
106
107 if self.window.len() < self.period {
108 return None;
109 }
110 let n = self.period as f64;
111 let mean_x = (n - 1.0) / 2.0;
116 let s_xx = self.denom / n;
119 let anchor = *self.window.front().expect("the window is full");
125 let mut sum_z = 0.0;
126 for &y in &self.window {
127 sum_z += y - anchor;
128 }
129 let mean_z = sum_z / n;
130 let mut sum_xz = 0.0;
131 for (i, &y) in self.window.iter().enumerate() {
132 sum_xz += (i as f64 - mean_x) * (y - anchor - mean_z);
133 }
134 let slope = sum_xz / s_xx;
135 let mut rss = 0.0;
136 for (i, &y) in self.window.iter().enumerate() {
137 let residual = (y - anchor - mean_z) - slope * (i as f64 - mean_x);
138 rss += residual * residual;
139 }
140 Some((rss / n).sqrt())
141 }
142
143 fn reset(&mut self) {
144 self.window.clear();
145 }
146
147 #[inline]
148 fn warmup_period(&self) -> usize {
149 self.period
150 }
151
152 #[inline]
153 fn is_ready(&self) -> bool {
154 self.window.len() == self.period
155 }
156
157 #[inline]
158 fn name(&self) -> &'static str {
159 "DetrendedStdDev"
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use crate::traits::BatchExt;
167 use approx::assert_relative_eq;
168
169 #[test]
170 fn rejects_period_below_two() {
171 assert!(DetrendedStdDev::new(0).is_err());
172 assert!(DetrendedStdDev::new(1).is_err());
173 assert!(DetrendedStdDev::new(2).is_ok());
174 }
175
176 #[test]
177 fn accessors_and_metadata() {
178 let d = DetrendedStdDev::new(14).unwrap();
179 assert_eq!(d.period(), 14);
180 assert_eq!(d.warmup_period(), 14);
181 assert_eq!(d.name(), "DetrendedStdDev");
182 }
183
184 #[test]
185 fn perfect_line_has_zero_residual() {
186 let prices: Vec<f64> = (0..30).map(|i| 2.0 * f64::from(i) + 5.0).collect();
188 let mut d = DetrendedStdDev::new(10).unwrap();
189 for v in d.batch(&prices).into_iter().flatten() {
190 assert_relative_eq!(v, 0.0, epsilon = 1e-9);
191 }
192 }
193
194 #[test]
195 fn constant_series_yields_zero() {
196 let mut d = DetrendedStdDev::new(5).unwrap();
197 for v in d.batch(&[42.0; 20]).into_iter().flatten() {
198 assert_relative_eq!(v, 0.0, epsilon = 1e-9);
199 }
200 }
201
202 #[test]
203 fn never_exceeds_stddev() {
204 let prices: Vec<f64> = (0..60)
208 .map(|i| 50.0 + f64::from(i) * 0.5 + (f64::from(i) * 0.7).sin() * 4.0)
209 .collect();
210 let mut d = DetrendedStdDev::new(14).unwrap();
211 let mut sd = crate::StdDev::new(14).unwrap();
212 for &p in &prices {
213 let (dv, sv) = (d.update(p), sd.update(p));
214 assert_eq!(dv.is_some(), sv.is_some());
215 if let (Some(dv), Some(sv)) = (dv, sv) {
216 assert!(dv <= sv + 1e-9, "detrended {dv} should be <= stddev {sv}");
217 }
218 }
219 }
220
221 #[test]
222 fn reset_clears_state() {
223 let mut d = DetrendedStdDev::new(5).unwrap();
224 d.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
225 assert!(d.is_ready());
226 d.reset();
227 assert!(!d.is_ready());
228 assert_eq!(d.update(1.0), None);
229 }
230
231 #[test]
232 fn batch_equals_streaming() {
233 let prices: Vec<f64> = (0..60)
234 .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 10.0)
235 .collect();
236 let batch = DetrendedStdDev::new(14).unwrap().batch(&prices);
237 let mut b = DetrendedStdDev::new(14).unwrap();
238 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
239 assert_eq!(batch, streamed);
240 }
241
242 fn centred_fit(window: &[f64]) -> (f64, f64, f64) {
250 let n = window.len() as f64;
251 let mean = window.iter().sum::<f64>() / n;
252 let mean_x = (n - 1.0) / 2.0;
253 let (mut sxy, mut sxx) = (0.0, 0.0);
254 for (i, &y) in window.iter().enumerate() {
255 let dx = i as f64 - mean_x;
256 sxy += dx * (y - mean);
257 sxx += dx * dx;
258 }
259 let slope = sxy / sxx;
260 let mut sse = 0.0;
261 for (i, &y) in window.iter().enumerate() {
262 let r = (y - mean) - slope * (i as f64 - mean_x);
263 sse += r * r;
264 }
265 (slope, mean, sse)
266 }
267
268 fn high_level_series(bars: usize) -> Vec<f64> {
272 (0..bars)
273 .map(|i| {
274 let t = i as f64;
275 1e8 + ((t * 0.11).sin() + 0.4 * (t * 0.37).cos())
276 })
277 .collect()
278 }
279
280 #[test]
286 fn deviation_at_a_high_price_level_does_not_collapse() {
287 const P: usize = 20;
288 let data = high_level_series(400);
289 let mut ind = DetrendedStdDev::new(P).unwrap();
290 let mut compared = 0_usize;
291 for (i, &v) in data.iter().enumerate() {
292 let Some(sigma) = ind.update(v) else { continue };
293 let (_, _, sse) = centred_fit(&data[i + 1 - P..=i]);
294 assert!(sigma > 0.0, "collapsed to zero at bar {i}");
295 compared += 1;
296 assert_relative_eq!(sigma, (sse / P as f64).sqrt(), max_relative = 1e-9);
297 }
298 assert_eq!(compared, data.len() - ind.warmup_period() + 1);
299 }
300 #[test]
306 fn a_near_perfect_fit_still_reports_a_meaningful_deviation() {
307 const P: usize = 20;
308 const BARS: usize = 240;
309 const WOBBLE: f64 = 1e-8;
310
311 let data: Vec<f64> = (0..BARS)
312 .map(|i| {
313 let t = i as f64;
314 100.0 + 0.05 * t + WOBBLE * ((t * 1.7).sin() + 0.3 * (t * 0.41).cos())
315 })
316 .collect();
317
318 let mut ind = DetrendedStdDev::new(P).unwrap();
319 let mean_x = (P as f64 - 1.0) / 2.0;
320 let mut compared = 0_usize;
321 for (i, &v) in data.iter().enumerate() {
322 let Some(sigma) = ind.update(v) else { continue };
323 let window = &data[i + 1 - P..=i];
324 let mean = window.iter().sum::<f64>() / P as f64;
325 let (mut sxy, mut sxx) = (0.0, 0.0);
326 for (j, &y) in window.iter().enumerate() {
327 let dx = j as f64 - mean_x;
328 sxy += dx * (y - mean);
329 sxx += dx * dx;
330 }
331 let slope = sxy / sxx;
332 let sse: f64 = window
333 .iter()
334 .enumerate()
335 .map(|(j, &y)| {
336 let r = (y - mean) - slope * (j as f64 - mean_x);
337 r * r
338 })
339 .sum();
340 assert!(sigma > 0.0, "collapsed to zero at bar {i}");
341 compared += 1;
342 assert_relative_eq!(sigma, (sse / P as f64).sqrt(), max_relative = 1e-6);
343 }
344 assert_eq!(compared, data.len() - ind.warmup_period() + 1);
345 }
346}