Skip to main content

wickra_core/indicators/
better_volume.rs

1//! Better Volume (VSA) — a streaming effort-versus-result oscillator.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Better Volume — a Volume-Spread-Analysis (VSA) "effort versus result"
10/// oscillator: how much volume (effort) a bar spent relative to the price range
11/// (result) it achieved, both normalised against their own recent averages.
12///
13/// ```text
14/// range_t   = high_t − low_t
15/// rel_vol   = volume_t / SMA(volume, period)
16/// rel_range = range_t  / SMA(range,  period)
17/// BetterVol = rel_vol − rel_range
18/// ```
19///
20/// Volume-Spread Analysis (Wyckoff, popularised by Tom Williams) reads markets
21/// through the relationship between **effort** (volume) and **result** (the bar's
22/// spread). A bar with heavy volume but a narrow range — `rel_vol` high while
23/// `rel_range` low, so the oscillator is **positive** — is *churn*: large effort
24/// produced little movement, the hallmark of absorption (supply meeting demand at
25/// a top, or vice versa at a bottom). A bar that travels far on light volume —
26/// negative oscillator — shows *ease of movement*, a trend meeting no resistance.
27///
28/// Both legs are normalised by their `period` simple moving averages (including
29/// the current bar), so the output is centred near `0` and self-scales to the
30/// instrument. A degenerate average of `0` makes its leg `0` rather than dividing
31/// by zero. The first value lands after `period` inputs. Each `update` is O(1).
32///
33/// # Example
34///
35/// ```
36/// use wickra_core::{Candle, Indicator, BetterVolume};
37///
38/// let mut indicator = BetterVolume::new(20).unwrap();
39/// let mut last = None;
40/// for i in 0..60 {
41///     let base = 100.0 + f64::from(i);
42///     let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1_000.0, 0).unwrap();
43///     last = indicator.update(c);
44/// }
45/// assert!(last.is_some());
46/// ```
47#[derive(Debug, Clone)]
48pub struct BetterVolume {
49    period: usize,
50    volumes: VecDeque<f64>,
51    ranges: VecDeque<f64>,
52    vol_sum: f64,
53    range_sum: f64,
54    last: Option<f64>,
55}
56
57impl BetterVolume {
58    /// Construct a new Better Volume oscillator with the given averaging `period`.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`Error::PeriodZero`] if `period == 0`.
63    pub fn new(period: usize) -> Result<Self> {
64        if period == 0 {
65            return Err(Error::PeriodZero);
66        }
67        Ok(Self {
68            period,
69            volumes: VecDeque::with_capacity(period),
70            ranges: VecDeque::with_capacity(period),
71            vol_sum: 0.0,
72            range_sum: 0.0,
73            last: None,
74        })
75    }
76
77    /// Configured averaging period.
78    pub const fn period(&self) -> usize {
79        self.period
80    }
81
82    /// Current value if available.
83    pub const fn value(&self) -> Option<f64> {
84        self.last
85    }
86}
87
88impl Indicator for BetterVolume {
89    type Input = Candle;
90    type Output = f64;
91
92    fn update(&mut self, candle: Candle) -> Option<f64> {
93        let range = candle.high - candle.low;
94        if self.volumes.len() == self.period {
95            self.vol_sum -= self.volumes.pop_front().expect("non-empty");
96            self.range_sum -= self.ranges.pop_front().expect("non-empty");
97        }
98        self.volumes.push_back(candle.volume);
99        self.ranges.push_back(range);
100        self.vol_sum += candle.volume;
101        self.range_sum += range;
102        if self.volumes.len() < self.period {
103            return None;
104        }
105        let n = self.period as f64;
106        let sma_vol = self.vol_sum / n;
107        let sma_range = self.range_sum / n;
108        let rel_vol = if sma_vol > 0.0 {
109            candle.volume / sma_vol
110        } else {
111            0.0
112        };
113        let rel_range = if sma_range > 0.0 {
114            range / sma_range
115        } else {
116            0.0
117        };
118        let out = rel_vol - rel_range;
119        self.last = Some(out);
120        Some(out)
121    }
122
123    fn reset(&mut self) {
124        self.volumes.clear();
125        self.ranges.clear();
126        self.vol_sum = 0.0;
127        self.range_sum = 0.0;
128        self.last = None;
129    }
130
131    fn warmup_period(&self) -> usize {
132        self.period
133    }
134
135    fn is_ready(&self) -> bool {
136        self.last.is_some()
137    }
138
139    fn name(&self) -> &'static str {
140        "BetterVolume"
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::traits::BatchExt;
148    use approx::assert_relative_eq;
149
150    fn candle(high: f64, low: f64, volume: f64) -> Candle {
151        Candle::new_unchecked(low, high, low, high, volume, 0)
152    }
153
154    #[test]
155    fn rejects_zero_period() {
156        assert!(matches!(BetterVolume::new(0), Err(Error::PeriodZero)));
157    }
158
159    #[test]
160    fn accessors_and_metadata() {
161        let bv = BetterVolume::new(20).unwrap();
162        assert_eq!(bv.period(), 20);
163        assert_eq!(bv.warmup_period(), 20);
164        assert_eq!(bv.name(), "BetterVolume");
165        assert!(!bv.is_ready());
166        assert_eq!(bv.value(), None);
167    }
168
169    #[test]
170    fn first_emission_at_warmup_period() {
171        let mut bv = BetterVolume::new(3).unwrap();
172        let candles: Vec<Candle> = (0..6).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
173        let out = bv.batch(&candles);
174        for v in out.iter().take(2) {
175            assert!(v.is_none());
176        }
177        assert!(out[2].is_some());
178    }
179
180    #[test]
181    fn steady_bars_are_neutral() {
182        // Identical volume and range every bar -> rel_vol = rel_range = 1 -> 0.
183        let mut bv = BetterVolume::new(4).unwrap();
184        let candles: Vec<Candle> = (0..10).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
185        let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
186        assert_relative_eq!(last, 0.0, epsilon = 1e-9);
187    }
188
189    #[test]
190    fn churn_bar_is_positive() {
191        // Three normal bars, then a high-volume narrow-range bar -> positive.
192        let mut bv = BetterVolume::new(4).unwrap();
193        let mut candles: Vec<Candle> = (0..3).map(|_| candle(105.0, 100.0, 1_000.0)).collect();
194        candles.push(candle(100.5, 100.0, 5_000.0)); // huge volume, tiny range
195        let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
196        assert!(last > 0.0, "churn bar should be positive, got {last}");
197    }
198
199    #[test]
200    fn ease_of_movement_bar_is_negative() {
201        // Three normal bars, then a wide-range light-volume bar -> negative.
202        let mut bv = BetterVolume::new(4).unwrap();
203        let mut candles: Vec<Candle> = (0..3).map(|_| candle(101.0, 100.0, 5_000.0)).collect();
204        candles.push(candle(115.0, 100.0, 500.0)); // wide range, tiny volume
205        let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
206        assert!(
207            last < 0.0,
208            "ease-of-movement bar should be negative, got {last}"
209        );
210    }
211
212    #[test]
213    fn zero_everything_is_zero() {
214        // Zero volume and zero range -> both legs guarded to 0.
215        let mut bv = BetterVolume::new(3).unwrap();
216        let candles: Vec<Candle> = (0..6).map(|_| candle(100.0, 100.0, 0.0)).collect();
217        for v in bv.batch(&candles).into_iter().flatten() {
218            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
219        }
220    }
221
222    #[test]
223    fn reset_clears_state() {
224        let mut bv = BetterVolume::new(3).unwrap();
225        bv.batch(
226            &(0..6)
227                .map(|_| candle(102.0, 100.0, 1_000.0))
228                .collect::<Vec<_>>(),
229        );
230        assert!(bv.is_ready());
231        bv.reset();
232        assert!(!bv.is_ready());
233        assert_eq!(bv.value(), None);
234        assert_eq!(bv.update(candle(102.0, 100.0, 1_000.0)), None);
235    }
236
237    #[test]
238    fn batch_equals_streaming() {
239        let candles: Vec<Candle> = (0..120)
240            .map(|i| {
241                let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
242                candle(
243                    base + 2.0,
244                    base - 1.5,
245                    1_000.0 + (f64::from(i) * 0.5).cos() * 400.0,
246                )
247            })
248            .collect();
249        let batch = BetterVolume::new(20).unwrap().batch(&candles);
250        let mut b = BetterVolume::new(20).unwrap();
251        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
252        assert_eq!(batch, streamed);
253    }
254}