Skip to main content

wickra_core/indicators/
rvi_volatility.rs

1//! Relative Volatility Index (Donald Dorsey).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Relative Volatility Index — Donald Dorsey's RSI-shaped volatility gauge.
9///
10/// Where RSI partitions price changes into gains and losses and Wilder-smooths
11/// each side, RVI partitions the rolling standard deviation of price into "up
12/// volatility" (when price rose since the previous bar) and "down volatility"
13/// (when price fell), then applies the same Wilder smoothing and ratio:
14///
15/// ```text
16/// sd_t        = stddev_pop(close over `period`)            // single scalar each bar
17/// up_t        = sd_t if close_t > close_{t-1}, else 0
18/// down_t      = sd_t if close_t < close_{t-1}, else 0
19/// AvgUp_t     = Wilder(up,   period)
20/// AvgDown_t   = Wilder(down, period)
21/// RVI_t       = 100 · AvgUp_t / (AvgUp_t + AvgDown_t)
22/// ```
23///
24/// The output is bounded on `[0, 100]`. A series with no down-bars saturates
25/// at `100`; a series with no up-bars saturates at `0`. A completely flat
26/// series (no movement, both averages zero) returns `50` by the same
27/// undefined-RS convention as `RSI` (`crates/wickra-core/src/indicators/rsi.rs`).
28///
29/// # Example
30///
31/// ```
32/// use wickra_core::{Indicator, RviVolatility};
33///
34/// let mut indicator = RviVolatility::new(10).unwrap();
35/// let mut last = None;
36/// for i in 0..80 {
37///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
38/// }
39/// assert!(last.is_some());
40/// ```
41#[derive(Debug, Clone)]
42pub struct RviVolatility {
43    period: usize,
44    // Rolling-stddev state.
45    window: VecDeque<f64>,
46    sum: f64,
47    sum_sq: f64,
48    // Direction tracking.
49    prev_close: Option<f64>,
50    // Wilder-smoothed up/down volatility.
51    seed_up: Vec<f64>,
52    seed_down: Vec<f64>,
53    avg_up: Option<f64>,
54    avg_down: Option<f64>,
55    last_value: Option<f64>,
56}
57
58impl RviVolatility {
59    /// Construct an RVI with the given period.
60    ///
61    /// `period` is used both as the standard-deviation window length and as
62    /// the Wilder smoothing constant for the up/down averages.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`Error::PeriodZero`] if `period == 0`, or
67    /// [`Error::InvalidPeriod`] if `period == 1` (a 1-bar rolling standard
68    /// deviation is always zero and the indicator would never produce a
69    /// meaningful reading).
70    pub fn new(period: usize) -> Result<Self> {
71        if period == 0 {
72            return Err(Error::PeriodZero);
73        }
74        if period < 2 {
75            return Err(Error::InvalidPeriod {
76                message: "RVI period must be >= 2",
77            });
78        }
79        Ok(Self {
80            period,
81            window: VecDeque::with_capacity(period),
82            sum: 0.0,
83            sum_sq: 0.0,
84            prev_close: None,
85            seed_up: Vec::with_capacity(period),
86            seed_down: Vec::with_capacity(period),
87            avg_up: None,
88            avg_down: None,
89            last_value: None,
90        })
91    }
92
93    /// Configured period.
94    pub const fn period(&self) -> usize {
95        self.period
96    }
97
98    /// Current value if available.
99    pub const fn value(&self) -> Option<f64> {
100        self.last_value
101    }
102
103    fn ratio(avg_up: f64, avg_down: f64) -> f64 {
104        let denom = avg_up + avg_down;
105        if denom == 0.0 {
106            // No volatility on either side. Match RSI's undefined-RS convention.
107            50.0
108        } else {
109            100.0 * avg_up / denom
110        }
111    }
112}
113
114impl Indicator for RviVolatility {
115    type Input = f64;
116    type Output = f64;
117
118    fn update(&mut self, input: f64) -> Option<f64> {
119        if !input.is_finite() {
120            // Non-finite input leaves state untouched, mirrors `StdDev` / `Rsi`.
121            return self.last_value;
122        }
123
124        // 1. Roll the standard-deviation window.
125        if self.window.len() == self.period {
126            let old = self.window.pop_front().expect("window is non-empty");
127            self.sum -= old;
128            self.sum_sq -= old * old;
129        }
130        self.window.push_back(input);
131        self.sum += input;
132        self.sum_sq += input * input;
133
134        if self.window.len() < self.period {
135            // Track previous close from the very first input so that the first
136            // ready stddev sample is paired with a valid direction.
137            self.prev_close = Some(input);
138            return None;
139        }
140
141        let n = self.period as f64;
142        let mean = self.sum / n;
143        // Population variance with a non-negativity clamp for FP cancellation.
144        let variance = (self.sum_sq / n - mean * mean).max(0.0);
145        let sd = variance.sqrt();
146
147        // 2. Classify the stddev sample as up- or down-volatility.
148        let prev = self
149            .prev_close
150            .expect("prev_close is set on every input before this point");
151        let (up, down) = if input > prev {
152            (sd, 0.0)
153        } else if input < prev {
154            (0.0, sd)
155        } else {
156            (0.0, 0.0)
157        };
158        self.prev_close = Some(input);
159
160        // 3. Wilder-smooth the up/down series.
161        if let (Some(au), Some(ad)) = (self.avg_up, self.avg_down) {
162            let new_au = au.mul_add(n - 1.0, up) / n;
163            let new_ad = ad.mul_add(n - 1.0, down) / n;
164            self.avg_up = Some(new_au);
165            self.avg_down = Some(new_ad);
166            let v = Self::ratio(new_au, new_ad);
167            self.last_value = Some(v);
168            return Some(v);
169        }
170
171        self.seed_up.push(up);
172        self.seed_down.push(down);
173        if self.seed_up.len() == self.period {
174            let au = self.seed_up.iter().sum::<f64>() / n;
175            let ad = self.seed_down.iter().sum::<f64>() / n;
176            self.avg_up = Some(au);
177            self.avg_down = Some(ad);
178            let v = Self::ratio(au, ad);
179            self.last_value = Some(v);
180            return Some(v);
181        }
182        None
183    }
184
185    fn reset(&mut self) {
186        self.window.clear();
187        self.sum = 0.0;
188        self.sum_sq = 0.0;
189        self.prev_close = None;
190        self.seed_up.clear();
191        self.seed_down.clear();
192        self.avg_up = None;
193        self.avg_down = None;
194        self.last_value = None;
195    }
196
197    fn warmup_period(&self) -> usize {
198        // `period` bars to fill the stddev window plus another `period − 1`
199        // bars to seed the Wilder averages with up/down samples. The two
200        // phases overlap by one bar (the `period`-th input produces both the
201        // first stddev sample and the first up/down classification), so the
202        // first ready RVI lands at index `2 · period − 2`, i.e. the
203        // `(2·period − 1)`-th input.
204        2 * self.period - 1
205    }
206
207    fn is_ready(&self) -> bool {
208        self.last_value.is_some()
209    }
210
211    fn name(&self) -> &'static str {
212        "RVIVolatility"
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::traits::BatchExt;
220    use approx::assert_relative_eq;
221
222    #[test]
223    fn rejects_zero_period() {
224        assert!(matches!(RviVolatility::new(0), Err(Error::PeriodZero)));
225    }
226
227    #[test]
228    fn rejects_period_one() {
229        assert!(matches!(
230            RviVolatility::new(1),
231            Err(Error::InvalidPeriod { .. })
232        ));
233    }
234
235    #[test]
236    fn accessors_and_metadata() {
237        let rvi = RviVolatility::new(14).unwrap();
238        assert_eq!(rvi.period(), 14);
239        assert_eq!(rvi.name(), "RVIVolatility");
240        assert_eq!(rvi.value(), None);
241        assert_eq!(rvi.warmup_period(), 27);
242        assert!(!rvi.is_ready());
243    }
244
245    #[test]
246    fn constant_series_yields_fifty() {
247        // Flat input -> stddev is zero every bar and direction is "unchanged",
248        // so both avg_up and avg_down stay at zero -> the undefined-RS
249        // convention returns 50.
250        let mut rvi = RviVolatility::new(5).unwrap();
251        let out = rvi.batch(&[42.0; 40]);
252        for v in out.iter().skip(9).flatten() {
253            assert_relative_eq!(*v, 50.0, epsilon = 1e-12);
254        }
255    }
256
257    #[test]
258    fn pure_uptrend_saturates_to_one_hundred() {
259        // Every bar's close is above the previous -> every stddev sample is
260        // classified as up, every down sample is zero -> RVI = 100.
261        let mut rvi = RviVolatility::new(5).unwrap();
262        let prices: Vec<f64> = (1..=40).map(f64::from).collect();
263        let out = rvi.batch(&prices);
264        for v in out.iter().skip(9).flatten() {
265            assert_relative_eq!(*v, 100.0, epsilon = 1e-9);
266        }
267    }
268
269    #[test]
270    fn pure_downtrend_saturates_to_zero() {
271        let mut rvi = RviVolatility::new(5).unwrap();
272        let prices: Vec<f64> = (1..=40).rev().map(f64::from).collect();
273        let out = rvi.batch(&prices);
274        for v in out.iter().skip(9).flatten() {
275            assert_relative_eq!(*v, 0.0, epsilon = 1e-9);
276        }
277    }
278
279    #[test]
280    fn output_is_bounded() {
281        let mut rvi = RviVolatility::new(10).unwrap();
282        let prices: Vec<f64> = (0..200)
283            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
284            .collect();
285        for v in rvi.batch(&prices).into_iter().flatten() {
286            assert!((0.0..=100.0).contains(&v), "RVI out of range: {v}");
287        }
288    }
289
290    #[test]
291    fn first_emission_at_warmup_period() {
292        let mut rvi = RviVolatility::new(5).unwrap();
293        assert_eq!(rvi.warmup_period(), 9);
294        let prices: Vec<f64> = (0..30)
295            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 3.0)
296            .collect();
297        let out = rvi.batch(&prices);
298        for v in out.iter().take(8) {
299            assert!(v.is_none(), "indicator must still be warming up");
300        }
301        assert!(
302            out[8].is_some(),
303            "first value lands at warmup_period - 1 = 8"
304        );
305    }
306
307    #[test]
308    fn batch_equals_streaming() {
309        let prices: Vec<f64> = (0..120)
310            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
311            .collect();
312        let batch = RviVolatility::new(10).unwrap().batch(&prices);
313        let mut streamer = RviVolatility::new(10).unwrap();
314        let streamed: Vec<_> = prices.iter().map(|p| streamer.update(*p)).collect();
315        assert_eq!(batch, streamed);
316    }
317
318    #[test]
319    fn reset_clears_state() {
320        let mut rvi = RviVolatility::new(5).unwrap();
321        rvi.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
322        assert!(rvi.is_ready());
323        rvi.reset();
324        assert!(!rvi.is_ready());
325        assert_eq!(rvi.value(), None);
326        assert_eq!(rvi.update(1.0), None);
327    }
328
329    #[test]
330    fn ignores_non_finite_input() {
331        let mut rvi = RviVolatility::new(5).unwrap();
332        let out = rvi.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
333        let last = *out.last().unwrap();
334        assert!(last.is_some());
335        assert_eq!(rvi.update(f64::NAN), last);
336        assert_eq!(rvi.update(f64::INFINITY), last);
337    }
338}