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        if period > crate::error::MAX_PERIOD {
68            return Err(Error::InvalidPeriod {
69                message: crate::error::PERIOD_ABOVE_MAX,
70            });
71        }
72        Ok(Self {
73            period,
74            volumes: VecDeque::with_capacity(period),
75            ranges: VecDeque::with_capacity(period),
76            vol_sum: 0.0,
77            range_sum: 0.0,
78            last: None,
79        })
80    }
81
82    /// Configured averaging period.
83    pub const fn period(&self) -> usize {
84        self.period
85    }
86
87    /// Current value if available.
88    pub const fn value(&self) -> Option<f64> {
89        self.last
90    }
91}
92
93impl Indicator for BetterVolume {
94    type Input = Candle;
95    type Output = f64;
96
97    #[inline]
98    fn update(&mut self, candle: Candle) -> Option<f64> {
99        let range = candle.high - candle.low;
100        if self.volumes.len() == self.period {
101            self.vol_sum -= self.volumes.pop_front().expect("non-empty");
102            self.range_sum -= self.ranges.pop_front().expect("non-empty");
103        }
104        self.volumes.push_back(candle.volume);
105        self.ranges.push_back(range);
106        self.vol_sum += candle.volume;
107        self.range_sum += range;
108        if self.volumes.len() < self.period {
109            return None;
110        }
111        let n = self.period as f64;
112        let sma_vol = self.vol_sum / n;
113        let sma_range = self.range_sum / n;
114        let rel_vol = if sma_vol > 0.0 {
115            candle.volume / sma_vol
116        } else {
117            0.0
118        };
119        let rel_range = if sma_range > 0.0 {
120            range / sma_range
121        } else {
122            0.0
123        };
124        let out = rel_vol - rel_range;
125        self.last = Some(out);
126        Some(out)
127    }
128
129    fn reset(&mut self) {
130        self.volumes.clear();
131        self.ranges.clear();
132        self.vol_sum = 0.0;
133        self.range_sum = 0.0;
134        self.last = None;
135    }
136
137    #[inline]
138    fn warmup_period(&self) -> usize {
139        self.period
140    }
141
142    #[inline]
143    fn is_ready(&self) -> bool {
144        self.last.is_some()
145    }
146
147    #[inline]
148    fn name(&self) -> &'static str {
149        "BetterVolume"
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::traits::BatchExt;
157    use approx::assert_relative_eq;
158
159    fn candle(high: f64, low: f64, volume: f64) -> Candle {
160        Candle::new_unchecked(low, high, low, high, volume, 0)
161    }
162
163    #[test]
164    fn rejects_zero_period() {
165        assert!(matches!(BetterVolume::new(0), Err(Error::PeriodZero)));
166    }
167
168    #[test]
169    fn accessors_and_metadata() {
170        let bv = BetterVolume::new(20).unwrap();
171        assert_eq!(bv.period(), 20);
172        assert_eq!(bv.warmup_period(), 20);
173        assert_eq!(bv.name(), "BetterVolume");
174        assert!(!bv.is_ready());
175        assert_eq!(bv.value(), None);
176    }
177
178    #[test]
179    fn first_emission_at_warmup_period() {
180        let mut bv = BetterVolume::new(3).unwrap();
181        let candles: Vec<Candle> = (0..6).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
182        let out = bv.batch(&candles);
183        for v in out.iter().take(2) {
184            assert!(v.is_none());
185        }
186        assert!(out[2].is_some());
187    }
188
189    #[test]
190    fn steady_bars_are_neutral() {
191        // Identical volume and range every bar -> rel_vol = rel_range = 1 -> 0.
192        let mut bv = BetterVolume::new(4).unwrap();
193        let candles: Vec<Candle> = (0..10).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
194        let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
195        assert_relative_eq!(last, 0.0, epsilon = 1e-9);
196    }
197
198    #[test]
199    fn churn_bar_is_positive() {
200        // Three normal bars, then a high-volume narrow-range bar -> positive.
201        let mut bv = BetterVolume::new(4).unwrap();
202        let mut candles: Vec<Candle> = (0..3).map(|_| candle(105.0, 100.0, 1_000.0)).collect();
203        candles.push(candle(100.5, 100.0, 5_000.0)); // huge volume, tiny range
204        let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
205        assert!(last > 0.0, "churn bar should be positive, got {last}");
206    }
207
208    #[test]
209    fn ease_of_movement_bar_is_negative() {
210        // Three normal bars, then a wide-range light-volume bar -> negative.
211        let mut bv = BetterVolume::new(4).unwrap();
212        let mut candles: Vec<Candle> = (0..3).map(|_| candle(101.0, 100.0, 5_000.0)).collect();
213        candles.push(candle(115.0, 100.0, 500.0)); // wide range, tiny volume
214        let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
215        assert!(
216            last < 0.0,
217            "ease-of-movement bar should be negative, got {last}"
218        );
219    }
220
221    #[test]
222    fn zero_everything_is_zero() {
223        // Zero volume and zero range -> both legs guarded to 0.
224        let mut bv = BetterVolume::new(3).unwrap();
225        let candles: Vec<Candle> = (0..6).map(|_| candle(100.0, 100.0, 0.0)).collect();
226        for v in bv.batch(&candles).into_iter().flatten() {
227            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
228        }
229    }
230
231    #[test]
232    fn reset_clears_state() {
233        let mut bv = BetterVolume::new(3).unwrap();
234        bv.batch(
235            &(0..6)
236                .map(|_| candle(102.0, 100.0, 1_000.0))
237                .collect::<Vec<_>>(),
238        );
239        assert!(bv.is_ready());
240        bv.reset();
241        assert!(!bv.is_ready());
242        assert_eq!(bv.value(), None);
243        assert_eq!(bv.update(candle(102.0, 100.0, 1_000.0)), None);
244    }
245
246    #[test]
247    fn batch_equals_streaming() {
248        let candles: Vec<Candle> = (0..120)
249            .map(|i| {
250                let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
251                candle(
252                    base + 2.0,
253                    base - 1.5,
254                    1_000.0 + (f64::from(i) * 0.5).cos() * 400.0,
255                )
256            })
257            .collect();
258        let batch = BetterVolume::new(20).unwrap().batch(&candles);
259        let mut b = BetterVolume::new(20).unwrap();
260        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
261        assert_eq!(batch, streamed);
262    }
263}