Skip to main content

wickra_core/indicators/
vwap_stddev_bands.rs

1//! VWAP Standard-Deviation Bands.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// `VWAP` `StdDev` Bands output.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct VwapStdDevBandsOutput {
10    /// Upper band: `vwap + multiplier · sigma`.
11    pub upper: f64,
12    /// Middle band: cumulative VWAP of typical price.
13    pub middle: f64,
14    /// Lower band: `vwap − multiplier · sigma`.
15    pub lower: f64,
16    /// Volume-weighted standard deviation of typical price about VWAP.
17    pub stddev: f64,
18}
19
20/// VWAP with volume-weighted standard-deviation envelopes.
21///
22/// ```text
23/// tp_i        = typical_price(candle_i)         // (high + low + close) / 3
24/// sum_v       = Σ volume_i
25/// sum_pv      = Σ tp_i · volume_i
26/// sum_p2v     = Σ tp_i² · volume_i
27/// vwap        = sum_pv / sum_v
28/// variance    = sum_p2v / sum_v − vwap²         // volume-weighted population variance
29/// sigma       = sqrt(max(variance, 0))
30/// upper/lower = vwap ± multiplier · sigma
31/// ```
32///
33/// The cumulative running sums make every update O(1) with no per-bar replay,
34/// matching the streaming contract of [`Vwap`](crate::Vwap). VWAP and its
35/// stddev bands are an intraday-session tool: call [`Indicator::reset`] at
36/// the start of each session boundary so the accumulators do not span the gap.
37///
38/// # Example
39///
40/// ```
41/// use wickra_core::{Candle, Indicator, VwapStdDevBands};
42///
43/// let mut indicator = VwapStdDevBands::new(2.0).unwrap();
44/// let mut last = None;
45/// for i in 0..40 {
46///     let base = 100.0 + f64::from(i);
47///     let candle =
48///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
49///     last = indicator.update(candle);
50/// }
51/// assert!(last.is_some());
52/// ```
53#[derive(Debug, Clone)]
54pub struct VwapStdDevBands {
55    multiplier: f64,
56    sum_pv: f64,
57    sum_p2v: f64,
58    sum_v: f64,
59    has_emitted: bool,
60}
61
62impl VwapStdDevBands {
63    /// # Errors
64    /// Returns [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
65    /// positive and finite.
66    pub fn new(multiplier: f64) -> Result<Self> {
67        if !multiplier.is_finite() || multiplier <= 0.0 {
68            return Err(Error::NonPositiveMultiplier);
69        }
70        Ok(Self {
71            multiplier,
72            sum_pv: 0.0,
73            sum_p2v: 0.0,
74            sum_v: 0.0,
75            has_emitted: false,
76        })
77    }
78
79    /// Configured multiplier.
80    pub const fn multiplier(&self) -> f64 {
81        self.multiplier
82    }
83}
84
85impl Indicator for VwapStdDevBands {
86    type Input = Candle;
87    type Output = VwapStdDevBandsOutput;
88
89    fn update(&mut self, candle: Candle) -> Option<VwapStdDevBandsOutput> {
90        let tp = candle.typical_price();
91        self.sum_pv += tp * candle.volume;
92        self.sum_p2v += tp * tp * candle.volume;
93        self.sum_v += candle.volume;
94        if self.sum_v == 0.0 {
95            return None;
96        }
97        self.has_emitted = true;
98        let vwap = self.sum_pv / self.sum_v;
99        // Volume-weighted population variance; clamp tiny negative cancellation
100        // noise back to zero on near-constant inputs.
101        let var = (self.sum_p2v / self.sum_v - vwap * vwap).max(0.0);
102        let sigma = var.sqrt();
103        Some(VwapStdDevBandsOutput {
104            upper: vwap + self.multiplier * sigma,
105            middle: vwap,
106            lower: vwap - self.multiplier * sigma,
107            stddev: sigma,
108        })
109    }
110
111    fn reset(&mut self) {
112        self.sum_pv = 0.0;
113        self.sum_p2v = 0.0;
114        self.sum_v = 0.0;
115        self.has_emitted = false;
116    }
117
118    fn warmup_period(&self) -> usize {
119        1
120    }
121
122    fn is_ready(&self) -> bool {
123        self.has_emitted
124    }
125
126    fn name(&self) -> &'static str {
127        "VwapStdDevBands"
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::traits::BatchExt;
135    use approx::assert_relative_eq;
136
137    fn c(h: f64, l: f64, cl: f64, v: f64) -> Candle {
138        Candle::new(cl, h, l, cl, v, 0).unwrap()
139    }
140
141    #[test]
142    fn rejects_non_positive_multiplier() {
143        assert!(matches!(
144            VwapStdDevBands::new(0.0),
145            Err(Error::NonPositiveMultiplier)
146        ));
147        assert!(matches!(
148            VwapStdDevBands::new(-1.0),
149            Err(Error::NonPositiveMultiplier)
150        ));
151        assert!(matches!(
152            VwapStdDevBands::new(f64::NAN),
153            Err(Error::NonPositiveMultiplier)
154        ));
155    }
156
157    #[test]
158    fn accessors_and_metadata() {
159        let v = VwapStdDevBands::new(2.0).unwrap();
160        assert_relative_eq!(v.multiplier(), 2.0, epsilon = 1e-12);
161        assert_eq!(v.warmup_period(), 1);
162        assert_eq!(v.name(), "VwapStdDevBands");
163    }
164
165    #[test]
166    fn zero_volume_returns_none() {
167        let mut v = VwapStdDevBands::new(2.0).unwrap();
168        assert!(v.update(c(10.0, 10.0, 10.0, 0.0)).is_none());
169    }
170
171    #[test]
172    fn constant_price_collapses_bands() {
173        let candles: Vec<Candle> = (0..10).map(|_| c(10.0, 10.0, 10.0, 5.0)).collect();
174        let mut v = VwapStdDevBands::new(2.0).unwrap();
175        let last = v.batch(&candles).into_iter().flatten().last().unwrap();
176        assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
177        assert_relative_eq!(last.stddev, 0.0, epsilon = 1e-9);
178        assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
179        assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
180    }
181
182    #[test]
183    fn upper_above_middle_above_lower() {
184        let candles: Vec<Candle> = (0..50)
185            .map(|i| {
186                let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
187                c(m + 1.0, m - 1.0, m, 1.0 + f64::from(i % 5))
188            })
189            .collect();
190        let mut v = VwapStdDevBands::new(2.0).unwrap();
191        for o in v.batch(&candles).into_iter().flatten() {
192            assert!(o.upper >= o.middle);
193            assert!(o.middle >= o.lower);
194            assert!(o.stddev >= 0.0);
195        }
196    }
197
198    #[test]
199    fn batch_equals_streaming() {
200        let candles: Vec<Candle> = (0..40)
201            .map(|i| {
202                c(
203                    f64::from(i) + 2.0,
204                    f64::from(i),
205                    f64::from(i) + 1.0,
206                    1.0 + f64::from(i % 4),
207                )
208            })
209            .collect();
210        let mut a = VwapStdDevBands::new(2.0).unwrap();
211        let mut b = VwapStdDevBands::new(2.0).unwrap();
212        assert_eq!(
213            a.batch(&candles),
214            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
215        );
216    }
217
218    #[test]
219    fn reset_clears_state() {
220        let candles: Vec<Candle> = (0..10)
221            .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i), 1.0))
222            .collect();
223        let mut v = VwapStdDevBands::new(2.0).unwrap();
224        v.batch(&candles);
225        assert!(v.is_ready());
226        v.reset();
227        assert!(!v.is_ready());
228        // After reset a zero-volume bar still returns `None` (volume is
229        // required to define the volume-weighted average).
230        assert_eq!(v.update(c(10.0, 10.0, 10.0, 0.0)), None);
231    }
232
233    /// Reference: two equal-volume bars at typical prices `tp = 8` and `tp = 12`.
234    /// VWAP = (8 + 12) / 2 = 10. Volume-weighted population variance =
235    /// (64 + 144) / 2 − 100 = 4. Sigma = 2. With multiplier 1.5: upper = 13,
236    /// lower = 7.
237    #[test]
238    fn reference_values() {
239        // typical_price = (high + low + close) / 3. Choose bars where this is
240        // exactly 8 and 12. Bar A: high=8, low=8, close=8 → tp=8.
241        // Bar B: high=12, low=12, close=12 → tp=12.
242        let candles = [c(8.0, 8.0, 8.0, 1.0), c(12.0, 12.0, 12.0, 1.0)];
243        let mut v = VwapStdDevBands::new(1.5).unwrap();
244        let _ = v.update(candles[0]);
245        let out = v.update(candles[1]).unwrap();
246        assert_relative_eq!(out.middle, 10.0, epsilon = 1e-9);
247        assert_relative_eq!(out.stddev, 2.0, epsilon = 1e-9);
248        assert_relative_eq!(out.upper, 13.0, epsilon = 1e-9);
249        assert_relative_eq!(out.lower, 7.0, epsilon = 1e-9);
250    }
251}