Skip to main content

indicators/volume/
vzo.rs

1//! Volume Zone Oscillator (VZO).
2//!
3//! Computes: `100 × (rolling_sum(pos_vol) − rolling_sum(neg_vol)) / rolling_sum(total_vol)`
4//!
5//! - `pos_vol` = bar volume when close > prev_close, else 0
6//! - `neg_vol` = bar volume when close < prev_close, else 0
7//!
8//! Output column: `vzo_{period}` (range approximately −100 to +100).
9
10use std::collections::HashMap;
11
12use crate::error::IndicatorError;
13use crate::indicator::{Indicator, IndicatorOutput};
14use crate::registry::param_usize;
15use crate::types::Candle;
16
17// ── Indicator struct ──────────────────────────────────────────────────────────
18
19#[derive(Debug, Clone)]
20pub struct VolumeZoneOscillator {
21    pub period: usize,
22}
23
24impl VolumeZoneOscillator {
25    pub fn new(period: usize) -> Self {
26        Self { period }
27    }
28}
29
30// ── Registry factory ──────────────────────────────────────────────────────────
31
32pub fn factory<S: ::std::hash::BuildHasher>(params: &HashMap<String, String, S>) -> Result<Box<dyn Indicator>, IndicatorError> {
33    let period = param_usize(params, "period", 14)?;
34    Ok(Box::new(VolumeZoneOscillator::new(period)))
35}
36
37// ── Indicator impl ────────────────────────────────────────────────────────────
38
39impl Indicator for VolumeZoneOscillator {
40    fn name(&self) -> &'static str {
41        "VZO"
42    }
43
44    fn required_len(&self) -> usize {
45        // Need `period` bars of direction-split volume plus one prior close to
46        // compute the first diff, so the window fills after `period + 1` candles.
47        self.period + 1
48    }
49
50    fn required_columns(&self) -> &[&'static str] {
51        &["close", "volume"]
52    }
53
54    fn calculate(&self, candles: &[Candle]) -> Result<IndicatorOutput, IndicatorError> {
55        self.check_len(candles)?;
56
57        let n = candles.len();
58        let p = self.period;
59        let mut out = vec![f64::NAN; n];
60
61        // Rolling sums over a sliding window of width `p`.
62        // We accumulate three running totals and subtract the element that
63        // falls out of the back of the window — O(n) without a VecDeque.
64        let mut sum_pos = 0.0_f64;
65        let mut sum_neg = 0.0_f64;
66        let mut sum_tot = 0.0_f64;
67
68        // Temporary per-bar splits (need random access for the drop step).
69        let mut pos_vols = vec![0.0_f64; n];
70        let mut neg_vols = vec![0.0_f64; n];
71
72        for i in 0..n {
73            let vol = candles[i].volume;
74            let close = candles[i].close;
75
76            // diff: undefined for the very first bar → treat as neutral (0 vol split).
77            let (pv, nv) = if i == 0 {
78                (0.0, 0.0)
79            } else {
80                let prev = candles[i - 1].close;
81                if close > prev {
82                    (vol, 0.0)
83                } else if close < prev {
84                    (0.0, vol)
85                } else {
86                    (0.0, 0.0) // flat close — neutral, same as original logic
87                }
88            };
89            pos_vols[i] = pv;
90            neg_vols[i] = nv;
91
92            // Grow the rolling window.
93            sum_pos += pv;
94            sum_neg += nv;
95            sum_tot += vol;
96
97            // Drop the bar that just left the window.
98            if i >= p {
99                let drop = i - p;
100                sum_pos -= pos_vols[drop];
101                sum_neg -= neg_vols[drop];
102                sum_tot -= candles[drop].volume;
103            }
104
105            // Emit once we have a full window (index >= p, i.e. p+1 bars seen).
106            if i >= p && sum_tot > 0.0 {
107                out[i] = 100.0 * (sum_pos - sum_neg) / sum_tot;
108            }
109        }
110
111        let col_name = format!("vzo_{p}");
112        Ok(IndicatorOutput::from_pairs([(col_name, out)]))
113    }
114}
115
116// ── Tests ─────────────────────────────────────────────────────────────────────
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    fn make_candle(close: f64, volume: f64) -> Candle {
123        Candle {
124            time: 0,
125            open: close,
126            high: close,
127            low: close,
128            close,
129            volume,
130        }
131    }
132
133    #[test]
134    fn insufficient_data_returns_error() {
135        let vzo = VolumeZoneOscillator::new(5);
136        let candles: Vec<Candle> = (0..5)
137            .map(|i| make_candle(100.0 + i as f64, 1000.0))
138            .collect();
139        assert!(vzo.calculate(&candles).is_err());
140    }
141
142    #[test]
143    fn all_up_bars_gives_positive_vzo() {
144        let vzo = VolumeZoneOscillator::new(5);
145        // 7 candles, every close higher than the previous → all pos_vol, no neg_vol
146        let candles: Vec<Candle> = (0..7)
147            .map(|i| make_candle(100.0 + i as f64, 1_000.0))
148            .collect();
149        let out = vzo.calculate(&candles).unwrap();
150        let vals = out.get("vzo_5").unwrap();
151        // Last value must be exactly +100
152        assert_eq!(*vals.last().unwrap(), 100.0);
153    }
154
155    #[test]
156    fn all_down_bars_gives_negative_vzo() {
157        let vzo = VolumeZoneOscillator::new(5);
158        let candles: Vec<Candle> = (0..7)
159            .map(|i| make_candle(200.0 - i as f64, 1_000.0))
160            .collect();
161        let out = vzo.calculate(&candles).unwrap();
162        let vals = out.get("vzo_5").unwrap();
163        assert_eq!(*vals.last().unwrap(), -100.0);
164    }
165
166    #[test]
167    fn flat_bars_give_zero_vzo() {
168        let vzo = VolumeZoneOscillator::new(5);
169        // Flat close → no direction → pos_vol = neg_vol = 0
170        let candles: Vec<Candle> = (0..7).map(|_| make_candle(100.0, 1_000.0)).collect();
171        let out = vzo.calculate(&candles).unwrap();
172        let vals = out.get("vzo_5").unwrap();
173        assert_eq!(*vals.last().unwrap(), 0.0);
174    }
175
176    #[test]
177    fn warm_up_bars_are_nan() {
178        let period = 5;
179        let vzo = VolumeZoneOscillator::new(period);
180        let candles: Vec<Candle> = (0..10)
181            .map(|i| make_candle(100.0 + i as f64, 1_000.0))
182            .collect();
183        let out = vzo.calculate(&candles).unwrap();
184        let vals = out.get("vzo_5").unwrap();
185        // First `period` values (indices 0..period) should be NaN
186        for v in &vals[..period] {
187            assert!(v.is_nan(), "expected NaN but got {v}");
188        }
189        // Values from index `period` onward should be finite
190        for v in &vals[period..] {
191            assert!(v.is_finite(), "expected finite but got {v}");
192        }
193    }
194
195    #[test]
196    fn output_length_matches_input() {
197        let vzo = VolumeZoneOscillator::new(5);
198        let candles: Vec<Candle> = (0..20)
199            .map(|i| make_candle(100.0 + i as f64, 500.0))
200            .collect();
201        let out = vzo.calculate(&candles).unwrap();
202        assert_eq!(out.len(), 20);
203    }
204
205    #[test]
206    fn vzo_bounded_between_minus100_and_plus100() {
207        let vzo = VolumeZoneOscillator::new(5);
208        // Alternating up/down with varying volume
209        let candles: Vec<Candle> = (0..30)
210            .map(|i| {
211                let close = if i % 2 == 0 {
212                    100.0 + i as f64
213                } else {
214                    99.0 + i as f64
215                };
216                make_candle(close, (i + 1) as f64 * 100.0)
217            })
218            .collect();
219        let out = vzo.calculate(&candles).unwrap();
220        for &v in out.get("vzo_5").unwrap() {
221            if v.is_finite() {
222                assert!((-100.0..=100.0).contains(&v), "VZO out of range: {v}");
223            }
224        }
225    }
226}