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    /// Construct a Session VWAP indicator with the given UTC offset (minutes).
45    pub const fn new(utc_offset_minutes: i32) -> Self {
46        Self {
47            utc_offset_minutes,
48            day_key: None,
49            cum_pv: 0.0,
50            cum_volume: 0.0,
51            last: None,
52        }
53    }
54
55    /// Configured UTC offset in minutes.
56    pub const fn utc_offset_minutes(&self) -> i32 {
57        self.utc_offset_minutes
58    }
59
60    /// Most recent VWAP if at least one bar has been seen.
61    pub const fn value(&self) -> Option<f64> {
62        self.last
63    }
64}
65
66impl Indicator for SessionVwap {
67    type Input = Candle;
68    type Output = f64;
69
70    fn update(&mut self, candle: Candle) -> Option<f64> {
71        let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
72        let key = (civil.year, civil.month, civil.day);
73        if self.day_key != Some(key) {
74            self.day_key = Some(key);
75            self.cum_pv = 0.0;
76            self.cum_volume = 0.0;
77        }
78        let typical = (candle.high + candle.low + candle.close) / 3.0;
79        self.cum_pv += typical * candle.volume;
80        self.cum_volume += candle.volume;
81        let vwap = if self.cum_volume > 0.0 {
82            self.cum_pv / self.cum_volume
83        } else {
84            typical
85        };
86        self.last = Some(vwap);
87        Some(vwap)
88    }
89
90    fn reset(&mut self) {
91        self.day_key = None;
92        self.cum_pv = 0.0;
93        self.cum_volume = 0.0;
94        self.last = None;
95    }
96
97    fn warmup_period(&self) -> usize {
98        1
99    }
100
101    fn is_ready(&self) -> bool {
102        self.last.is_some()
103    }
104
105    fn name(&self) -> &'static str {
106        "SessionVwap"
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::traits::BatchExt;
114    use approx::assert_relative_eq;
115
116    const HOUR: i64 = 3_600_000;
117
118    fn c(price: f64, volume: f64, ts: i64) -> Candle {
119        Candle::new(price, price, price, price, volume, ts).unwrap()
120    }
121
122    #[test]
123    fn metadata_and_accessors() {
124        let vwap = SessionVwap::new(-480);
125        assert_eq!(vwap.utc_offset_minutes(), -480);
126        assert_eq!(vwap.name(), "SessionVwap");
127        assert_eq!(vwap.warmup_period(), 1);
128        assert!(!vwap.is_ready());
129        assert!(vwap.value().is_none());
130    }
131
132    #[test]
133    fn volume_weights_the_average() {
134        let mut vwap = SessionVwap::new(0);
135        let first = vwap.update(c(100.0, 10.0, 0)).unwrap();
136        assert_relative_eq!(first, 100.0);
137        assert!(vwap.is_ready());
138        let second = vwap.update(c(110.0, 30.0, HOUR)).unwrap();
139        assert_relative_eq!(second, 107.5);
140    }
141
142    #[test]
143    fn zero_volume_session_falls_back_to_typical() {
144        let mut vwap = SessionVwap::new(0);
145        let v = vwap.update(c(100.0, 0.0, 0)).unwrap();
146        assert_relative_eq!(v, 100.0);
147        let v2 = vwap.update(c(120.0, 0.0, HOUR)).unwrap();
148        assert_relative_eq!(v2, 120.0);
149    }
150
151    #[test]
152    fn re_anchors_on_new_day() {
153        let mut vwap = SessionVwap::new(0);
154        vwap.update(c(100.0, 10.0, 0));
155        vwap.update(c(110.0, 30.0, HOUR));
156        // New day: VWAP restarts from the first bar of day 2.
157        let next = vwap.update(c(200.0, 5.0, 24 * HOUR)).unwrap();
158        assert_relative_eq!(next, 200.0);
159    }
160
161    #[test]
162    fn typical_price_uses_high_low_close() {
163        let mut vwap = SessionVwap::new(0);
164        // typical = (120 + 90 + 102) / 3 = 104.
165        let candle = Candle::new(100.0, 120.0, 90.0, 102.0, 10.0, 0).unwrap();
166        let v = vwap.update(candle).unwrap();
167        assert_relative_eq!(v, 104.0);
168    }
169
170    #[test]
171    fn reset_clears_state() {
172        let mut vwap = SessionVwap::new(0);
173        vwap.update(c(100.0, 10.0, 0));
174        vwap.reset();
175        assert!(!vwap.is_ready());
176        assert!(vwap.value().is_none());
177        let after = vwap.update(c(50.0, 1.0, HOUR)).unwrap();
178        assert_relative_eq!(after, 50.0);
179    }
180
181    #[test]
182    fn batch_equals_streaming() {
183        let candles: Vec<Candle> = (0..30)
184            .map(|i| {
185                c(
186                    100.0 + f64::from(i),
187                    1.0 + f64::from(i % 4),
188                    i64::from(i) * HOUR,
189                )
190            })
191            .collect();
192        let mut a = SessionVwap::new(0);
193        let mut b = SessionVwap::new(0);
194        assert_eq!(
195            a.batch(&candles),
196            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
197        );
198    }
199}