Skip to main content

quantwave_core/indicators/
sve_volatility_bands.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::indicators::smoothing::{SMA, WMA};
3use crate::traits::Next;
4
5pub const METADATA: IndicatorMetadata = IndicatorMetadata {
6    name: "SVE Volatility Bands",
7    description: "Volatility bands designed to highlight volatility changes especially when using non-time-related charts like Renko.",
8    usage: "Use to identify extreme price excursions and volatility contraction/expansion. The bands adapt to volatility using a smoothed ATR-like calculation.",
9    keywords: &["bands", "volatility", "renko", "vervoort"],
10    ehlers_summary: "Introduced by Sylvain Vervoort, SVE Volatility Bands use a weighted moving average of price and a smoothed True Range to create dynamic bands. It includes a specific adjustment for the lower band and a midline based on typical price.",
11    params: &[
12        ParamDef {
13            name: "bands_period",
14            default: "20",
15            description: "Period for the price WMA and the ATR smoothing basis.",
16        },
17        ParamDef {
18            name: "bands_deviation",
19            default: "2.4",
20            description: "Multiplier for the volatility range.",
21        },
22        ParamDef {
23            name: "low_band_adjust",
24            default: "0.9",
25            description: "Adjustment factor for the lower band.",
26        },
27        ParamDef {
28            name: "mid_line_length",
29            default: "20",
30            description: "Period for the midline WMA.",
31        },
32    ],
33    formula_source: "Technical Analysis of Stocks & Commodities, January 2019",
34    formula_latex: r#"
35\[
36ATR\_MA = SMA(TrueRange, bands\_period \times 2 - 1) \\
37WtdAvgVal = WMA(Close, bands\_period) \\
38Upper = WtdAvgVal \times (1 + (ATR\_MA \times bands\_deviation) / Close) \\
39Lower = WtdAvgVal \times (1 - (ATR\_MA \times bands\_deviation \times low\_band\_adjust) / Close) \\
40MidLine = WMA(TypicalPrice, mid\_line\_length)
41\]
42"#,
43    gold_standard_file: "sve_volatility_bands_20_2.4_0.9_20.json",
44    category: "Classic",
45};
46
47/// SVE Volatility Bands
48#[derive(Debug, Clone)]
49pub struct SVEVolatilityBands {
50    price_wma: WMA,
51    tr_sma: SMA,
52    mid_line_wma: WMA,
53    bands_deviation: f64,
54    low_band_adjust: f64,
55    prev_close: Option<f64>,
56}
57
58impl SVEVolatilityBands {
59    pub fn new(bands_period: usize, bands_deviation: f64, low_band_adjust: f64, mid_line_length: usize) -> Self {
60        Self {
61            price_wma: WMA::new(bands_period),
62            tr_sma: SMA::new(bands_period * 2 - 1),
63            mid_line_wma: WMA::new(mid_line_length),
64            bands_deviation,
65            low_band_adjust,
66            prev_close: None,
67        }
68    }
69}
70
71impl Next<(f64, f64, f64)> for SVEVolatilityBands {
72    type Output = (f64, f64, f64); // (Upper, Mid, Lower)
73
74    fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
75        // True Range calculation
76        let tr = match self.prev_close {
77            Some(pc) => {
78                let h = high.max(pc);
79                let l = low.min(pc);
80                h - l
81            }
82            None => high - low,
83        };
84        self.prev_close = Some(close);
85
86        let ma_tr = self.tr_sma.next(tr);
87        let wtd_avg_val = self.price_wma.next(close);
88        
89        let typical_price = (high + low + close) / 3.0;
90        let mid_line = self.mid_line_wma.next(typical_price);
91
92        let atr_val = ma_tr * self.bands_deviation;
93        
94        // From TradeStation code: 
95        // HighBand = WtdAvgVal + WtdAvgVal * ( AtrVal / Price )
96        // LowBand = WtdAvgVal - WtdAvgVal * ( AtrVal * LowBandAdjust / Price )
97        
98        let upper = wtd_avg_val + wtd_avg_val * (atr_val / close);
99        let lower = wtd_avg_val - wtd_avg_val * (atr_val * self.low_band_adjust / close);
100
101        (upper, mid_line, lower)
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use proptest::prelude::*;
109
110    #[test]
111    fn test_sve_volatility_bands_basic() {
112        let mut sve = SVEVolatilityBands::new(20, 2.4, 0.9, 20);
113        let (upper, mid, lower) = sve.next((105.0, 95.0, 100.0));
114        
115        // TR = 10, SMA(39) = 10.0
116        // Price WMA(20) = 100.0
117        // Typical = (105+95+100)/3 = 100.0. Mid WMA(20) = 100.0
118        // ATRVal = 10 * 2.4 = 24.0
119        // Upper = 100 + 100 * (24 / 100) = 124.0
120        // Lower = 100 - 100 * (24 * 0.9 / 100) = 100 - 21.6 = 78.4
121        
122        assert_eq!(mid, 100.0);
123        assert_eq!(upper, 124.0);
124        assert_eq!(lower, 78.4);
125    }
126
127    fn sve_volatility_bands_batch(
128        data: &[(f64, f64, f64)],
129        period: usize,
130        dev: f64,
131        adj: f64,
132        mid_len: usize,
133    ) -> Vec<(f64, f64, f64)> {
134        let mut sve = SVEVolatilityBands::new(period, dev, adj, mid_len);
135        data.iter().map(|&x| sve.next(x)).collect()
136    }
137
138    proptest! {
139        #[test]
140        fn test_sve_volatility_bands_parity(input in prop::collection::vec((0.1..100.0, 0.1..100.0, 0.1..100.0), 1..100)) {
141            let mut adj_input = Vec::with_capacity(input.len());
142            for (h, l, c) in input {
143                let h_f: f64 = h;
144                let l_f: f64 = l;
145                let c_f: f64 = c;
146                let high = h_f.max(l_f).max(c_f);
147                let low = l_f.min(h_f).min(c_f);
148                adj_input.push((high, low, c_f));
149            }
150
151            let period = 20;
152            let dev = 2.4;
153            let adj = 0.9;
154            let mid_len = 20;
155            
156            let mut sve = SVEVolatilityBands::new(period, dev, adj, mid_len);
157            let streaming_results: Vec<(f64, f64, f64)> = adj_input.iter().map(|&x| sve.next(x)).collect();
158            let batch_results = sve_volatility_bands_batch(&adj_input, period, dev, adj, mid_len);
159
160            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
161                approx::assert_relative_eq!(s.0, b.0, epsilon = 1e-6);
162                approx::assert_relative_eq!(s.1, b.1, epsilon = 1e-6);
163                approx::assert_relative_eq!(s.2, b.2, epsilon = 1e-6);
164            }
165        }
166    }
167}