wickra_core/indicators/
z_score.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::ShiftedMoments;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
35pub struct ZScore {
36 period: usize,
37 window: VecDeque<f64>,
38 moments: ShiftedMoments,
39}
40
41impl ZScore {
42 pub fn new(period: usize) -> Result<Self> {
47 if period == 0 {
48 return Err(Error::PeriodZero);
49 }
50 if period > crate::error::MAX_PERIOD {
51 return Err(Error::InvalidPeriod {
52 message: crate::error::PERIOD_ABOVE_MAX,
53 });
54 }
55 Ok(Self {
56 period,
57 window: VecDeque::with_capacity(period),
58 moments: ShiftedMoments::new(),
59 })
60 }
61
62 pub const fn period(&self) -> usize {
64 self.period
65 }
66}
67
68impl Indicator for ZScore {
69 type Input = f64;
70 type Output = f64;
71
72 #[inline]
73 fn update(&mut self, value: f64) -> Option<f64> {
74 if !value.is_finite() {
75 return None;
76 }
77 if self.window.len() == self.period {
78 let old = self.window.pop_front().expect("non-empty");
79 self.moments.evict(old);
80 }
81 self.window.push_back(value);
82 self.moments.push(value);
83 if self.moments.needs_reseed(self.period) {
84 self.moments.reseed(self.window.iter().copied());
85 }
86 if self.window.len() < self.period {
87 return None;
88 }
89 let mean = self.moments.mean(self.period);
90 let std = self.moments.std_dev(self.period);
91 if std == 0.0 {
92 return Some(0.0);
94 }
95 Some((value - mean) / std)
96 }
97
98 fn reset(&mut self) {
99 self.window.clear();
100 self.moments.reset();
101 }
102
103 #[inline]
104 fn warmup_period(&self) -> usize {
105 self.period
106 }
107
108 #[inline]
109 fn is_ready(&self) -> bool {
110 self.window.len() == self.period
111 }
112
113 #[inline]
114 fn name(&self) -> &'static str {
115 "ZScore"
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use crate::traits::BatchExt;
123 use approx::assert_relative_eq;
124
125 #[test]
126 fn reference_values() {
127 let mut z = ZScore::new(2).unwrap();
130 let out = z.batch(&[1.0, 3.0]);
131 assert!(out[0].is_none());
132 assert_relative_eq!(out[1].unwrap(), 1.0, epsilon = 1e-12);
133 }
134
135 #[test]
136 fn constant_series_yields_zero() {
137 let mut z = ZScore::new(10).unwrap();
138 for v in z.batch(&[42.0; 30]).into_iter().flatten() {
139 assert_relative_eq!(v, 0.0, epsilon = 1e-12);
140 }
141 }
142
143 #[test]
144 fn rising_price_is_above_its_mean() {
145 let prices: Vec<f64> = (0..40).map(f64::from).collect();
147 let mut z = ZScore::new(10).unwrap();
148 for v in z.batch(&prices).into_iter().flatten() {
149 assert!(
150 v > 0.0,
151 "a rising price should score above its mean, got {v}"
152 );
153 }
154 }
155
156 #[test]
157 fn first_value_on_period_th_input() {
158 let mut z = ZScore::new(5).unwrap();
159 let out = z.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
160 for (i, v) in out.iter().enumerate().take(4) {
161 assert!(v.is_none(), "index {i} must be None during warmup");
162 }
163 assert!(out[4].is_some(), "first value lands at index period - 1");
164 assert_eq!(z.warmup_period(), 5);
165 }
166
167 #[test]
168 fn rejects_zero_period() {
169 assert!(ZScore::new(0).is_err());
170 }
171
172 #[test]
175 fn accessors_and_metadata() {
176 let z = ZScore::new(20).unwrap();
177 assert_eq!(z.period(), 20);
178 assert_eq!(z.name(), "ZScore");
179 }
180
181 #[test]
182 fn reset_clears_state() {
183 let mut z = ZScore::new(5).unwrap();
184 z.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
185 assert!(z.is_ready());
186 z.reset();
187 assert!(!z.is_ready());
188 assert_eq!(z.update(1.0), None);
189 }
190
191 #[test]
192 fn batch_equals_streaming() {
193 let prices: Vec<f64> = (0..60)
194 .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
195 .collect();
196 let mut a = ZScore::new(20).unwrap();
197 let mut b = ZScore::new(20).unwrap();
198 assert_eq!(
199 a.batch(&prices),
200 prices.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
201 );
202 }
203}