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