Skip to main content

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;