wickra_core/indicators/
autocorrelation_periodogram.rs1#![allow(clippy::doc_markdown)]
3
4use std::collections::VecDeque;
5use std::f64::consts::TAU;
6
7use crate::error::{Error, Result};
8use crate::indicators::roofing_filter::RoofingFilter;
9use crate::traits::Indicator;
10
11const AVG_LENGTH: usize = 3;
13
14#[derive(Debug, Clone)]
54pub struct AutocorrelationPeriodogram {
55 min_period: usize,
56 max_period: usize,
57 roof: RoofingFilter,
58 buffer: VecDeque<f64>,
59 r: Vec<f64>,
60 max_pwr: f64,
61 last: Option<f64>,
62}
63
64impl AutocorrelationPeriodogram {
65 pub fn new(min_period: usize, max_period: usize) -> Result<Self> {
74 if min_period == 0 || max_period == 0 {
75 return Err(Error::PeriodZero);
76 }
77 if min_period < AVG_LENGTH + 1 || max_period <= min_period {
78 return Err(Error::InvalidPeriod {
79 message: "autocorrelation periodogram needs AvgLength < min_period < max_period",
80 });
81 }
82 Ok(Self {
83 min_period,
84 max_period,
85 roof: RoofingFilter::new(10, max_period)?,
86 buffer: VecDeque::with_capacity(max_period + AVG_LENGTH),
87 r: vec![0.0; max_period + 1],
88 max_pwr: 0.0,
89 last: None,
90 })
91 }
92
93 pub const fn periods(&self) -> (usize, usize) {
95 (self.min_period, self.max_period)
96 }
97
98 pub const fn value(&self) -> Option<f64> {
100 self.last
101 }
102
103 fn correlation(&self, lag: usize) -> f64 {
106 let len = self.buffer.len();
107 let filt = |k: usize| self.buffer[len - 1 - k];
108 let m = AVG_LENGTH as f64;
109 let (mut sx, mut sy, mut sxx, mut syy, mut sxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
110 for count in 0..AVG_LENGTH {
111 let x = filt(count);
112 let y = filt(lag + count);
113 sx += x;
114 sy += y;
115 sxx += x * x;
116 syy += y * y;
117 sxy += x * y;
118 }
119 let denom = (m * sxx - sx * sx) * (m * syy - sy * sy);
120 if denom > 0.0 {
121 (m * sxy - sx * sy) / denom.sqrt()
122 } else {
123 0.0
124 }
125 }
126}
127
128impl Indicator for AutocorrelationPeriodogram {
129 type Input = f64;
130 type Output = f64;
131
132 fn update(&mut self, price: f64) -> Option<f64> {
133 if !price.is_finite() {
134 return self.last;
135 }
136 let filt = self.roof.update(price)?;
137 if self.buffer.len() == self.max_period + AVG_LENGTH {
138 self.buffer.pop_front();
139 }
140 self.buffer.push_back(filt);
141 if self.buffer.len() < self.max_period + AVG_LENGTH {
142 return None;
143 }
144
145 let mut corr = vec![0.0; self.max_period + 1];
147 for (lag, c) in corr.iter_mut().enumerate() {
148 *c = self.correlation(lag);
149 }
150
151 self.max_pwr *= 0.995;
153 for period in self.min_period..=self.max_period {
154 let mut cosine = 0.0;
155 let mut sine = 0.0;
156 for (n, &cn) in corr
157 .iter()
158 .enumerate()
159 .take(self.max_period + 1)
160 .skip(AVG_LENGTH)
161 {
162 let angle = TAU * n as f64 / period as f64;
163 cosine += cn * angle.cos();
164 sine += cn * angle.sin();
165 }
166 let power = cosine * cosine + sine * sine;
167 self.r[period] = 0.2 * power + 0.8 * self.r[period];
168 if self.r[period] > self.max_pwr {
169 self.max_pwr = self.r[period];
170 }
171 }
172
173 let mut spx = 0.0;
175 let mut sp = 0.0;
176 for period in self.min_period..=self.max_period {
177 let pwr = if self.max_pwr > 0.0 {
178 self.r[period] / self.max_pwr
179 } else {
180 0.0
181 };
182 if pwr >= 0.5 {
183 spx += period as f64 * pwr;
184 sp += pwr;
185 }
186 }
187 let dominant = if sp > 0.0 {
188 (spx / sp).clamp(self.min_period as f64, self.max_period as f64)
189 } else {
190 self.min_period as f64
191 };
192 self.last = Some(dominant);
193 Some(dominant)
194 }
195
196 fn reset(&mut self) {
197 self.roof.reset();
198 self.buffer.clear();
199 self.r.iter_mut().for_each(|x| *x = 0.0);
200 self.max_pwr = 0.0;
201 self.last = None;
202 }
203
204 fn warmup_period(&self) -> usize {
205 self.max_period + AVG_LENGTH
206 }
207
208 fn is_ready(&self) -> bool {
209 self.last.is_some()
210 }
211
212 fn name(&self) -> &'static str {
213 "AutocorrelationPeriodogram"
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use crate::traits::BatchExt;
221
222 #[test]
223 fn rejects_invalid_periods() {
224 assert!(matches!(
225 AutocorrelationPeriodogram::new(0, 48),
226 Err(Error::PeriodZero)
227 ));
228 assert!(matches!(
229 AutocorrelationPeriodogram::new(3, 48),
230 Err(Error::InvalidPeriod { .. })
231 ));
232 assert!(matches!(
233 AutocorrelationPeriodogram::new(48, 10),
234 Err(Error::InvalidPeriod { .. })
235 ));
236 }
237
238 #[test]
239 fn accessors_and_metadata() {
240 let p = AutocorrelationPeriodogram::new(10, 48).unwrap();
241 assert_eq!(p.periods(), (10, 48));
242 assert_eq!(p.warmup_period(), 51);
243 assert_eq!(p.name(), "AutocorrelationPeriodogram");
244 assert!(!p.is_ready());
245 assert_eq!(p.value(), None);
246 }
247
248 #[test]
249 fn first_emission_at_warmup_period() {
250 let mut p = AutocorrelationPeriodogram::new(8, 20).unwrap();
251 let xs: Vec<f64> = (0..40)
252 .map(|i| 100.0 + (TAU * f64::from(i) / 12.0).sin() * 5.0)
253 .collect();
254 let out = p.batch(&xs);
255 let warmup = p.warmup_period(); assert_eq!(warmup, 23);
257 for v in out.iter().take(warmup - 1) {
258 assert!(v.is_none());
259 }
260 assert!(out[warmup - 1].is_some());
261 }
262
263 #[test]
264 fn output_within_period_band() {
265 let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
266 let xs: Vec<f64> = (0..400)
267 .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
268 .collect();
269 for v in p.batch(&xs).into_iter().flatten() {
270 assert!((10.0..=48.0).contains(&v), "cycle out of band: {v}");
271 }
272 }
273
274 #[test]
275 fn detects_injected_cycle() {
276 let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
278 let xs: Vec<f64> = (0..600)
279 .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
280 .collect();
281 let last = p.batch(&xs).into_iter().flatten().last().unwrap();
282 assert!(
283 (last - 20.0).abs() < 6.0,
284 "expected ~20-bar cycle, got {last}"
285 );
286 }
287
288 #[test]
289 fn ignores_non_finite() {
290 let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
291 p.batch(
292 &(0..80)
293 .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
294 .collect::<Vec<_>>(),
295 );
296 let before = p.value();
297 assert_eq!(p.update(f64::NAN), before);
298 }
299
300 #[test]
301 fn reset_clears_state() {
302 let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
303 p.batch(
304 &(0..120)
305 .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
306 .collect::<Vec<_>>(),
307 );
308 assert!(p.is_ready());
309 p.reset();
310 assert!(!p.is_ready());
311 assert_eq!(p.value(), None);
312 }
313
314 #[test]
315 fn batch_equals_streaming() {
316 let xs: Vec<f64> = (0..200)
317 .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
318 .collect();
319 let batch = AutocorrelationPeriodogram::new(10, 48).unwrap().batch(&xs);
320 let mut b = AutocorrelationPeriodogram::new(10, 48).unwrap();
321 let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
322 assert_eq!(batch, streamed);
323 }
324
325 #[test]
326 fn flat_input_falls_back_to_min_period() {
327 let flat = [100.0_f64; 200];
331 let last = AutocorrelationPeriodogram::new(10, 48)
332 .unwrap()
333 .batch(&flat)
334 .into_iter()
335 .flatten()
336 .last()
337 .unwrap();
338 assert_eq!(last, 10.0);
339 }
340}