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