Skip to main content

mantis_ta/indicators/volume/
vwap.rs

1use crate::indicators::Indicator;
2use crate::types::Candle;
3use crate::utils::ringbuf::RingBuf;
4
5/// Volume Weighted Average Price over a rolling window.
6///
7/// Typical Price = (High + Low + Close) / 3
8/// VWAP = sum(Typical Price * Volume) / sum(Volume) over the last `period` bars.
9///
10/// TA-Lib has no native VWAP function; VWAP is conventionally a session-anchored
11/// cumulative average, but `Candle` carries no session boundary. This
12/// implementation instead uses a fixed rolling window (matching the convention
13/// of this crate's other windowed indicators, e.g. `ATR`, `VolumeSMA`), so it
14/// re-centers continuously rather than resetting at a session start.
15///
16/// # Examples
17/// ```rust
18/// use mantis_ta::indicators::{Indicator, VWAP};
19/// use mantis_ta::types::Candle;
20///
21/// let candles: Vec<Candle> = [
22///     (11.0, 9.0, 10.0, 100.0),
23///     (12.0, 10.0, 11.0, 200.0),
24///     (13.0, 11.0, 12.0, 300.0),
25/// ]
26/// .iter()
27/// .enumerate()
28/// .map(|(i, (h, l, c, v))| Candle {
29///     timestamp: i as i64,
30///     open: *c,
31///     high: *h,
32///     low: *l,
33///     close: *c,
34///     volume: *v,
35/// })
36/// .collect();
37///
38/// let out = VWAP::new(3).calculate(&candles);
39/// assert!(out.iter().take(2).all(|v| v.is_none()));
40/// // Typical prices: 10, 11, 12; volumes: 100, 200, 300.
41/// let expected = (10.0 * 100.0 + 11.0 * 200.0 + 12.0 * 300.0) / (100.0 + 200.0 + 300.0);
42/// assert!((out[2].unwrap() - expected).abs() < 1e-9);
43/// ```
44#[derive(Debug, Clone)]
45pub struct VWAP {
46    period: usize,
47    window: RingBuf<(f64, f64)>,
48    sum_tp_vol: f64,
49    sum_vol: f64,
50}
51
52impl VWAP {
53    pub fn new(period: usize) -> Self {
54        assert!(period > 0, "period must be > 0");
55        Self {
56            period,
57            window: RingBuf::new(period, (0.0, 0.0)),
58            sum_tp_vol: 0.0,
59            sum_vol: 0.0,
60        }
61    }
62
63    #[inline]
64    fn update(&mut self, high: f64, low: f64, close: f64, volume: f64) -> Option<f64> {
65        let typical_price = (high + low + close) / 3.0;
66        let tp_vol = typical_price * volume;
67
68        if let Some((old_tp_vol, old_vol)) = self.window.push((tp_vol, volume)) {
69            self.sum_tp_vol -= old_tp_vol;
70            self.sum_vol -= old_vol;
71        }
72        self.sum_tp_vol += tp_vol;
73        self.sum_vol += volume;
74
75        if self.window.len() < self.period {
76            None
77        } else if self.sum_vol == 0.0 {
78            Some(typical_price)
79        } else {
80            Some(self.sum_tp_vol / self.sum_vol)
81        }
82    }
83}
84
85impl Indicator for VWAP {
86    type Output = f64;
87
88    fn next(&mut self, candle: &Candle) -> Option<Self::Output> {
89        self.update(candle.high, candle.low, candle.close, candle.volume)
90    }
91
92    fn reset(&mut self) {
93        self.window = RingBuf::new(self.period, (0.0, 0.0));
94        self.sum_tp_vol = 0.0;
95        self.sum_vol = 0.0;
96    }
97
98    fn warmup_period(&self) -> usize {
99        self.period
100    }
101
102    fn clone_boxed(&self) -> Box<dyn Indicator<Output = Self::Output>> {
103        Box::new(self.clone())
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    fn candle(h: f64, l: f64, c: f64, v: f64) -> Candle {
112        Candle {
113            timestamp: 0,
114            open: c,
115            high: h,
116            low: l,
117            close: c,
118            volume: v,
119        }
120    }
121
122    #[test]
123    fn vwap_warmup_period_is_period() {
124        let vwap = VWAP::new(14);
125        assert_eq!(vwap.warmup_period(), 14);
126    }
127
128    #[test]
129    fn vwap_emits_none_until_warmup() {
130        let mut vwap = VWAP::new(3);
131        let candles = vec![
132            candle(11.0, 9.0, 10.0, 100.0),
133            candle(12.0, 10.0, 11.0, 110.0),
134        ];
135        let outputs: Vec<_> = candles.iter().map(|c| vwap.next(c)).collect();
136        assert!(outputs.iter().all(|v| v.is_none()));
137    }
138
139    #[test]
140    fn vwap_matches_manual_calculation() {
141        let mut vwap = VWAP::new(3);
142        let candles = vec![
143            candle(11.0, 9.0, 10.0, 100.0),
144            candle(12.0, 10.0, 11.0, 200.0),
145            candle(13.0, 11.0, 12.0, 300.0),
146        ];
147        let outputs: Vec<_> = candles.iter().map(|c| vwap.next(c)).collect();
148
149        let expected = (10.0 * 100.0 + 11.0 * 200.0 + 12.0 * 300.0) / (100.0 + 200.0 + 300.0);
150        assert!((outputs[2].unwrap() - expected).abs() < 1e-9);
151    }
152
153    #[test]
154    fn vwap_flat_prices_equals_typical_price() {
155        let mut vwap = VWAP::new(3);
156        let candles = vec![candle(51.0, 49.0, 50.0, 100.0); 3];
157        let outputs: Vec<_> = candles.iter().map(|c| vwap.next(c)).collect();
158        assert!((outputs[2].unwrap() - 50.0).abs() < 1e-9);
159    }
160
161    #[test]
162    fn vwap_zero_volume_window_falls_back_to_typical_price() {
163        let mut vwap = VWAP::new(2);
164        let candles = vec![candle(11.0, 9.0, 10.0, 0.0), candle(21.0, 19.0, 20.0, 0.0)];
165        let outputs: Vec<_> = candles.iter().map(|c| vwap.next(c)).collect();
166        assert!((outputs[1].unwrap() - 20.0).abs() < 1e-9);
167    }
168
169    #[test]
170    fn vwap_bounded_by_window_extremes() {
171        // VWAP is a weighted average, so it must sit within [min typical price, max typical price]
172        // of the window, regardless of volume weighting.
173        let mut vwap = VWAP::new(4);
174        let candles = vec![
175            candle(11.0, 9.0, 10.0, 50.0),
176            candle(21.0, 19.0, 20.0, 500.0),
177            candle(16.0, 14.0, 15.0, 10.0),
178            candle(31.0, 29.0, 30.0, 200.0),
179        ];
180        let outputs: Vec<_> = candles.iter().map(|c| vwap.next(c)).collect();
181        let v = outputs[3].unwrap();
182        assert!((10.0..=30.0).contains(&v));
183    }
184
185    #[test]
186    fn vwap_streaming_matches_batch() {
187        let candles: Vec<Candle> = (0..15)
188            .map(|i| {
189                candle(
190                    101.0 + i as f64,
191                    99.0 + i as f64,
192                    100.0 + i as f64,
193                    1_000.0 + i as f64 * 10.0,
194                )
195            })
196            .collect();
197
198        let batch = VWAP::new(5).calculate(&candles);
199
200        let mut streamed_vwap = VWAP::new(5);
201        let streamed: Vec<_> = candles.iter().map(|c| streamed_vwap.next(c)).collect();
202
203        assert_eq!(streamed, batch);
204    }
205
206    #[test]
207    fn vwap_reset_clears_state() {
208        let mut vwap = VWAP::new(4);
209        let candles: Vec<Candle> = (0..6)
210            .map(|i| candle(101.0 + i as f64, 99.0 + i as f64, 100.0 + i as f64, 1_000.0))
211            .collect();
212        for c in &candles {
213            vwap.next(c);
214        }
215        vwap.reset();
216
217        let mut fresh = VWAP::new(4);
218        for c in &candles {
219            assert_eq!(vwap.next(c), fresh.next(c));
220        }
221    }
222
223    #[test]
224    #[should_panic(expected = "period must be > 0")]
225    fn vwap_rejects_zero_period() {
226        VWAP::new(0);
227    }
228}