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    #[inline]
79    fn update(&mut self, candle: Candle) -> Option<f64> {
80        let f = self.fast.update(candle.volume);
81        let s = self.slow.update(candle.volume);
82        let (fast_v, slow_v) = (f?, s?);
83        if slow_v == 0.0 {
84            // Whole slow window is zero-volume — the ratio is undefined; report 0.
85            return Some(0.0);
86        }
87        Some(100.0 * (fast_v - slow_v) / slow_v)
88    }
89
90    fn reset(&mut self) {
91        self.fast.reset();
92        self.slow.reset();
93    }
94
95    #[inline]
96    fn warmup_period(&self) -> usize {
97        self.slow_period
98    }
99
100    #[inline]
101    fn is_ready(&self) -> bool {
102        self.slow.is_ready()
103    }
104
105    #[inline]
106    fn name(&self) -> &'static str {
107        "VolumeOscillator"
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::traits::BatchExt;
115    use approx::assert_relative_eq;
116
117    fn c(volume: f64, ts: i64) -> Candle {
118        Candle::new(10.0, 10.0, 10.0, 10.0, volume, ts).unwrap()
119    }
120
121    #[test]
122    fn rejects_zero_period() {
123        assert!(matches!(
124            VolumeOscillator::new(0, 5),
125            Err(Error::PeriodZero)
126        ));
127        assert!(matches!(
128            VolumeOscillator::new(5, 0),
129            Err(Error::PeriodZero)
130        ));
131    }
132
133    #[test]
134    fn rejects_fast_geq_slow() {
135        assert!(matches!(
136            VolumeOscillator::new(10, 10),
137            Err(Error::InvalidPeriod { .. })
138        ));
139        assert!(matches!(
140            VolumeOscillator::new(28, 14),
141            Err(Error::InvalidPeriod { .. })
142        ));
143    }
144
145    #[test]
146    fn accessors_and_metadata() {
147        let vo = VolumeOscillator::new(14, 28).unwrap();
148        assert_eq!(vo.periods(), (14, 28));
149        assert_eq!(vo.name(), "VolumeOscillator");
150        assert_eq!(vo.warmup_period(), 28);
151    }
152
153    #[test]
154    fn constant_volume_yields_zero() {
155        // Both SMAs equal the constant volume, so (fast - slow) / slow = 0.
156        let mut vo = VolumeOscillator::new(3, 6).unwrap();
157        let candles: Vec<Candle> = (0..30i64).map(|i| c(500.0, i)).collect();
158        for v in vo.batch(&candles).into_iter().flatten() {
159            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
160        }
161    }
162
163    #[test]
164    fn zero_volume_window_yields_zero() {
165        // All bars carry zero volume — slow SMA is 0, defensive branch returns 0.
166        let mut vo = VolumeOscillator::new(2, 4).unwrap();
167        let candles: Vec<Candle> = (0..10i64).map(|i| c(0.0, i)).collect();
168        let out = vo.batch(&candles);
169        assert_relative_eq!(out[3].unwrap(), 0.0, epsilon = 1e-12);
170    }
171
172    #[test]
173    fn reference_value() {
174        // fast=2, slow=4 over volumes [10, 20, 30, 40, 50]:
175        //   bar 4 (index 3): fast=(40+30)/2=35, slow=(10+20+30+40)/4=25,
176        //                    VO = 100·(35-25)/25 = 40.
177        let mut vo = VolumeOscillator::new(2, 4).unwrap();
178        let candles = [c(10.0, 0), c(20.0, 1), c(30.0, 2), c(40.0, 3), c(50.0, 4)];
179        let out = vo.batch(&candles);
180        assert!(out[0].is_none() && out[1].is_none() && out[2].is_none());
181        assert_relative_eq!(out[3].unwrap(), 40.0, epsilon = 1e-9);
182        // bar 5 (index 4): fast=(50+40)/2=45, slow=(20+30+40+50)/4=35,
183        //                  VO = 100·(45-35)/35 = 1000/35.
184        assert_relative_eq!(out[4].unwrap(), 1000.0 / 35.0, epsilon = 1e-9);
185    }
186
187    #[test]
188    fn batch_equals_streaming() {
189        let candles: Vec<Candle> = (0..80i64)
190            .map(|i| c(100.0 + ((i % 11) as f64) * 5.0, i))
191            .collect();
192        let mut a = VolumeOscillator::new(14, 28).unwrap();
193        let mut b = VolumeOscillator::new(14, 28).unwrap();
194        assert_eq!(
195            a.batch(&candles),
196            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
197        );
198    }
199
200    #[test]
201    fn reset_clears_state() {
202        let candles: Vec<Candle> = (0..60i64).map(|i| c(100.0 + (i as f64), i)).collect();
203        let mut vo = VolumeOscillator::new(14, 28).unwrap();
204        vo.batch(&candles);
205        assert!(vo.is_ready());
206        vo.reset();
207        assert!(!vo.is_ready());
208        assert_eq!(vo.update(candles[0]), None);
209    }
210}