Skip to main content

wickra_core/indicators/
better_volume.rs

1//! Better Volume (Barry Taylor, emini-watch) — volume-bar classification.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Code of a bar with no notable volume signature.
10const BETTER_VOLUME_NEUTRAL: f64 = 0.0;
11/// Code of a low-volume bar (lowest volume of the lookback).
12const BETTER_VOLUME_LOW: f64 = 1.0;
13/// Code of a high-churn bar (highest volume per unit of range).
14const BETTER_VOLUME_CHURN: f64 = 2.0;
15/// Code of a climax bar (highest volume · range); `+3` on an up bar, `−3` on a
16/// down bar.
17const BETTER_VOLUME_CLIMAX: f64 = 3.0;
18/// Code of a bar that is both a climax and a high-churn bar.
19const BETTER_VOLUME_CLIMAX_CHURN: f64 = 4.0;
20
21/// Better Volume — Barry Taylor's (emini-watch) classification of each volume
22/// bar by its effort (volume) against its result (range) over a `period`-bar
23/// lookback.
24///
25/// ```text
26/// range      = high − low
27/// low volume : volume          is a new low  of the lookback   -> 1
28/// climax     : volume · range  is a new high of the lookback   -> +3 up bar, −3 down bar
29/// churn      : volume / range  is a new high of the lookback   -> 2
30/// both       : climax and churn on the same bar                -> 4
31/// otherwise                                                    -> 0
32/// ```
33///
34/// The rules are applied in that order and a later match overrides an earlier
35/// one, as in Taylor's original. "New high / low" means strictly beyond every one
36/// of the previous `period − 1` bars, so a run of identical bars stays neutral.
37/// A zero-range bar has no defined volume per range and never counts as churn.
38///
39/// Reading the codes (Volume Spread Analysis): a **climax** is the most effort
40/// meeting the widest result — often the start or end of a move; **churn** is
41/// heavy volume going nowhere — professional absorption near turning points; and
42/// a **low-volume** bar shows the absence of interest that marks pullbacks and
43/// tests.
44///
45/// `Input = Candle`, `Output = f64` (one of `0, 1, 2, ±3, 4`),
46/// `warmup_period == period`.
47///
48/// # Example
49///
50/// ```
51/// use wickra_core::{BetterVolume, Candle, Indicator};
52///
53/// let mut bv = BetterVolume::new(3).unwrap();
54/// bv.update(Candle::new(100.0, 101.0, 99.0, 100.5, 1_000.0, 0).unwrap());
55/// bv.update(Candle::new(100.5, 101.5, 99.5, 101.0, 1_000.0, 1).unwrap());
56/// // A wide up bar with the most volume · range, but not the most volume per
57/// // point of range: a climax up bar.
58/// let out = bv.update(Candle::new(101.0, 105.0, 100.5, 104.5, 2_000.0, 2).unwrap());
59/// assert_eq!(out, Some(3.0));
60/// ```
61#[derive(Debug, Clone)]
62pub struct BetterVolume {
63    period: usize,
64    /// `(volume, volume · range, volume / range)` of the last `period` bars;
65    /// the last entry is `None` on a zero-range bar.
66    window: VecDeque<(f64, f64, Option<f64>)>,
67    last: Option<f64>,
68}
69
70impl BetterVolume {
71    /// Construct a new Better Volume classifier with the given lookback `period`.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`Error::PeriodZero`] if `period == 0`.
76    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    /// Configured lookback period.
93    pub const fn period(&self) -> usize {
94        self.period
95    }
96
97    /// Current value if available.
98    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        // Extremes over the previous `period − 1` bars (the current bar excluded).
120        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    /// A bar from `low` to `high`, opening at `open` and closing at `close`.
180    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        // Wide range and heavy volume, but less volume per point than before.
231        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        // Same range, a bit more volume -> more volume per point, but a
248        // narrower bar keeps volume · range below the previous highs.
249        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    /// Hand-computed: two `steady` bars have volume `1000`, range `2`,
370    /// `volume · range = 2000`, `volume / range = 500`. The third bar has volume
371    /// `900`, range `3`: `900 < 1000` (low volume, code 1), but
372    /// `900 · 3 = 2700 > 2000` (climax) overrides it; `900 / 3 = 300 < 500` is no
373    /// churn. It closes above its open, so the code is `+3`.
374    #[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    /// Hand-computed: churn overrides low volume. Volume `600`, range `1`:
385    /// `600 < 1000` (low), `600 · 1 = 600 < 2000` (no climax),
386    /// `600 / 1 = 600 > 500` (churn) -> code 2.
387    #[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        // Equal volume is not a new low, equal `volume · range` not a new climax,
410        // equal `volume / range` not new churn: strictly-beyond comparisons.
411        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        // min volume 1000, max climax 4000, max churn 2000.
418        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        // A single `period = 3` stream that walks through all six codes.
427        let mut bv = BetterVolume::new(3).unwrap();
428        let candles = [
429            steady(),
430            steady(),
431            steady(),                                  // 0: ties everywhere
432            bar(100.0, 102.0, 100.0, 101.0, 500.0),    // 1: vol 500 < 1000
433            bar(100.0, 101.0, 100.0, 100.5, 1_200.0),  // 2: churn 1200 > 500, climax 1200 < 2000
434            bar(100.0, 110.0, 100.0, 109.0, 1_000.0),  // +3: climax 10000, churn 100
435            bar(109.0, 110.0, 90.0, 91.0, 1_000.0),    // −3: climax 20000, churn 50
436            bar(100.0, 104.0, 100.0, 103.0, 50_000.0), // 4: climax 200000, churn 12500
437        ];
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        // With no previous bars every extreme is unbeaten: a ranged bar is both a
455        // climax and churn (4); a zero-range bar is a climax only (±3).
456        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}