Skip to main content

kestrel_chartkit/indicator/
vwap.rs

1use std::collections::HashMap;
2use std::collections::VecDeque;
3
4use crate::model::Bar;
5
6use super::{Indicator, IndicatorAlert, IndicatorOutput};
7
8/// Rolling Volume Weighted Average Price with standard-deviation bands and slope.
9///
10/// Over the last `window` bars, `VWAP = sum(typical * volume) / sum(volume)` with
11/// `typical = (high + low + close) / 3`; no output while the window holds no volume.
12/// `extra["sigma"]` is the volume-weighted standard deviation of the typical prices around it,
13/// `sqrt(sum(volume * (typical - VWAP)^2) / sum(volume))`, with bands at one and two sigma
14/// (`upper_1sigma` .. `lower_2sigma`). `extra["slope"]` is `(VWAP - VWAP_{t-k}) / k` for
15/// `k = slope_lookback`, present once `k + 1` values exist. First output: with the first bar that
16/// carries volume.
17///
18/// Note: this is a rolling VWAP over `window` bars, not a session-anchored VWAP — the
19/// `Bar` model carries no session-boundary marker, so a true session-reset VWAP needs to be
20/// driven by the consumer (e.g. calling `reset()` on session open). See plan Anhang A,
21/// "Designfrage für die spätere Umsetzung: Session-Fenster als Parameter".
22pub struct Vwap {
23    window: usize,
24    slope_lookback: usize,
25    prices_x_volume: VecDeque<f64>,
26    volumes: VecDeque<f64>,
27    vwap_history: VecDeque<f64>,
28}
29
30impl Vwap {
31    pub fn new(window: usize, slope_lookback: usize) -> Self {
32        Self {
33            window,
34            slope_lookback,
35            prices_x_volume: VecDeque::new(),
36            volumes: VecDeque::new(),
37            vwap_history: VecDeque::new(),
38        }
39    }
40
41    /// ~one RTH session at 1-minute bars.
42    pub fn with_defaults() -> Self {
43        Self::new(390, 20)
44    }
45}
46
47impl Indicator for Vwap {
48    fn name(&self) -> &str {
49        "vwap"
50    }
51
52    fn warmup_period(&self) -> usize {
53        1
54    }
55
56    fn reset(&mut self) {
57        self.prices_x_volume.clear();
58        self.volumes.clear();
59        self.vwap_history.clear();
60    }
61
62    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
63        let typical = bar.typical_price();
64        self.prices_x_volume.push_back(typical * bar.volume);
65        self.volumes.push_back(bar.volume);
66        if self.prices_x_volume.len() > self.window {
67            self.prices_x_volume.pop_front();
68            self.volumes.pop_front();
69        }
70
71        let vol_sum: f64 = self.volumes.iter().sum();
72        if vol_sum <= 0.0 {
73            return None;
74        }
75        let pv_sum: f64 = self.prices_x_volume.iter().sum();
76        let vwap = pv_sum / vol_sum;
77
78        // Volume-weighted variance for sigma bands (plan Anhang A: Z_VWAP = (Price-VWAP)/sigma).
79        let mut weighted_sq_dev = 0.0;
80        for (pv, vol) in self.prices_x_volume.iter().zip(self.volumes.iter()) {
81            if *vol <= 0.0 {
82                continue;
83            }
84            let price = pv / vol;
85            weighted_sq_dev += vol * (price - vwap).powi(2);
86        }
87        let sigma = (weighted_sq_dev / vol_sum).sqrt();
88
89        self.vwap_history.push_back(vwap);
90        if self.vwap_history.len() > self.slope_lookback + 1 {
91            self.vwap_history.pop_front();
92        }
93
94        let mut extra = HashMap::new();
95        extra.insert("sigma".to_string(), sigma);
96        extra.insert("upper_1sigma".to_string(), vwap + sigma);
97        extra.insert("lower_1sigma".to_string(), vwap - sigma);
98        extra.insert("upper_2sigma".to_string(), vwap + 2.0 * sigma);
99        extra.insert("lower_2sigma".to_string(), vwap - 2.0 * sigma);
100
101        if self.vwap_history.len() > self.slope_lookback {
102            let past = self.vwap_history[0];
103            let slope = (vwap - past) / self.slope_lookback as f64;
104            extra.insert("slope".to_string(), slope);
105        }
106
107        Some(IndicatorOutput::with_extra(vwap, extra))
108    }
109
110    fn alerts(&self) -> Vec<IndicatorAlert> {
111        Vec::new()
112    }
113}
114
115pub fn build_vwap(params: &HashMap<String, f64>) -> Vwap {
116    let window = params.get("window").copied().unwrap_or(390.0) as usize;
117    let slope_lookback = params.get("slope_lookback").copied().unwrap_or(20.0) as usize;
118    Vwap::new(window, slope_lookback)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    fn bar(t: i64, close: f64, volume: f64) -> Bar {
126        Bar::new(t, close, close, close, close, volume)
127    }
128
129    #[test]
130    fn flat_price_series_has_vwap_equal_to_price() {
131        let mut vwap = Vwap::new(10, 5);
132        let mut last = None;
133        for i in 0..10 {
134            last = vwap.on_bar(&bar(i, 100.0, 10.0));
135        }
136        let out = last.expect("expected output once volume accumulated");
137        assert!((out.value - 100.0).abs() < 1e-9);
138        assert!((out.extra["sigma"]).abs() < 1e-9);
139    }
140
141    #[test]
142    fn rising_prices_produce_positive_slope() {
143        let mut vwap = Vwap::new(50, 5);
144        let mut last = None;
145        for i in 0..20 {
146            let price = 100.0 + i as f64;
147            last = vwap.on_bar(&bar(i, price, 10.0));
148        }
149        let out = last.expect("expected output");
150        assert!(out.extra["slope"] > 0.0);
151    }
152}