1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9const BETTER_VOLUME_NEUTRAL: f64 = 0.0;
11const BETTER_VOLUME_LOW: f64 = 1.0;
13const BETTER_VOLUME_CHURN: f64 = 2.0;
15const BETTER_VOLUME_CLIMAX: f64 = 3.0;
18const BETTER_VOLUME_CLIMAX_CHURN: f64 = 4.0;
20
21#[derive(Debug, Clone)]
62pub struct BetterVolume {
63 period: usize,
64 window: VecDeque<(f64, f64, Option<f64>)>,
67 last: Option<f64>,
68}
69
70impl BetterVolume {
71 pub fn new(period: usize) -> Result<Self> {
77 if period == 0 {
78 return Err(Error::PeriodZero);
79 }
80 if period > crate::error::MAX_PERIOD {
81 return Err(Error::InvalidPeriod {
82 message: crate::error::PERIOD_ABOVE_MAX,
83 });
84 }
85 Ok(Self {
86 period,
87 window: VecDeque::with_capacity(period),
88 last: None,
89 })
90 }
91
92 pub const fn period(&self) -> usize {
94 self.period
95 }
96
97 pub const fn value(&self) -> Option<f64> {
99 self.last
100 }
101}
102
103impl Indicator for BetterVolume {
104 type Input = Candle;
105 type Output = f64;
106
107 #[inline]
108 fn update(&mut self, candle: Candle) -> Option<f64> {
109 let range = candle.high - candle.low;
110 let volume = candle.volume;
111 let churn = (range > 0.0).then(|| volume / range);
112 if self.window.len() == self.period {
113 self.window.pop_front();
114 }
115 self.window.push_back((volume, volume * range, churn));
116 if self.window.len() < self.period {
117 return None;
118 }
119 let previous = self.window.iter().take(self.period - 1);
121 let (mut min_vol, mut max_climax, mut max_churn) =
122 (f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
123 for &(v, c, ch) in previous {
124 min_vol = min_vol.min(v);
125 max_climax = max_climax.max(c);
126 if let Some(ch) = ch {
127 max_churn = max_churn.max(ch);
128 }
129 }
130 let is_climax = volume * range > max_climax;
131 let is_churn = churn.is_some_and(|ch| ch > max_churn);
132 let mut code = BETTER_VOLUME_NEUTRAL;
133 if volume < min_vol {
134 code = BETTER_VOLUME_LOW;
135 }
136 if is_climax {
137 code = if candle.close >= candle.open {
138 BETTER_VOLUME_CLIMAX
139 } else {
140 -BETTER_VOLUME_CLIMAX
141 };
142 }
143 if is_churn {
144 code = BETTER_VOLUME_CHURN;
145 }
146 if is_climax && is_churn {
147 code = BETTER_VOLUME_CLIMAX_CHURN;
148 }
149 self.last = Some(code);
150 Some(code)
151 }
152
153 fn reset(&mut self) {
154 self.window.clear();
155 self.last = None;
156 }
157
158 #[inline]
159 fn warmup_period(&self) -> usize {
160 self.period
161 }
162
163 #[inline]
164 fn is_ready(&self) -> bool {
165 self.last.is_some()
166 }
167
168 #[inline]
169 fn name(&self) -> &'static str {
170 "BetterVolume"
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::traits::BatchExt;
178
179 fn bar(open: f64, high: f64, low: f64, close: f64, volume: f64) -> Candle {
181 Candle::new_unchecked(open, high, low, close, volume, 0)
182 }
183
184 fn steady() -> Candle {
185 bar(100.0, 102.0, 100.0, 101.0, 1_000.0)
186 }
187
188 #[test]
189 fn rejects_zero_period() {
190 assert!(matches!(BetterVolume::new(0), Err(Error::PeriodZero)));
191 }
192
193 #[test]
194 fn accessors_and_metadata() {
195 let bv = BetterVolume::new(20).unwrap();
196 assert_eq!(bv.period(), 20);
197 assert_eq!(bv.warmup_period(), 20);
198 assert_eq!(bv.name(), "BetterVolume");
199 assert!(!bv.is_ready());
200 assert_eq!(bv.value(), None);
201 }
202
203 #[test]
204 fn first_emission_at_warmup_period() {
205 let mut bv = BetterVolume::new(3).unwrap();
206 let out = bv.batch(&[steady(), steady(), steady()]);
207 assert!(out[0].is_none() && out[1].is_none());
208 assert!(out[2].is_some());
209 }
210
211 #[test]
212 fn steady_bars_are_neutral() {
213 let mut bv = BetterVolume::new(5).unwrap();
214 let out = bv.batch(&[steady(); 20]);
215 assert_eq!(out.last().copied().flatten(), Some(BETTER_VOLUME_NEUTRAL));
216 }
217
218 #[test]
219 fn low_volume_bar() {
220 let mut bv = BetterVolume::new(3).unwrap();
221 bv.batch(&[steady(), steady()]);
222 let v = bv.update(bar(100.0, 102.0, 100.0, 101.0, 200.0));
223 assert_eq!(v, Some(BETTER_VOLUME_LOW));
224 }
225
226 #[test]
227 fn climax_bars_carry_direction() {
228 let mut up = BetterVolume::new(3).unwrap();
229 up.batch(&[steady(), steady()]);
230 assert_eq!(
232 up.update(bar(100.0, 110.0, 100.0, 109.0, 4_000.0)),
233 Some(BETTER_VOLUME_CLIMAX)
234 );
235 let mut down = BetterVolume::new(3).unwrap();
236 down.batch(&[steady(), steady()]);
237 assert_eq!(
238 down.update(bar(109.0, 110.0, 100.0, 100.5, 4_000.0)),
239 Some(-BETTER_VOLUME_CLIMAX)
240 );
241 }
242
243 #[test]
244 fn churn_bar() {
245 let mut bv = BetterVolume::new(3).unwrap();
246 bv.batch(&[steady(), steady()]);
247 assert_eq!(
250 bv.update(bar(100.0, 101.0, 100.0, 100.5, 1_500.0)),
251 Some(BETTER_VOLUME_CHURN)
252 );
253 }
254
255 #[test]
256 fn climax_and_churn_together() {
257 let mut bv = BetterVolume::new(3).unwrap();
258 bv.batch(&[steady(), steady()]);
259 assert_eq!(
260 bv.update(bar(100.0, 103.0, 100.0, 102.0, 10_000.0)),
261 Some(BETTER_VOLUME_CLIMAX_CHURN)
262 );
263 }
264
265 #[test]
266 fn zero_range_bars_never_churn() {
267 let mut bv = BetterVolume::new(3).unwrap();
268 let flat = bar(100.0, 100.0, 100.0, 100.0, 0.0);
269 for v in bv.batch(&[flat; 10]).into_iter().flatten() {
270 assert_eq!(v, BETTER_VOLUME_NEUTRAL);
271 }
272 }
273
274 #[test]
275 fn reset_clears_state() {
276 let mut bv = BetterVolume::new(3).unwrap();
277 bv.batch(&[steady(); 5]);
278 assert!(bv.is_ready());
279 bv.reset();
280 assert!(!bv.is_ready());
281 assert_eq!(bv.value(), None);
282 assert_eq!(bv.update(steady()), None);
283 }
284
285 #[test]
286 fn batch_equals_streaming() {
287 let candles: Vec<Candle> = (0..60)
288 .map(|i| {
289 let f = f64::from(i);
290 let mid = 100.0 + (f * 0.3).sin() * 4.0;
291 let half = 1.0 + (f * 0.7).cos().abs() * 2.0;
292 bar(
293 mid - 0.2,
294 mid + half,
295 mid - half,
296 mid + 0.2,
297 1_000.0 + (f * 0.5).sin() * 600.0,
298 )
299 })
300 .collect();
301 let mut a = BetterVolume::new(10).unwrap();
302 let mut b = BetterVolume::new(10).unwrap();
303 let batch = a.batch(&candles);
304 let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
305 assert_eq!(batch, streamed);
306 }
307
308 #[test]
309 fn rejects_oversized_period() {
310 let too_long = crate::error::MAX_PERIOD + 1;
311 assert!(matches!(
312 BetterVolume::new(too_long),
313 Err(Error::InvalidPeriod { .. })
314 ));
315 }
316
317 fn mixed(len: i32) -> Vec<Candle> {
318 (0..len)
319 .map(|i| {
320 let f = f64::from(i);
321 let mid = 100.0 + (f * 0.3).sin() * 4.0;
322 let half = (f * 0.7).cos().abs() * 2.0;
323 let drift = (f * 1.1).sin() * 0.5;
324 bar(
325 mid - drift,
326 mid + half,
327 mid - half,
328 mid + drift,
329 1_000.0 + (f * 0.5).sin() * 600.0,
330 )
331 })
332 .collect()
333 }
334
335 #[test]
336 fn first_value_lands_exactly_at_warmup_index() {
337 let candles = mixed(30);
338 let mut bv = BetterVolume::new(7).unwrap();
339 let warmup = bv.warmup_period();
340 let out = bv.batch(&candles);
341 assert!(out.iter().take(warmup - 1).all(Option::is_none));
342 assert!(out.iter().skip(warmup - 1).all(Option::is_some));
343 }
344
345 #[test]
346 fn reset_replays_identically_to_fresh_instance() {
347 let candles = mixed(60);
348 let mut used = BetterVolume::new(10).unwrap();
349 used.batch(&candles);
350 used.reset();
351 let replay = used.batch(&candles);
352 assert_eq!(replay, BetterVolume::new(10).unwrap().batch(&candles));
353 }
354
355 #[test]
356 fn batch_nan_into_matches_streaming_bits() {
357 let candles = mixed(80);
358 let mut nan_out = vec![0.0; candles.len()];
359 BetterVolume::new(6)
360 .unwrap()
361 .batch_nan_into(&candles, &mut nan_out);
362 let mut streamer = BetterVolume::new(6).unwrap();
363 let identical = candles.iter().zip(&nan_out).all(|(candle, v)| {
364 streamer.update(*candle).unwrap_or(f64::NAN).to_bits() == v.to_bits()
365 });
366 assert!(identical);
367 }
368
369 #[test]
375 fn climax_overrides_low_volume() {
376 let mut bv = BetterVolume::new(3).unwrap();
377 bv.batch(&[steady(), steady()]);
378 assert_eq!(
379 bv.update(bar(100.0, 103.0, 100.0, 102.0, 900.0)),
380 Some(BETTER_VOLUME_CLIMAX)
381 );
382 }
383
384 #[test]
388 fn churn_overrides_low_volume() {
389 let mut bv = BetterVolume::new(3).unwrap();
390 bv.batch(&[steady(), steady()]);
391 assert_eq!(
392 bv.update(bar(100.0, 101.0, 100.0, 100.5, 600.0)),
393 Some(BETTER_VOLUME_CHURN)
394 );
395 }
396
397 #[test]
398 fn climax_on_unchanged_close_counts_as_up_bar() {
399 let mut bv = BetterVolume::new(3).unwrap();
400 bv.batch(&[steady(), steady()]);
401 assert_eq!(
402 bv.update(bar(105.0, 110.0, 100.0, 105.0, 4_000.0)),
403 Some(BETTER_VOLUME_CLIMAX)
404 );
405 }
406
407 #[test]
408 fn ties_with_previous_extremes_stay_neutral() {
409 let mut bv = BetterVolume::new(4).unwrap();
412 bv.batch(&[
413 steady(),
414 bar(100.0, 104.0, 100.0, 103.0, 1_000.0),
415 bar(100.0, 101.0, 100.0, 100.5, 2_000.0),
416 ]);
417 assert_eq!(
419 bv.update(bar(100.0, 104.0, 100.0, 103.0, 1_000.0)),
420 Some(BETTER_VOLUME_NEUTRAL)
421 );
422 }
423
424 #[test]
425 fn every_code_from_one_rolling_window() {
426 let mut bv = BetterVolume::new(3).unwrap();
428 let candles = [
429 steady(),
430 steady(),
431 steady(), bar(100.0, 102.0, 100.0, 101.0, 500.0), bar(100.0, 101.0, 100.0, 100.5, 1_200.0), bar(100.0, 110.0, 100.0, 109.0, 1_000.0), bar(109.0, 110.0, 90.0, 91.0, 1_000.0), bar(100.0, 104.0, 100.0, 103.0, 50_000.0), ];
438 let out: Vec<Option<f64>> = bv.batch(&candles);
439 let expected = [
440 None,
441 None,
442 Some(0.0),
443 Some(1.0),
444 Some(2.0),
445 Some(3.0),
446 Some(-3.0),
447 Some(4.0),
448 ];
449 assert_eq!(out, expected);
450 }
451
452 #[test]
453 fn period_one_compares_against_an_empty_lookback() {
454 let mut bv = BetterVolume::new(1).unwrap();
457 assert_eq!(bv.update(steady()), Some(BETTER_VOLUME_CLIMAX_CHURN));
458 assert_eq!(
459 bv.update(bar(100.0, 100.0, 100.0, 100.0, 10.0)),
460 Some(BETTER_VOLUME_CLIMAX)
461 );
462 }
463}