Skip to main content

wickra_core/indicators/
session_vwap.rs

1//! Session VWAP — the volume-weighted average price accumulated since the start
2//! of the current calendar-day session, re-anchored automatically each day.
3
4use crate::calendar::civil_from_timestamp;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// Volume-weighted average price reset at each local day boundary.
9///
10/// Each bar contributes its typical price `(high + low + close) / 3` weighted by
11/// volume. The running VWAP is `Σ(typical · volume) / Σ volume` over the current
12/// session; if the session's volume is still zero the indicator falls back to the
13/// latest typical price so the output is always finite. The session boundary is
14/// the wall-clock day of [`Candle::timestamp`](crate::Candle) shifted by
15/// `utc_offset_minutes`.
16///
17/// Where [`crate::RollingVwap`] averages over a fixed bar window and
18/// [`crate::AnchoredVwap`] anchors at a caller-chosen bar, Session VWAP anchors
19/// at the automatically detected day open.
20///
21/// # Example
22///
23/// ```
24/// use wickra_core::{Candle, Indicator, SessionVwap};
25///
26/// let hour = 3_600_000;
27/// let mut vwap = SessionVwap::new(0);
28/// // typical = 100, volume 10.
29/// vwap.update(Candle::new(100.0, 100.0, 100.0, 100.0, 10.0, 0).unwrap());
30/// // typical = 110, volume 30 -> VWAP = (100*10 + 110*30) / 40 = 107.5.
31/// let v = vwap.update(Candle::new(110.0, 110.0, 110.0, 110.0, 30.0, hour).unwrap()).unwrap();
32/// assert!((v - 107.5).abs() < 1e-9);
33/// ```
34#[derive(Debug, Clone)]
35pub struct SessionVwap {
36    utc_offset_minutes: i32,
37    day_key: Option<(i64, u32, u32)>,
38    cum_pv: f64,
39    cum_volume: f64,
40    last: Option<f64>,
41}
42
43impl SessionVwap {
44    ///
45    /// The offset is a constant and does not follow daylight saving: for a
46    /// venue that observes it, one value is correct for part of the year and an
47    /// hour out for the rest, which shifts every session boundary by an hour.
48    /// Either pass the offset in force for the span being analysed and keep
49    /// spans that cross a transition apart, or convert the timestamps to the
50    /// venue's wall clock upstream and pass `0`.
51    /// Construct a Session VWAP indicator with the given UTC offset (minutes).
52    pub const fn new(utc_offset_minutes: i32) -> Self {
53        Self {
54            utc_offset_minutes,
55            day_key: None,
56            cum_pv: 0.0,
57            cum_volume: 0.0,
58            last: None,
59        }
60    }
61
62    /// Configured UTC offset in minutes.
63    pub const fn utc_offset_minutes(&self) -> i32 {
64        self.utc_offset_minutes
65    }
66
67    /// Most recent VWAP if at least one bar has been seen.
68    pub const fn value(&self) -> Option<f64> {
69        self.last
70    }
71}
72
73impl Indicator for SessionVwap {
74    type Input = Candle;
75    type Output = f64;
76
77    #[inline]
78    fn update(&mut self, candle: Candle) -> Option<f64> {
79        let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
80        let key = (civil.year, civil.month, civil.day);
81        if self.day_key != Some(key) {
82            self.day_key = Some(key);
83            self.cum_pv = 0.0;
84            self.cum_volume = 0.0;
85        }
86        let typical = (candle.high + candle.low + candle.close) / 3.0;
87        self.cum_pv += typical * candle.volume;
88        self.cum_volume += candle.volume;
89        let vwap = if self.cum_volume > 0.0 {
90            self.cum_pv / self.cum_volume
91        } else {
92            typical
93        };
94        self.last = Some(vwap);
95        Some(vwap)
96    }
97
98    fn reset(&mut self) {
99        self.day_key = None;
100        self.cum_pv = 0.0;
101        self.cum_volume = 0.0;
102        self.last = None;
103    }
104
105    #[inline]
106    fn warmup_period(&self) -> usize {
107        1
108    }
109
110    #[inline]
111    fn is_ready(&self) -> bool {
112        self.last.is_some()
113    }
114
115    #[inline]
116    fn name(&self) -> &'static str {
117        "SessionVwap"
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::traits::BatchExt;
125    use approx::assert_relative_eq;
126
127    const HOUR: i64 = 3_600_000;
128
129    fn c(price: f64, volume: f64, ts: i64) -> Candle {
130        Candle::new(price, price, price, price, volume, ts).unwrap()
131    }
132
133    #[test]
134    fn metadata_and_accessors() {
135        let vwap = SessionVwap::new(-480);
136        assert_eq!(vwap.utc_offset_minutes(), -480);
137        assert_eq!(vwap.name(), "SessionVwap");
138        assert_eq!(vwap.warmup_period(), 1);
139        assert!(!vwap.is_ready());
140        assert!(vwap.value().is_none());
141    }
142
143    #[test]
144    fn volume_weights_the_average() {
145        let mut vwap = SessionVwap::new(0);
146        let first = vwap.update(c(100.0, 10.0, 0)).unwrap();
147        assert_relative_eq!(first, 100.0);
148        assert!(vwap.is_ready());
149        let second = vwap.update(c(110.0, 30.0, HOUR)).unwrap();
150        assert_relative_eq!(second, 107.5);
151    }
152
153    #[test]
154    fn zero_volume_session_falls_back_to_typical() {
155        let mut vwap = SessionVwap::new(0);
156        let v = vwap.update(c(100.0, 0.0, 0)).unwrap();
157        assert_relative_eq!(v, 100.0);
158        let v2 = vwap.update(c(120.0, 0.0, HOUR)).unwrap();
159        assert_relative_eq!(v2, 120.0);
160    }
161
162    #[test]
163    fn re_anchors_on_new_day() {
164        let mut vwap = SessionVwap::new(0);
165        vwap.update(c(100.0, 10.0, 0));
166        vwap.update(c(110.0, 30.0, HOUR));
167        // New day: VWAP restarts from the first bar of day 2.
168        let next = vwap.update(c(200.0, 5.0, 24 * HOUR)).unwrap();
169        assert_relative_eq!(next, 200.0);
170    }
171
172    #[test]
173    fn typical_price_uses_high_low_close() {
174        let mut vwap = SessionVwap::new(0);
175        // typical = (120 + 90 + 102) / 3 = 104.
176        let candle = Candle::new(100.0, 120.0, 90.0, 102.0, 10.0, 0).unwrap();
177        let v = vwap.update(candle).unwrap();
178        assert_relative_eq!(v, 104.0);
179    }
180
181    #[test]
182    fn reset_clears_state() {
183        let mut vwap = SessionVwap::new(0);
184        vwap.update(c(100.0, 10.0, 0));
185        vwap.reset();
186        assert!(!vwap.is_ready());
187        assert!(vwap.value().is_none());
188        let after = vwap.update(c(50.0, 1.0, HOUR)).unwrap();
189        assert_relative_eq!(after, 50.0);
190    }
191
192    #[test]
193    fn batch_equals_streaming() {
194        let candles: Vec<Candle> = (0..30)
195            .map(|i| {
196                c(
197                    100.0 + f64::from(i),
198                    1.0 + f64::from(i % 4),
199                    i64::from(i) * HOUR,
200                )
201            })
202            .collect();
203        let mut a = SessionVwap::new(0);
204        let mut b = SessionVwap::new(0);
205        assert_eq!(
206            a.batch(&candles),
207            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
208        );
209    }
210}