Skip to main content

wickra_core/indicators/
vwma.rs

1//! Volume-Weighted Moving Average.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::RollingSum;
7use crate::ohlcv::Candle;
8use crate::traits::Indicator;
9
10/// Volume-Weighted Moving Average over a rolling window of `period` candles.
11///
12/// Each close is weighted by its own bar volume:
13///
14/// ```text
15/// VWMA_t = Σ(close_i · volume_i) / Σ(volume_i)   over the last `period` bars
16/// ```
17///
18/// High-volume bars pull the average toward their close, so VWMA reacts to
19/// price moves that the market actually participated in and largely ignores
20/// thin, low-conviction bars.
21///
22/// If every candle in the window has zero volume the weighted mean is
23/// undefined; the indicator then falls back to the **unweighted** mean of the
24/// `period` closes, so the output is always finite. The first output lands
25/// after exactly `period` candles.
26///
27/// # Example
28///
29/// ```
30/// use wickra_core::{Candle, Indicator, Vwma};
31///
32/// let mut indicator = Vwma::new(5).unwrap();
33/// let mut last = None;
34/// for i in 0..40 {
35///     let p = 100.0 + f64::from(i);
36///     let candle = Candle::new(p, p + 1.0, p - 1.0, p, 10.0, i64::from(i)).unwrap();
37///     last = indicator.update(candle);
38/// }
39/// assert!(last.is_some());
40/// ```
41#[derive(Debug, Clone)]
42pub struct Vwma {
43    period: usize,
44    /// Rolling window of `(close, volume)` pairs, oldest at the front.
45    window: VecDeque<(f64, f64)>,
46    sum_pv: RollingSum,
47    sum_v: RollingSum,
48    sum_close: RollingSum,
49    current: Option<f64>,
50}
51
52impl Vwma {
53    /// Construct a new VWMA with the given period.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`Error::PeriodZero`] if `period == 0`.
58    pub fn new(period: usize) -> Result<Self> {
59        if period == 0 {
60            return Err(Error::PeriodZero);
61        }
62        if period > crate::error::MAX_PERIOD {
63            return Err(Error::InvalidPeriod {
64                message: crate::error::PERIOD_ABOVE_MAX,
65            });
66        }
67        Ok(Self {
68            period,
69            window: VecDeque::with_capacity(period),
70            sum_pv: RollingSum::new(),
71            sum_v: RollingSum::new(),
72            sum_close: RollingSum::new(),
73            current: None,
74        })
75    }
76
77    /// Configured 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.current
85    }
86}
87
88impl Indicator for Vwma {
89    type Input = Candle;
90    type Output = f64;
91
92    #[inline]
93    fn update(&mut self, candle: Candle) -> Option<f64> {
94        let close = candle.close;
95        let volume = candle.volume;
96        if self.window.len() == self.period {
97            let (old_close, old_volume) = self.window.pop_front().expect("window is non-empty");
98            self.sum_pv.evict(old_close * old_volume);
99            self.sum_v.evict(old_volume);
100            self.sum_close.evict(old_close);
101        }
102        self.window.push_back((close, volume));
103        self.sum_pv.push(close * volume);
104        self.sum_v.push(volume);
105        self.sum_close.push(close);
106        if self.sum_pv.needs_reseed(self.period) {
107            self.sum_pv.reseed(self.window.iter().map(|&(c, v)| c * v));
108            self.sum_v.reseed(self.window.iter().map(|&(_, v)| v));
109            self.sum_close.reseed(self.window.iter().map(|&(c, _)| c));
110        }
111        if self.window.len() < self.period {
112            return None;
113        }
114        let value = if self.sum_v.value() > 0.0 {
115            self.sum_pv.value() / self.sum_v.value()
116        } else {
117            // Degenerate window: every bar had zero volume. Fall back to the
118            // plain mean of the closes so the output stays finite.
119            self.sum_close.value() / self.period as f64
120        };
121        self.current = Some(value);
122        Some(value)
123    }
124
125    fn reset(&mut self) {
126        self.window.clear();
127        self.sum_pv.reset();
128        self.sum_v.reset();
129        self.sum_close.reset();
130        self.current = None;
131    }
132
133    #[inline]
134    fn warmup_period(&self) -> usize {
135        self.period
136    }
137
138    #[inline]
139    fn is_ready(&self) -> bool {
140        self.current.is_some()
141    }
142
143    #[inline]
144    fn name(&self) -> &'static str {
145        "VWMA"
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::traits::BatchExt;
153    use approx::assert_relative_eq;
154
155    /// Build a flat candle with a given close and volume.
156    fn candle(close: f64, volume: f64, ts: i64) -> Candle {
157        Candle::new(close, close, close, close, volume, ts).unwrap()
158    }
159
160    #[test]
161    fn new_rejects_zero_period() {
162        assert!(matches!(Vwma::new(0), Err(Error::PeriodZero)));
163    }
164
165    /// Cover the const accessors `period` / `value` (72-79) and the
166    /// Indicator-impl `name` body (129-131). Existing tests inspect
167    /// VWMA output but never query the metadata.
168    #[test]
169    fn accessors_and_metadata() {
170        let mut v = Vwma::new(5).unwrap();
171        assert_eq!(v.period(), 5);
172        assert_eq!(v.name(), "VWMA");
173        assert_eq!(v.value(), None);
174        for i in 1..=5i64 {
175            let p = 100.0 + i as f64;
176            v.update(Candle::new(p, p, p, p, 1.0, i).unwrap());
177        }
178        assert!(v.value().is_some());
179    }
180
181    #[test]
182    fn reference_value() {
183        // VWMA(2): (10·1 + 20·3) / (1 + 3) = 70 / 4 = 17.5.
184        let mut vwma = Vwma::new(2).unwrap();
185        assert_eq!(vwma.update(candle(10.0, 1.0, 0)), None);
186        assert_relative_eq!(
187            vwma.update(candle(20.0, 3.0, 1)).unwrap(),
188            17.5,
189            epsilon = 1e-12
190        );
191        // Window slides: (20·3 + 30·1) / (3 + 1) = 90 / 4 = 22.5.
192        assert_relative_eq!(
193            vwma.update(candle(30.0, 1.0, 2)).unwrap(),
194            22.5,
195            epsilon = 1e-12
196        );
197    }
198
199    #[test]
200    fn zero_volume_window_falls_back_to_unweighted_mean() {
201        let mut vwma = Vwma::new(2).unwrap();
202        assert_eq!(vwma.update(candle(10.0, 0.0, 0)), None);
203        // Both bars have zero volume: fall back to mean(10, 20) = 15.
204        assert_relative_eq!(
205            vwma.update(candle(20.0, 0.0, 1)).unwrap(),
206            15.0,
207            epsilon = 1e-12
208        );
209    }
210
211    #[test]
212    fn constant_series_yields_the_constant() {
213        let mut vwma = Vwma::new(5).unwrap();
214        let candles: Vec<Candle> = (0..30).map(|i| candle(42.0, 3.0, i)).collect();
215        let out = vwma.batch(&candles);
216        for x in out.iter().skip(4).flatten() {
217            assert_relative_eq!(*x, 42.0, epsilon = 1e-12);
218        }
219    }
220
221    #[test]
222    fn high_volume_bar_pulls_the_average() {
223        // A heavy bar at a higher close drags VWMA above the simple mean.
224        let mut vwma = Vwma::new(3).unwrap();
225        vwma.update(candle(10.0, 1.0, 0));
226        vwma.update(candle(10.0, 1.0, 1));
227        let v = vwma.update(candle(20.0, 100.0, 2)).unwrap();
228        let simple_mean = (10.0 + 10.0 + 20.0) / 3.0;
229        assert!(
230            v > simple_mean,
231            "{v} should exceed simple mean {simple_mean}"
232        );
233    }
234
235    #[test]
236    fn first_emission_at_warmup_period() {
237        let mut vwma = Vwma::new(4).unwrap();
238        assert_eq!(vwma.warmup_period(), 4);
239        for i in 0..3 {
240            assert_eq!(vwma.update(candle(10.0, 1.0, i)), None);
241        }
242        assert!(vwma.update(candle(10.0, 1.0, 3)).is_some());
243    }
244
245    #[test]
246    fn reset_clears_state() {
247        let mut vwma = Vwma::new(3).unwrap();
248        let candles: Vec<Candle> = (0..10).map(|i| candle(10.0 + i as f64, 2.0, i)).collect();
249        vwma.batch(&candles);
250        assert!(vwma.is_ready());
251        vwma.reset();
252        assert!(!vwma.is_ready());
253        assert_eq!(vwma.update(candle(10.0, 1.0, 0)), None);
254    }
255
256    #[test]
257    fn batch_equals_streaming() {
258        let candles: Vec<Candle> = (0..50_i64)
259            .map(|i| {
260                let c = 100.0 + (i as f64 * 0.3).sin() * 8.0;
261                candle(c, 1.0 + (i % 7) as f64, i)
262            })
263            .collect();
264        let batch = Vwma::new(8).unwrap().batch(&candles);
265        let mut b = Vwma::new(8).unwrap();
266        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
267        assert_eq!(batch, streamed);
268    }
269}