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 {
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 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 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}