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>(
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
39impl Indicator for VolumeZoneOscillator {
42 fn name(&self) -> &'static str {
43 "VZO"
44 }
45
46 fn required_len(&self) -> usize {
47 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 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 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 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) }
90 };
91 pos_vols[i] = pv;
92 neg_vols[i] = nv;
93
94 sum_pos += pv;
96 sum_neg += nv;
97 sum_tot += vol;
98
99 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 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#[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 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 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 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 for v in &vals[..period] {
189 assert!(v.is_nan(), "expected NaN but got {v}");
190 }
191 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 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}