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(
60        bands_period: usize,
61        bands_deviation: f64,
62        low_band_adjust: f64,
63        mid_line_length: usize,
64    ) -> Self {
65        Self {
66            price_wma: WMA::new(bands_period),
67            tr_sma: SMA::new(bands_period * 2 - 1),
68            mid_line_wma: WMA::new(mid_line_length),
69            bands_deviation,
70            low_band_adjust,
71            prev_close: None,
72        }
73    }
74}
75
76impl Next<(f64, f64, f64)> for SVEVolatilityBands {
77    type Output = (f64, f64, f64); // (Upper, Mid, Lower)
78
79    fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
80        // True Range calculation
81        let tr = match self.prev_close {
82            Some(pc) => {
83                let h = high.max(pc);
84                let l = low.min(pc);
85                h - l
86            }
87            None => high - low,
88        };
89        self.prev_close = Some(close);
90
91        let ma_tr = self.tr_sma.next(tr);
92        let wtd_avg_val = self.price_wma.next(close);
93
94        let typical_price = (high + low + close) / 3.0;
95        let mid_line = self.mid_line_wma.next(typical_price);
96
97        let atr_val = ma_tr * self.bands_deviation;
98
99        // From TradeStation code:
100        // HighBand = WtdAvgVal + WtdAvgVal * ( AtrVal / Price )
101        // LowBand = WtdAvgVal - WtdAvgVal * ( AtrVal * LowBandAdjust / Price )
102
103        let upper = wtd_avg_val + wtd_avg_val * (atr_val / close);
104        let lower = wtd_avg_val - wtd_avg_val * (atr_val * self.low_band_adjust / close);
105
106        (upper, mid_line, lower)
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use proptest::prelude::*;
114
115    #[test]
116    fn test_sve_volatility_bands_basic() {
117        let mut sve = SVEVolatilityBands::new(20, 2.4, 0.9, 20);
118        let (upper, mid, lower) = sve.next((105.0, 95.0, 100.0));
119
120        // TR = 10, SMA(39) = 10.0
121        // Price WMA(20) = 100.0
122        // Typical = (105+95+100)/3 = 100.0. Mid WMA(20) = 100.0
123        // ATRVal = 10 * 2.4 = 24.0
124        // Upper = 100 + 100 * (24 / 100) = 124.0
125        // Lower = 100 - 100 * (24 * 0.9 / 100) = 100 - 21.6 = 78.4
126
127        assert_eq!(mid, 100.0);
128        assert_eq!(upper, 124.0);
129        assert_eq!(lower, 78.4);
130    }
131
132    fn sve_volatility_bands_batch(
133        data: &[(f64, f64, f64)],
134        period: usize,
135        dev: f64,
136        adj: f64,
137        mid_len: usize,
138    ) -> Vec<(f64, f64, f64)> {
139        let mut sve = SVEVolatilityBands::new(period, dev, adj, mid_len);
140        data.iter().map(|&x| sve.next(x)).collect()
141    }
142
143    proptest! {
144        #[test]
145        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)) {
146            let mut adj_input = Vec::with_capacity(input.len());
147            for (h, l, c) in input {
148                let h_f: f64 = h;
149                let l_f: f64 = l;
150                let c_f: f64 = c;
151                let high = h_f.max(l_f).max(c_f);
152                let low = l_f.min(h_f).min(c_f);
153                adj_input.push((high, low, c_f));
154            }
155
156            let period = 20;
157            let dev = 2.4;
158            let adj = 0.9;
159            let mid_len = 20;
160
161            let mut sve = SVEVolatilityBands::new(period, dev, adj, mid_len);
162            let streaming_results: Vec<(f64, f64, f64)> = adj_input.iter().map(|&x| sve.next(x)).collect();
163            let batch_results = sve_volatility_bands_batch(&adj_input, period, dev, adj, mid_len);
164
165            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
166                approx::assert_relative_eq!(s.0, b.0, epsilon = 1e-6);
167                approx::assert_relative_eq!(s.1, b.1, epsilon = 1e-6);
168                approx::assert_relative_eq!(s.2, b.2, epsilon = 1e-6);
169            }
170        }
171    }
172}