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