quant-indicators 0.7.0

Pure indicator math library for trading — MA, RSI, Bollinger, MACD, ATR, HRP
Documentation
//! Volume anomaly (VolumeSignal) indicator.
//!
//! Detects abnormal volume activity relative to a rolling baseline.
//! Implements [`ClassificationIndicator<VolumeSignal>`] — returns `None`
//! for candles within the warmup window, `Some(VolumeSignal)` thereafter.

use std::fmt;

use quant_primitives::Candle;
use rust_decimal::Decimal;

use crate::error::IndicatorError;
use crate::indicator::ClassificationIndicator;

/// Ratio below which volume is classified as Subdued (0.5).
const SUBDUED_THRESHOLD: Decimal = Decimal::from_parts(5, 0, 0, false, 1);

/// Classification of volume activity relative to rolling baseline.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VolumeAnomaly {
    /// Volume is significantly below the rolling average (ratio < 0.5).
    Subdued,
    /// Volume is within the normal range.
    Normal,
    /// Volume is significantly above the rolling average (ratio >= elevated_threshold).
    Elevated,
}

impl fmt::Display for VolumeAnomaly {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            VolumeAnomaly::Subdued => f.write_str("Subdued"),
            VolumeAnomaly::Normal => f.write_str("Normal"),
            VolumeAnomaly::Elevated => f.write_str("Elevated"),
        }
    }
}

/// Output of the VolumeSignal indicator for a single candle.
#[derive(Debug, Clone)]
pub struct VolumeSignal {
    /// Classification of the volume anomaly.
    pub anomaly: VolumeAnomaly,
    /// Ratio of candle volume to rolling mean (candle.volume / mean).
    pub ratio: Decimal,
}

/// Configuration for the VolumeSignal indicator.
pub struct VolSignalConfig {
    /// Number of prior candles used to compute the rolling mean.
    pub lookback: usize,
    /// Volume ratio at or above which volume is classified as Elevated.
    pub elevated_threshold: Decimal,
}

/// Volume anomaly indicator.
///
/// Classifies each candle's volume relative to a rolling mean of the prior
/// `lookback` candles. Returns `None` until sufficient history is available.
///
/// Implements [`ClassificationIndicator<VolumeSignal>`].
pub struct VolumeSignalIndicator {
    config: VolSignalConfig,
}

impl VolumeSignalIndicator {
    /// Create a new VolumeSignal indicator.
    ///
    /// # Errors
    ///
    /// Returns `InvalidParameter` if `lookback` is 0 or `elevated_threshold` is not positive.
    pub fn new(config: VolSignalConfig) -> Result<Self, IndicatorError> {
        if config.lookback == 0 {
            return Err(IndicatorError::InvalidParameter {
                message: "VolumeSignal lookback must be > 0".to_string(),
            });
        }
        if config.elevated_threshold <= Decimal::ZERO {
            return Err(IndicatorError::InvalidParameter {
                message: "VolumeSignal elevated_threshold must be positive".to_string(),
            });
        }
        Ok(Self { config })
    }

    /// Convenience wrapper — delegates to the [`ClassificationIndicator`] trait impl.
    ///
    /// Callers that hold a concrete `VolumeSignalIndicator` can call `.compute()`
    /// without importing the trait.
    pub fn compute(&self, candles: &[Candle]) -> Result<Vec<Option<VolumeSignal>>, IndicatorError> {
        <Self as ClassificationIndicator<VolumeSignal>>::compute(self, candles)
    }
}

impl ClassificationIndicator<VolumeSignal> for VolumeSignalIndicator {
    fn lookback(&self) -> usize {
        self.config.lookback
    }

    fn compute(&self, candles: &[Candle]) -> Result<Vec<Option<VolumeSignal>>, IndicatorError> {
        let mut results = Vec::with_capacity(candles.len());

        for i in 0..candles.len() {
            if i < self.config.lookback {
                results.push(None);
                continue;
            }

            // Rolling mean of the prior `lookback` candles (exclusive of current).
            let window = &candles[(i - self.config.lookback)..i];
            let sum: Decimal = window.iter().map(Candle::volume).sum();
            let mean = sum / Decimal::from(self.config.lookback as u64);

            let ratio = if mean.is_zero() {
                Decimal::ONE
            } else {
                candles[i].volume() / mean
            };

            let anomaly = if ratio >= self.config.elevated_threshold {
                VolumeAnomaly::Elevated
            } else if ratio < SUBDUED_THRESHOLD {
                VolumeAnomaly::Subdued
            } else {
                VolumeAnomaly::Normal
            };

            results.push(Some(VolumeSignal { anomaly, ratio }));
        }

        Ok(results)
    }
}

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