Skip to main content

wickra_core/indicators/
volume_oscillator.rs

1//! Volume Oscillator.
2
3use crate::error::{Error, Result};
4use crate::indicators::sma::Sma;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// Volume Oscillator — the percent difference between a fast and a slow SMA
9/// of the bar volume.
10///
11/// ```text
12/// VO_t = 100 · (SMA(volume, fast)_t − SMA(volume, slow)_t) / SMA(volume, slow)_t
13/// ```
14///
15/// A positive reading means short-term volume is running above the longer-term
16/// average (rising participation), a negative reading the opposite. The line is
17/// unbounded above and below `-100`, but stays near zero in stable conditions.
18/// Classic configuration is `fast = 14, slow = 28`. The first emission lands
19/// after `slow` candles. A slow average of `0` (only possible if every volume
20/// in the slow window was zero) collapses the output to `0` rather than NaN.
21///
22/// # Example
23///
24/// ```
25/// use wickra_core::{Candle, Indicator, VolumeOscillator};
26///
27/// let mut indicator = VolumeOscillator::new(14, 28).unwrap();
28/// let mut last = None;
29/// for i in 0..80 {
30///     let base = 100.0 + f64::from(i);
31///     let candle =
32///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
33///     last = indicator.update(candle);
34/// }
35/// assert!(last.is_some());
36/// ```
37#[derive(Debug, Clone)]
38pub struct VolumeOscillator {
39    fast_period: usize,
40    slow_period: usize,
41    fast: Sma,
42    slow: Sma,
43}
44
45impl VolumeOscillator {
46    /// Construct a Volume Oscillator with the given SMA periods.
47    ///
48    /// # Errors
49    /// Returns [`Error::PeriodZero`] if either period is zero, or
50    /// [`Error::InvalidPeriod`] if `fast >= slow`.
51    pub fn new(fast: usize, slow: usize) -> Result<Self> {
52        if fast == 0 || slow == 0 {
53            return Err(Error::PeriodZero);
54        }
55        if fast >= slow {
56            return Err(Error::InvalidPeriod {
57                message: "VolumeOscillator needs fast < slow",
58            });
59        }
60        Ok(Self {
61            fast_period: fast,
62            slow_period: slow,
63            fast: Sma::new(fast)?,
64            slow: Sma::new(slow)?,
65        })
66    }
67
68    /// Configured `(fast, slow)` periods.
69    pub const fn periods(&self) -> (usize, usize) {
70        (self.fast_period, self.slow_period)
71    }
72}
73
74impl Indicator for VolumeOscillator {
75    type Input = Candle;
76    type Output = f64;
77
78    fn update(&mut self, candle: Candle) -> Option<f64> {
79        let f = self.fast.update(candle.volume);
80        let s = self.slow.update(candle.volume);
81        let (fast_v, slow_v) = (f?, s?);
82        if slow_v == 0.0 {
83            // Whole slow window is zero-volume — the ratio is undefined; report 0.
84            return Some(0.0);
85        }
86        Some(100.0 * (fast_v - slow_v) / slow_v)
87    }
88
89    fn reset(&mut self) {
90        self.fast.reset();
91        self.slow.reset();
92    }
93
94    fn warmup_period(&self) -> usize {
95        self.slow_period
96    }
97
98    fn is_ready(&self) -> bool {
99        self.slow.is_ready()
100    }
101
102    fn name(&self) -> &'static str {
103        "VolumeOscillator"
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::traits::BatchExt;
111    use approx::assert_relative_eq;
112
113    fn c(volume: f64, ts: i64) -> Candle {
114        Candle::new(10.0, 10.0, 10.0, 10.0, volume, ts).unwrap()
115    }
116
117    #[test]
118    fn rejects_zero_period() {
119        assert!(matches!(
120            VolumeOscillator::new(0, 5),
121            Err(Error::PeriodZero)
122        ));
123        assert!(matches!(
124            VolumeOscillator::new(5, 0),
125            Err(Error::PeriodZero)
126        ));
127    }
128
129    #[test]
130    fn rejects_fast_geq_slow() {
131        assert!(matches!(
132            VolumeOscillator::new(10, 10),
133            Err(Error::InvalidPeriod { .. })
134        ));
135        assert!(matches!(
136            VolumeOscillator::new(28, 14),
137            Err(Error::InvalidPeriod { .. })
138        ));
139    }
140
141    #[test]
142    fn accessors_and_metadata() {
143        let vo = VolumeOscillator::new(14, 28).unwrap();
144        assert_eq!(vo.periods(), (14, 28));
145        assert_eq!(vo.name(), "VolumeOscillator");
146        assert_eq!(vo.warmup_period(), 28);
147    }
148
149    #[test]
150    fn constant_volume_yields_zero() {
151        // Both SMAs equal the constant volume, so (fast - slow) / slow = 0.
152        let mut vo = VolumeOscillator::new(3, 6).unwrap();
153        let candles: Vec<Candle> = (0..30i64).map(|i| c(500.0, i)).collect();
154        for v in vo.batch(&candles).into_iter().flatten() {
155            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
156        }
157    }
158
159    #[test]
160    fn zero_volume_window_yields_zero() {
161        // All bars carry zero volume — slow SMA is 0, defensive branch returns 0.
162        let mut vo = VolumeOscillator::new(2, 4).unwrap();
163        let candles: Vec<Candle> = (0..10i64).map(|i| c(0.0, i)).collect();
164        let out = vo.batch(&candles);
165        assert_relative_eq!(out[3].unwrap(), 0.0, epsilon = 1e-12);
166    }
167
168    #[test]
169    fn reference_value() {
170        // fast=2, slow=4 over volumes [10, 20, 30, 40, 50]:
171        //   bar 4 (index 3): fast=(40+30)/2=35, slow=(10+20+30+40)/4=25,
172        //                    VO = 100·(35-25)/25 = 40.
173        let mut vo = VolumeOscillator::new(2, 4).unwrap();
174        let candles = [c(10.0, 0), c(20.0, 1), c(30.0, 2), c(40.0, 3), c(50.0, 4)];
175        let out = vo.batch(&candles);
176        assert!(out[0].is_none() && out[1].is_none() && out[2].is_none());
177        assert_relative_eq!(out[3].unwrap(), 40.0, epsilon = 1e-9);
178        // bar 5 (index 4): fast=(50+40)/2=45, slow=(20+30+40+50)/4=35,
179        //                  VO = 100·(45-35)/35 = 1000/35.
180        assert_relative_eq!(out[4].unwrap(), 1000.0 / 35.0, epsilon = 1e-9);
181    }
182
183    #[test]
184    fn batch_equals_streaming() {
185        let candles: Vec<Candle> = (0..80i64)
186            .map(|i| c(100.0 + ((i % 11) as f64) * 5.0, i))
187            .collect();
188        let mut a = VolumeOscillator::new(14, 28).unwrap();
189        let mut b = VolumeOscillator::new(14, 28).unwrap();
190        assert_eq!(
191            a.batch(&candles),
192            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
193        );
194    }
195
196    #[test]
197    fn reset_clears_state() {
198        let candles: Vec<Candle> = (0..60i64).map(|i| c(100.0 + (i as f64), i)).collect();
199        let mut vo = VolumeOscillator::new(14, 28).unwrap();
200        vo.batch(&candles);
201        assert!(vo.is_ready());
202        vo.reset();
203        assert!(!vo.is_ready());
204        assert_eq!(vo.update(candles[0]), None);
205    }
206}