kestrel_chartkit/indicator/
zscore.rs1use super::{Indicator, IndicatorAlert, IndicatorOutput};
2use crate::model::Bar;
3use crate::stats::{rolling_mean, rolling_stddev};
4use std::collections::VecDeque;
5
6#[derive(Debug, Clone)]
9pub struct ZScoreEngine {
10 period: usize,
11 window: VecDeque<f64>,
12}
13
14impl ZScoreEngine {
15 pub fn new(period: usize) -> Self {
16 Self {
17 period: period.max(2),
18 window: VecDeque::with_capacity(period),
19 }
20 }
21}
22
23impl Indicator for ZScoreEngine {
24 fn name(&self) -> &str {
25 "zscore"
26 }
27
28 fn warmup_period(&self) -> usize {
29 self.period
30 }
31
32 fn reset(&mut self) {
33 self.window.clear();
34 }
35
36 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
37 self.window.push_back(bar.close);
38 if self.window.len() > self.period {
39 self.window.pop_front();
40 }
41
42 if self.window.len() < self.period {
43 return None;
44 }
45
46 let slice: Vec<f64> = self.window.iter().copied().collect();
47 let mean = rolling_mean(&slice);
48 let stddev = rolling_stddev(&slice);
49
50 let z = if stddev > 1e-8 {
51 (bar.close - mean) / stddev
52 } else {
53 0.0
54 };
55
56 Some(IndicatorOutput::new(z))
57 }
58
59 fn alerts(&self) -> Vec<IndicatorAlert> {
60 Vec::new()
61 }
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67
68 #[test]
69 fn test_zscore_calculation() {
70 let mut zsec = ZScoreEngine::new(5);
71 for i in 0..5 {
72 let b = Bar::new(i, 100.0, 105.0, 95.0, 100.0 + i as f64 * 10.0, 1000.0);
73 zsec.on_bar(&b);
74 }
75 let out = zsec
76 .on_bar(&Bar::new(5, 100.0, 105.0, 95.0, 140.0, 1000.0))
77 .unwrap();
78 assert!(out.value > 0.0);
79 }
80}