kestrel_chartkit/indicator/
vwap.rs1use std::collections::HashMap;
2use std::collections::VecDeque;
3
4use crate::model::Bar;
5
6use super::{Indicator, IndicatorAlert, IndicatorOutput};
7
8pub 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 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 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}