1use std::collections::HashMap;
11
12use crate::error::IndicatorError;
13use crate::indicator::{Indicator, IndicatorOutput};
14use crate::registry::param_usize;
15use crate::types::Candle;
16
17#[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
30pub 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
37impl Indicator for VolumeZoneOscillator {
40 fn name(&self) -> &'static str {
41 "VZO"
42 }
43
44 fn required_len(&self) -> usize {
45 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 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 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 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) }
88 };
89 pos_vols[i] = pv;
90 neg_vols[i] = nv;
91
92 sum_pos += pv;
94 sum_neg += nv;
95 sum_tot += vol;
96
97 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 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#[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 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 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 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 for v in &vals[..period] {
187 assert!(v.is_nan(), "expected NaN but got {v}");
188 }
189 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 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}