Skip to main content

quant_indicators/
volume_signal.rs

1//! Volume anomaly (VolumeSignal) indicator.
2//!
3//! Detects abnormal volume activity relative to a rolling baseline.
4//! Implements [`ClassificationIndicator<VolumeSignal>`] — returns `None`
5//! for candles within the warmup window, `Some(VolumeSignal)` thereafter.
6
7use std::fmt;
8
9use quant_primitives::Candle;
10use rust_decimal::Decimal;
11
12use crate::error::IndicatorError;
13use crate::indicator::ClassificationIndicator;
14
15/// Ratio below which volume is classified as Subdued (0.5).
16const SUBDUED_THRESHOLD: Decimal = Decimal::from_parts(5, 0, 0, false, 1);
17
18/// Classification of volume activity relative to rolling baseline.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum VolumeAnomaly {
21    /// Volume is significantly below the rolling average (ratio < 0.5).
22    Subdued,
23    /// Volume is within the normal range.
24    Normal,
25    /// Volume is significantly above the rolling average (ratio >= elevated_threshold).
26    Elevated,
27}
28
29impl fmt::Display for VolumeAnomaly {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            VolumeAnomaly::Subdued => f.write_str("Subdued"),
33            VolumeAnomaly::Normal => f.write_str("Normal"),
34            VolumeAnomaly::Elevated => f.write_str("Elevated"),
35        }
36    }
37}
38
39/// Output of the VolumeSignal indicator for a single candle.
40#[derive(Debug, Clone)]
41pub struct VolumeSignal {
42    /// Classification of the volume anomaly.
43    pub anomaly: VolumeAnomaly,
44    /// Ratio of candle volume to rolling mean (candle.volume / mean).
45    pub ratio: Decimal,
46}
47
48/// Configuration for the VolumeSignal indicator.
49pub struct VolSignalConfig {
50    /// Number of prior candles used to compute the rolling mean.
51    pub lookback: usize,
52    /// Volume ratio at or above which volume is classified as Elevated.
53    pub elevated_threshold: Decimal,
54}
55
56/// Volume anomaly indicator.
57///
58/// Classifies each candle's volume relative to a rolling mean of the prior
59/// `lookback` candles. Returns `None` until sufficient history is available.
60///
61/// Implements [`ClassificationIndicator<VolumeSignal>`].
62pub struct VolumeSignalIndicator {
63    config: VolSignalConfig,
64}
65
66impl VolumeSignalIndicator {
67    /// Create a new VolumeSignal indicator.
68    ///
69    /// # Errors
70    ///
71    /// Returns `InvalidParameter` if `lookback` is 0 or `elevated_threshold` is not positive.
72    pub fn new(config: VolSignalConfig) -> Result<Self, IndicatorError> {
73        if config.lookback == 0 {
74            return Err(IndicatorError::InvalidParameter {
75                message: "VolumeSignal lookback must be > 0".to_string(),
76            });
77        }
78        if config.elevated_threshold <= Decimal::ZERO {
79            return Err(IndicatorError::InvalidParameter {
80                message: "VolumeSignal elevated_threshold must be positive".to_string(),
81            });
82        }
83        Ok(Self { config })
84    }
85
86    /// Convenience wrapper — delegates to the [`ClassificationIndicator`] trait impl.
87    ///
88    /// Callers that hold a concrete `VolumeSignalIndicator` can call `.compute()`
89    /// without importing the trait.
90    pub fn compute(&self, candles: &[Candle]) -> Result<Vec<Option<VolumeSignal>>, IndicatorError> {
91        <Self as ClassificationIndicator<VolumeSignal>>::compute(self, candles)
92    }
93}
94
95impl ClassificationIndicator<VolumeSignal> for VolumeSignalIndicator {
96    fn lookback(&self) -> usize {
97        self.config.lookback
98    }
99
100    fn compute(&self, candles: &[Candle]) -> Result<Vec<Option<VolumeSignal>>, IndicatorError> {
101        let mut results = Vec::with_capacity(candles.len());
102
103        for i in 0..candles.len() {
104            if i < self.config.lookback {
105                results.push(None);
106                continue;
107            }
108
109            // Rolling mean of the prior `lookback` candles (exclusive of current).
110            let window = &candles[(i - self.config.lookback)..i];
111            let sum: Decimal = window.iter().map(Candle::volume).sum();
112            let mean = sum / Decimal::from(self.config.lookback as u64);
113
114            let ratio = if mean.is_zero() {
115                Decimal::ONE
116            } else {
117                candles[i].volume() / mean
118            };
119
120            let anomaly = if ratio >= self.config.elevated_threshold {
121                VolumeAnomaly::Elevated
122            } else if ratio < SUBDUED_THRESHOLD {
123                VolumeAnomaly::Subdued
124            } else {
125                VolumeAnomaly::Normal
126            };
127
128            results.push(Some(VolumeSignal { anomaly, ratio }));
129        }
130
131        Ok(results)
132    }
133}
134
135#[cfg(test)]
136#[path = "volume_signal_tests.rs"]
137mod tests;