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