quant-indicators 0.7.0

Pure indicator math library for trading — MA, RSI, Bollinger, MACD, ATR, HRP
Documentation
//! Rolling Z-Score indicator.
//!
//! Computes `(value - rolling_mean) / rolling_stddev` over a configurable
//! sliding window of scalar observations.

use std::collections::VecDeque;

use rust_decimal::Decimal;

use crate::error::IndicatorError;
use crate::stddev::decimal_sqrt;

/// Rolling z-score over a sliding window of scalar observations.
///
/// Feeds individual `Decimal` values via [`update`](Self::update) and returns
/// the current z-score via [`value`](Self::value).
///
/// Returns `None` when the window is not yet filled or when the standard
/// deviation is zero (all values identical).
///
/// # Example
///
/// ```
/// use quant_indicators::RollingZScore;
/// use rust_decimal_macros::dec;
///
/// let mut zs = RollingZScore::new(3).unwrap();
/// zs.update(dec!(1));
/// zs.update(dec!(2));
/// assert!(zs.value().is_none()); // window not filled
/// zs.update(dec!(3));
/// assert!(zs.value().is_some()); // z-score available
/// ```
#[derive(Debug, Clone)]
pub struct RollingZScore {
    window: usize,
    values: VecDeque<Decimal>,
}

impl RollingZScore {
    /// Create a new rolling z-score with the given window size.
    ///
    /// # Errors
    ///
    /// Returns `InvalidParameter` if `window` is 0 or 1 (z-score requires
    /// at least 2 observations for variance).
    #[must_use = "returns Result that may contain an error"]
    pub fn new(window: usize) -> Result<Self, IndicatorError> {
        if window <= 1 {
            return Err(IndicatorError::InvalidParameter {
                message: format!("RollingZScore window must be > 1, got {}", window),
            });
        }
        Ok(Self {
            window,
            values: VecDeque::with_capacity(window),
        })
    }

    /// Feed a new observation into the rolling window.
    ///
    /// If the window is full, the oldest observation is evicted.
    pub fn update(&mut self, value: Decimal) {
        if self.values.len() == self.window {
            self.values.pop_front();
        }
        self.values.push_back(value);
    }

    /// Return the current z-score, or `None` if the window is not yet
    /// filled or the standard deviation is zero.
    #[must_use]
    pub fn value(&self) -> Option<Decimal> {
        if self.values.len() < self.window {
            return None;
        }

        let n = Decimal::from(self.window as u64);
        let sum: Decimal = self.values.iter().copied().sum();
        let mean = sum / n;

        let variance_sum: Decimal = self
            .values
            .iter()
            .map(|v| {
                let diff = *v - mean;
                diff * diff
            })
            .sum();

        let variance = variance_sum / n;
        let stddev = decimal_sqrt(variance);

        if stddev.is_zero() {
            return None;
        }

        let latest = self.values.back()?;
        Some((*latest - mean) / stddev)
    }
}

#[cfg(test)]
#[path = "rolling_zscore_tests.rs"]
mod tests;