Skip to main content

fin_primitives/signals/indicators/
normalized_volume.rs

1//! Normalized Volume indicator — volume relative to its moving average.
2
3use crate::error::FinError;
4use crate::signals::{BarInput, Signal, SignalValue};
5use rust_decimal::Decimal;
6use std::collections::VecDeque;
7
8/// Normalized Volume — divides the current bar's volume by the SMA of volume
9/// over the last `period` bars.
10///
11/// A value of `1.0` means volume equals its average. Values above `1.0` indicate
12/// above-average volume; values below `1.0` indicate below-average volume.
13///
14/// Returns [`SignalValue::Unavailable`] until `period` bars have been seen, or when the
15/// average volume is zero (e.g. all bars have zero volume).
16///
17/// # Example
18/// ```rust
19/// use fin_primitives::signals::indicators::NormalizedVolume;
20/// use fin_primitives::signals::Signal;
21/// let nv = NormalizedVolume::new("nvol_20", 20).unwrap();
22/// assert_eq!(nv.period(), 20);
23/// ```
24pub struct NormalizedVolume {
25    name: String,
26    period: usize,
27    volumes: VecDeque<Decimal>,
28}
29
30impl NormalizedVolume {
31    /// Constructs a new `NormalizedVolume`.
32    ///
33    /// # Errors
34    /// Returns [`FinError::InvalidPeriod`] if `period == 0`.
35    pub fn new(name: impl Into<String>, period: usize) -> Result<Self, FinError> {
36        if period == 0 {
37            return Err(FinError::InvalidPeriod(period));
38        }
39        Ok(Self {
40            name: name.into(),
41            period,
42            volumes: VecDeque::with_capacity(period),
43        })
44    }
45}
46
47impl Signal for NormalizedVolume {
48    fn name(&self) -> &str {
49        &self.name
50    }
51
52    fn period(&self) -> usize {
53        self.period
54    }
55
56    fn is_ready(&self) -> bool {
57        self.volumes.len() >= self.period
58    }
59
60    fn update(&mut self, bar: &BarInput) -> Result<SignalValue, FinError> {
61        self.volumes.push_back(bar.volume);
62        if self.volumes.len() > self.period {
63            self.volumes.pop_front();
64        }
65        if self.volumes.len() < self.period {
66            return Ok(SignalValue::Unavailable);
67        }
68
69        #[allow(clippy::cast_possible_truncation)]
70        let period_d = Decimal::from(self.period as u32);
71        let sum: Decimal = self.volumes.iter().copied().sum();
72        let avg = sum
73            .checked_div(period_d)
74            .ok_or(FinError::ArithmeticOverflow)?;
75
76        if avg.is_zero() {
77            return Ok(SignalValue::Unavailable);
78        }
79
80        let ratio = bar
81            .volume
82            .checked_div(avg)
83            .ok_or(FinError::ArithmeticOverflow)?;
84        Ok(SignalValue::Scalar(ratio))
85    }
86
87    fn reset(&mut self) {
88        self.volumes.clear();
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::ohlcv::OhlcvBar;
96    use crate::signals::Signal;
97    use crate::types::{NanoTimestamp, Price, Quantity, Symbol};
98    use rust_decimal_macros::dec;
99
100    fn bar_with_vol(vol: &str) -> OhlcvBar {
101        let p = Price::new(dec!(100)).unwrap();
102        OhlcvBar {
103            symbol: Symbol::new("X").unwrap(),
104            open: p, high: p, low: p, close: p,
105            volume: Quantity::new(vol.parse().unwrap()).unwrap(),
106            ts_open: NanoTimestamp::new(0),
107            ts_close: NanoTimestamp::new(1),
108            tick_count: 1,
109        }
110    }
111
112    #[test]
113    fn test_nvol_invalid_period() {
114        assert!(NormalizedVolume::new("nv", 0).is_err());
115    }
116
117    #[test]
118    fn test_nvol_unavailable_before_period() {
119        let mut nv = NormalizedVolume::new("nv", 3).unwrap();
120        assert_eq!(nv.update_bar(&bar_with_vol("100")).unwrap(), SignalValue::Unavailable);
121        assert_eq!(nv.update_bar(&bar_with_vol("200")).unwrap(), SignalValue::Unavailable);
122        assert!(!nv.is_ready());
123    }
124
125    #[test]
126    fn test_nvol_equal_volumes_gives_one() {
127        let mut nv = NormalizedVolume::new("nv", 3).unwrap();
128        nv.update_bar(&bar_with_vol("100")).unwrap();
129        nv.update_bar(&bar_with_vol("100")).unwrap();
130        let v = nv.update_bar(&bar_with_vol("100")).unwrap();
131        assert_eq!(v, SignalValue::Scalar(dec!(1)));
132    }
133
134    #[test]
135    fn test_nvol_double_avg_gives_two() {
136        let mut nv = NormalizedVolume::new("nv", 3).unwrap();
137        // avg = (100 + 100 + 200) / 3 = 133.333...; last = 200; 200/133.333 = 1.5
138        // Let's use uniform base then a big bar.
139        nv.update_bar(&bar_with_vol("100")).unwrap();
140        nv.update_bar(&bar_with_vol("100")).unwrap();
141        nv.update_bar(&bar_with_vol("100")).unwrap();
142        // Now push 200 — window slides to [100, 100, 200], avg=133.33, ratio=1.5
143        let v = nv.update_bar(&bar_with_vol("200")).unwrap();
144        if let SignalValue::Scalar(ratio) = v {
145            // 200 / ((100+100+200)/3) = 200 / 133.33... = 1.5
146            assert!((ratio - dec!(1.5)).abs() < dec!(0.001), "expected 1.5, got {ratio}");
147        } else {
148            panic!("expected Scalar");
149        }
150    }
151
152    #[test]
153    fn test_nvol_zero_average_returns_unavailable() {
154        let mut nv = NormalizedVolume::new("nv", 2).unwrap();
155        nv.update_bar(&bar_with_vol("0")).unwrap();
156        let v = nv.update_bar(&bar_with_vol("0")).unwrap();
157        assert_eq!(v, SignalValue::Unavailable);
158    }
159
160    #[test]
161    fn test_nvol_reset() {
162        let mut nv = NormalizedVolume::new("nv", 2).unwrap();
163        nv.update_bar(&bar_with_vol("100")).unwrap();
164        nv.update_bar(&bar_with_vol("100")).unwrap();
165        assert!(nv.is_ready());
166        nv.reset();
167        assert!(!nv.is_ready());
168    }
169
170    #[test]
171    fn test_nvol_period_and_name() {
172        let nv = NormalizedVolume::new("my_nv", 20).unwrap();
173        assert_eq!(nv.period(), 20);
174        assert_eq!(nv.name(), "my_nv");
175    }
176}