Skip to main content

wickra_core/indicators/
vwap.rs

1//! Volume-Weighted Average Price (VWAP).
2//!
3//! Two variants are offered: a cumulative `Vwap` that runs forever (the
4//! intraday convention), and a rolling-window `RollingVwap` for streaming bots
5//! that need a finite-memory price benchmark.
6
7use std::collections::VecDeque;
8
9use crate::error::{Error, Result};
10use crate::indicators::rolling_moments::RollingSum;
11use crate::ohlcv::Candle;
12use crate::traits::Indicator;
13
14/// Cumulative session VWAP. Call [`Indicator::reset`] at the start of each
15/// session (e.g. trading-day boundary) to restart the accumulation.
16///
17/// # Example
18///
19/// ```
20/// use wickra_core::{Candle, Indicator, Vwap};
21///
22/// let mut indicator = Vwap::new();
23/// let mut last = None;
24/// for i in 0..80 {
25///     let base = 100.0 + f64::from(i);
26///     let candle =
27///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
28///     last = indicator.update(candle);
29/// }
30/// assert!(last.is_some());
31/// ```
32#[derive(Debug, Clone, Default)]
33pub struct Vwap {
34    sum_pv: f64,
35    sum_v: f64,
36    has_emitted: bool,
37}
38
39impl Vwap {
40    /// Construct a fresh cumulative VWAP.
41    pub const fn new() -> Self {
42        Self {
43            sum_pv: 0.0,
44            sum_v: 0.0,
45            has_emitted: false,
46        }
47    }
48
49    /// Current VWAP if at least one candle with non-zero volume has been observed.
50    pub fn value(&self) -> Option<f64> {
51        if self.sum_v == 0.0 {
52            None
53        } else {
54            Some(self.sum_pv / self.sum_v)
55        }
56    }
57}
58
59impl Indicator for Vwap {
60    type Input = Candle;
61    type Output = f64;
62
63    #[inline]
64    fn update(&mut self, candle: Candle) -> Option<f64> {
65        let tp = candle.typical_price();
66        self.sum_pv += tp * candle.volume;
67        self.sum_v += candle.volume;
68        if self.sum_v == 0.0 {
69            return None;
70        }
71        self.has_emitted = true;
72        Some(self.sum_pv / self.sum_v)
73    }
74
75    fn reset(&mut self) {
76        self.sum_pv = 0.0;
77        self.sum_v = 0.0;
78        self.has_emitted = false;
79    }
80
81    #[inline]
82    fn warmup_period(&self) -> usize {
83        1
84    }
85
86    #[inline]
87    fn is_ready(&self) -> bool {
88        self.has_emitted
89    }
90
91    #[inline]
92    fn name(&self) -> &'static str {
93        "VWAP"
94    }
95}
96
97/// Rolling-window VWAP: a finite-memory variant for bots that don't want
98/// unbounded accumulation.
99///
100/// # Example
101///
102/// ```
103/// use wickra_core::{Candle, Indicator, RollingVwap};
104///
105/// let mut indicator = RollingVwap::new(5).unwrap();
106/// let mut last = None;
107/// for i in 0..80 {
108///     let base = 100.0 + f64::from(i);
109///     let candle =
110///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
111///     last = indicator.update(candle);
112/// }
113/// assert!(last.is_some());
114/// ```
115#[derive(Debug, Clone)]
116pub struct RollingVwap {
117    period: usize,
118    window: VecDeque<(f64, f64)>, // (typical_price * volume, volume)
119    sum_pv: RollingSum,
120    sum_v: RollingSum,
121}
122
123impl RollingVwap {
124    /// # Errors
125    /// Returns [`Error::PeriodZero`] if `period == 0`.
126    pub fn new(period: usize) -> Result<Self> {
127        if period == 0 {
128            return Err(Error::PeriodZero);
129        }
130        if period > crate::error::MAX_PERIOD {
131            return Err(Error::InvalidPeriod {
132                message: crate::error::PERIOD_ABOVE_MAX,
133            });
134        }
135        Ok(Self {
136            period,
137            window: VecDeque::with_capacity(period),
138            sum_pv: RollingSum::new(),
139            sum_v: RollingSum::new(),
140        })
141    }
142
143    /// Configured rolling window length.
144    pub const fn period(&self) -> usize {
145        self.period
146    }
147}
148
149impl Indicator for RollingVwap {
150    type Input = Candle;
151    type Output = f64;
152
153    #[inline]
154    fn update(&mut self, candle: Candle) -> Option<f64> {
155        let pv = candle.typical_price() * candle.volume;
156        if self.window.len() == self.period {
157            let (old_pv, old_v) = self.window.pop_front().expect("non-empty");
158            self.sum_pv.evict(old_pv);
159            self.sum_v.evict(old_v);
160        }
161        self.window.push_back((pv, candle.volume));
162        self.sum_pv.push(pv);
163        self.sum_v.push(candle.volume);
164        if self.sum_pv.needs_reseed(self.period) {
165            self.sum_pv.reseed(self.window.iter().map(|&(p, _)| p));
166            self.sum_v.reseed(self.window.iter().map(|&(_, v)| v));
167        }
168        if self.window.len() < self.period || self.sum_v.value() == 0.0 {
169            return None;
170        }
171        Some(self.sum_pv.value() / self.sum_v.value())
172    }
173
174    fn reset(&mut self) {
175        self.window.clear();
176        self.sum_pv.reset();
177        self.sum_v.reset();
178    }
179
180    #[inline]
181    fn warmup_period(&self) -> usize {
182        self.period
183    }
184
185    #[inline]
186    fn is_ready(&self) -> bool {
187        self.window.len() == self.period && self.sum_v.value() > 0.0
188    }
189
190    #[inline]
191    fn name(&self) -> &'static str {
192        "RollingVWAP"
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::traits::BatchExt;
200    use approx::assert_relative_eq;
201
202    fn c(price: f64, volume: f64) -> Candle {
203        Candle::new(price, price, price, price, volume, 0).unwrap()
204    }
205
206    #[test]
207    fn cumulative_vwap_equal_volumes_equals_mean() {
208        let candles = vec![c(10.0, 1.0), c(20.0, 1.0), c(30.0, 1.0)];
209        let mut v = Vwap::new();
210        let out = v.batch(&candles);
211        assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-12);
212    }
213
214    /// Cover the `Some` branch of `Vwap::value()` (line 53). The only other
215    /// test that calls `value()` is `cumulative_reset_clears_state`, which
216    /// calls it after `reset()` so `sum_v == 0` and the `None` branch fires.
217    #[test]
218    fn cumulative_value_some_branch_after_update() {
219        let mut v = Vwap::new();
220        // typical_price of a flat OHLC bar equals the price itself.
221        v.update(c(42.0, 5.0));
222        assert_relative_eq!(v.value().expect("non-zero volume"), 42.0, epsilon = 1e-12);
223    }
224
225    /// Cover the `return None` early-out inside `Vwap::update` (line 67),
226    /// reached when the running `sum_v` is still 0 after adding the latest
227    /// candle's volume — i.e. the first candle has volume 0. Existing tests
228    /// only use strictly positive volumes, so the early-return never fired.
229    #[test]
230    fn cumulative_zero_volume_first_candle_returns_none() {
231        let mut v = Vwap::new();
232        let out = v.update(c(42.0, 0.0));
233        assert_eq!(out, None);
234        assert!(!v.is_ready());
235        // Adding a non-zero candle afterwards still works as expected.
236        let out2 = v.update(c(10.0, 4.0));
237        assert_relative_eq!(out2.expect("now warmed"), 10.0, epsilon = 1e-12);
238    }
239
240    /// Cover the cumulative `Vwap` Indicator-impl metadata: `warmup_period`
241    /// (lines 79-81) and `name` (lines 87-89). Existing tests inspected
242    /// only the numeric output, never the metadata surface.
243    #[test]
244    fn cumulative_metadata() {
245        let v = Vwap::new();
246        assert_eq!(v.warmup_period(), 1);
247        assert_eq!(v.name(), "VWAP");
248    }
249
250    #[test]
251    fn cumulative_vwap_weighted() {
252        // Two candles: 10@1 and 20@3 -> (10*1 + 20*3) / (1+3) = 70/4 = 17.5
253        let candles = vec![c(10.0, 1.0), c(20.0, 3.0)];
254        let mut v = Vwap::new();
255        let out = v.batch(&candles);
256        assert_relative_eq!(out[1].unwrap(), 17.5, epsilon = 1e-12);
257    }
258
259    /// Cover the `RollingVwap` accessors and metadata: `period`
260    /// (lines 134-136), `warmup_period` (165-167), `name` (173-175).
261    /// Existing rolling tests called `update`/`batch`/`reset`/`is_ready`
262    /// only, never queried the configuration or metadata.
263    #[test]
264    fn rolling_accessors_and_metadata() {
265        let v = RollingVwap::new(7).unwrap();
266        assert_eq!(v.period(), 7);
267        assert_eq!(v.warmup_period(), 7);
268        assert_eq!(v.name(), "RollingVWAP");
269    }
270
271    #[test]
272    fn rolling_vwap_window_slides() {
273        let candles = vec![c(10.0, 1.0), c(20.0, 1.0), c(30.0, 1.0), c(40.0, 1.0)];
274        let mut v = RollingVwap::new(3).unwrap();
275        let out = v.batch(&candles);
276        assert!(out[1].is_none());
277        // index 2 -> (10+20+30)/3 = 20
278        assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-12);
279        // index 3 -> (20+30+40)/3 = 30
280        assert_relative_eq!(out[3].unwrap(), 30.0, epsilon = 1e-12);
281    }
282
283    #[test]
284    fn batch_equals_streaming_cumulative() {
285        let candles: Vec<Candle> = (1..20).map(|i| c(f64::from(i), 1.0)).collect();
286        let mut a = Vwap::new();
287        let mut b = Vwap::new();
288        assert_eq!(
289            a.batch(&candles),
290            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
291        );
292    }
293
294    #[test]
295    fn batch_equals_streaming_rolling() {
296        let candles: Vec<Candle> = (1..30)
297            .map(|i| c(f64::from(i), f64::from(i % 5 + 1)))
298            .collect();
299        let mut a = RollingVwap::new(10).unwrap();
300        let mut b = RollingVwap::new(10).unwrap();
301        assert_eq!(
302            a.batch(&candles),
303            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
304        );
305    }
306
307    #[test]
308    fn rolling_rejects_zero_period() {
309        assert!(RollingVwap::new(0).is_err());
310    }
311
312    #[test]
313    fn cumulative_reset_clears_state() {
314        let candles = vec![c(10.0, 1.0), c(20.0, 1.0), c(30.0, 1.0)];
315        let mut v = Vwap::new();
316        v.batch(&candles);
317        assert!(v.is_ready());
318        v.reset();
319        assert!(!v.is_ready());
320        assert_eq!(v.value(), None);
321    }
322
323    #[test]
324    fn rolling_reset_clears_state() {
325        let candles: Vec<Candle> = (1..=10).map(|i| c(f64::from(i), 1.0)).collect();
326        let mut v = RollingVwap::new(5).unwrap();
327        v.batch(&candles);
328        assert!(v.is_ready());
329        v.reset();
330        assert!(!v.is_ready());
331        assert_eq!(v.update(candles[0]), None);
332    }
333}