quant_indicators/rolling_zscore.rs
1//! Rolling Z-Score indicator.
2//!
3//! Computes `(value - rolling_mean) / rolling_stddev` over a configurable
4//! sliding window of scalar observations.
5
6use std::collections::VecDeque;
7
8use rust_decimal::Decimal;
9
10use crate::error::IndicatorError;
11use crate::stddev::decimal_sqrt;
12
13/// Rolling z-score over a sliding window of scalar observations.
14///
15/// Feeds individual `Decimal` values via [`update`](Self::update) and returns
16/// the current z-score via [`value`](Self::value).
17///
18/// Returns `None` when the window is not yet filled or when the standard
19/// deviation is zero (all values identical).
20///
21/// # Example
22///
23/// ```
24/// use quant_indicators::RollingZScore;
25/// use rust_decimal_macros::dec;
26///
27/// let mut zs = RollingZScore::new(3).unwrap();
28/// zs.update(dec!(1));
29/// zs.update(dec!(2));
30/// assert!(zs.value().is_none()); // window not filled
31/// zs.update(dec!(3));
32/// assert!(zs.value().is_some()); // z-score available
33/// ```
34#[derive(Debug, Clone)]
35pub struct RollingZScore {
36 window: usize,
37 values: VecDeque<Decimal>,
38}
39
40impl RollingZScore {
41 /// Create a new rolling z-score with the given window size.
42 ///
43 /// # Errors
44 ///
45 /// Returns `InvalidParameter` if `window` is 0 or 1 (z-score requires
46 /// at least 2 observations for variance).
47 #[must_use = "returns Result that may contain an error"]
48 pub fn new(window: usize) -> Result<Self, IndicatorError> {
49 if window <= 1 {
50 return Err(IndicatorError::InvalidParameter {
51 message: format!("RollingZScore window must be > 1, got {}", window),
52 });
53 }
54 Ok(Self {
55 window,
56 values: VecDeque::with_capacity(window),
57 })
58 }
59
60 /// Feed a new observation into the rolling window.
61 ///
62 /// If the window is full, the oldest observation is evicted.
63 pub fn update(&mut self, value: Decimal) {
64 if self.values.len() == self.window {
65 self.values.pop_front();
66 }
67 self.values.push_back(value);
68 }
69
70 /// Return the current z-score, or `None` if the window is not yet
71 /// filled or the standard deviation is zero.
72 #[must_use]
73 pub fn value(&self) -> Option<Decimal> {
74 if self.values.len() < self.window {
75 return None;
76 }
77
78 let n = Decimal::from(self.window as u64);
79 let sum: Decimal = self.values.iter().copied().sum();
80 let mean = sum / n;
81
82 let variance_sum: Decimal = self
83 .values
84 .iter()
85 .map(|v| {
86 let diff = *v - mean;
87 diff * diff
88 })
89 .sum();
90
91 let variance = variance_sum / n;
92 let stddev = decimal_sqrt(variance);
93
94 if stddev.is_zero() {
95 return None;
96 }
97
98 let latest = self.values.back()?;
99 Some((*latest - mean) / stddev)
100 }
101}
102
103#[cfg(test)]
104#[path = "rolling_zscore_tests.rs"]
105mod tests;