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