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