Skip to main content

wickra_core/indicators/
rvi.rs

1//! Relative Vigor Index (RVI).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Relative Vigor Index — John Ehlers' ratio of intra-bar drive (close − open)
10/// to intra-bar range (high − low) (Technical Analysis of Stocks & Commodities,
11/// January 2002).
12///
13/// Both series are first smoothed with Ehlers' symmetric four-bar `1-2-2-1`
14/// weighting, then summed over `period` bars:
15///
16/// ```text
17/// num_t = ((C−O)_t + 2·(C−O)_{t−1} + 2·(C−O)_{t−2} + (C−O)_{t−3}) / 6
18/// den_t = ((H−L)_t + 2·(H−L)_{t−1} + 2·(H−L)_{t−2} + (H−L)_{t−3}) / 6
19/// RVI_t = Σ_period num / Σ_period den
20/// ```
21///
22/// A positive value means the bars in the window closed above where they opened
23/// (bullish "vigor"); a negative value means they closed below. Ehlers' signal
24/// line is the same `1-2-2-1` weighting of the RVI itself. The denominator sum
25/// can fall to zero on a perfectly flat stretch, in which case the ratio is
26/// undefined and the indicator holds its previous value. The first value lands
27/// on bar `period + 3`.
28///
29/// # Example
30///
31/// ```
32/// use wickra_core::{Candle, Indicator, Rvi};
33///
34/// let mut rvi = Rvi::new(10).unwrap();
35/// let mut last = None;
36/// for i in 0..40 {
37///     let o = 100.0 + f64::from(i);
38///     let c = o + 0.5;
39///     let candle = Candle::new(o, c + 0.2, o - 0.2, c, 1.0, i64::from(i)).unwrap();
40///     last = rvi.update(candle);
41/// }
42/// assert!(last.is_some());
43/// ```
44#[derive(Debug, Clone)]
45pub struct Rvi {
46    period: usize,
47    /// The last four raw `(close − open, high − low)` pairs, oldest first.
48    raw: VecDeque<(f64, f64)>,
49    /// The last `period` weighted pairs.
50    window: VecDeque<(f64, f64)>,
51    sum_num: f64,
52    sum_den: f64,
53    current: Option<f64>,
54}
55
56impl Rvi {
57    /// # Errors
58    /// Returns [`Error::PeriodZero`] if `period == 0`.
59    pub fn new(period: usize) -> Result<Self> {
60        if period == 0 {
61            return Err(Error::PeriodZero);
62        }
63        if period > crate::error::MAX_PERIOD {
64            return Err(Error::InvalidPeriod {
65                message: crate::error::PERIOD_ABOVE_MAX,
66            });
67        }
68        Ok(Self {
69            period,
70            raw: VecDeque::with_capacity(4),
71            window: VecDeque::with_capacity(period),
72            sum_num: 0.0,
73            sum_den: 0.0,
74            current: None,
75        })
76    }
77
78    /// Configured period.
79    pub const fn period(&self) -> usize {
80        self.period
81    }
82
83    /// Current value if available.
84    pub const fn value(&self) -> Option<f64> {
85        self.current
86    }
87}
88
89impl Indicator for Rvi {
90    type Input = Candle;
91    type Output = f64;
92
93    #[inline]
94    fn update(&mut self, candle: Candle) -> Option<f64> {
95        if self.raw.len() == 4 {
96            self.raw.pop_front();
97        }
98        self.raw
99            .push_back((candle.close - candle.open, candle.high - candle.low));
100        if self.raw.len() < 4 {
101            return None;
102        }
103        // Symmetric 1-2-2-1 weighting, newest at index 3.
104        let (n3, d3) = self.raw[0];
105        let (n2, d2) = self.raw[1];
106        let (n1, d1) = self.raw[2];
107        let (n0, d0) = self.raw[3];
108        let num = (n0 + 2.0 * n1 + 2.0 * n2 + n3) / 6.0;
109        let den = (d0 + 2.0 * d1 + 2.0 * d2 + d3) / 6.0;
110        if self.window.len() == self.period {
111            let (old_n, old_d) = self.window.pop_front().expect("window is non-empty");
112            self.sum_num -= old_n;
113            self.sum_den -= old_d;
114        }
115        self.window.push_back((num, den));
116        self.sum_num += num;
117        self.sum_den += den;
118        if self.window.len() < self.period {
119            return None;
120        }
121        if self.sum_den <= 0.0 {
122            // Window of perfectly flat (zero-range) bars: ratio undefined.
123            // Hold the previous value rather than emitting NaN / inf.
124            return self.current;
125        }
126        let value = self.sum_num / self.sum_den;
127        self.current = Some(value);
128        Some(value)
129    }
130
131    fn reset(&mut self) {
132        self.raw.clear();
133        self.window.clear();
134        self.sum_num = 0.0;
135        self.sum_den = 0.0;
136        self.current = None;
137    }
138
139    #[inline]
140    fn warmup_period(&self) -> usize {
141        self.period + 3
142    }
143
144    #[inline]
145    fn is_ready(&self) -> bool {
146        self.current.is_some()
147    }
148
149    #[inline]
150    fn name(&self) -> &'static str {
151        "RVI"
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::traits::BatchExt;
159    use approx::assert_relative_eq;
160
161    fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
162        Candle::new(open, high, low, close, 1.0, ts).unwrap()
163    }
164
165    #[test]
166    fn rejects_zero_period() {
167        assert!(matches!(Rvi::new(0), Err(Error::PeriodZero)));
168    }
169
170    #[test]
171    fn accessors_and_metadata() {
172        let mut r = Rvi::new(10).unwrap();
173        assert_eq!(r.period(), 10);
174        assert_eq!(r.warmup_period(), 13);
175        assert_eq!(r.name(), "RVI");
176        assert_eq!(r.value(), None);
177        for i in 0..13 {
178            r.update(candle(10.0, 11.0, 9.0, 10.5, i));
179        }
180        assert!(r.value().is_some());
181    }
182
183    #[test]
184    fn reference_value_period_2() {
185        // Four bars (10, 11, 9, 10.5): num 0.5, den 2.0 each, so the first
186        // weighted pair is (0.5, 2.0). A fifth bar (10.5, 11.5, 10, 11.5) has
187        // num 1.0, den 1.5: weighted num = (1 + 2·0.5 + 2·0.5 + 0.5) / 6 = 3.5/6,
188        // den = (1.5 + 2·2 + 2·2 + 2) / 6 = 11.5/6.
189        //   RVI = (0.5 + 3.5/6) / (2 + 11.5/6) = 6.5 / 23.5
190        let mut r = Rvi::new(2).unwrap();
191        for i in 0..4 {
192            assert_eq!(r.update(candle(10.0, 11.0, 9.0, 10.5, i)), None);
193        }
194        let v = r.update(candle(10.5, 11.5, 10.0, 11.5, 4)).unwrap();
195        assert_relative_eq!(v, 6.5 / 23.5, epsilon = 1e-12);
196    }
197
198    #[test]
199    fn warmup_emits_first_value_at_period_plus_three() {
200        let mut r = Rvi::new(3).unwrap();
201        for i in 0..5 {
202            assert_eq!(r.update(candle(10.0, 11.0, 9.0, 10.5, i)), None);
203        }
204        assert!(r.update(candle(10.5, 11.5, 10.0, 11.0, 5)).is_some());
205    }
206
207    #[test]
208    fn pure_uptrend_is_positive() {
209        // Every bar closes above its open and has a non-zero range: RVI > 0.
210        let mut r = Rvi::new(5).unwrap();
211        for i in 0..10 {
212            let o = 10.0 + f64::from(i);
213            let c = o + 0.5;
214            r.update(candle(o, c + 0.2, o - 0.2, c, i64::from(i)));
215        }
216        let v = r.value().unwrap();
217        assert!(v > 0.0, "uptrend RVI should be positive: {v}");
218    }
219
220    #[test]
221    fn zero_range_window_holds_value() {
222        // Window of perfectly flat bars (high == low): ratio undefined,
223        // indicator holds.
224        let mut r = Rvi::new(3).unwrap();
225        for i in 0..5 {
226            r.update(candle(10.0, 10.0, 10.0, 10.0, i));
227        }
228        assert_eq!(r.update(candle(10.0, 10.0, 10.0, 10.0, 5)), None);
229    }
230
231    #[test]
232    fn batch_equals_streaming() {
233        let candles: Vec<Candle> = (0..40_i64)
234            .map(|i| {
235                let o = 100.0 + (i as f64 * 0.3).sin() * 5.0;
236                let c = o + (i as f64 * 0.1).cos();
237                candle(o, o.max(c) + 0.5, o.min(c) - 0.5, c, i)
238            })
239            .collect();
240        let batch = Rvi::new(10).unwrap().batch(&candles);
241        let mut b = Rvi::new(10).unwrap();
242        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
243        assert_eq!(batch, streamed);
244    }
245
246    #[test]
247    fn reset_clears_state() {
248        let mut r = Rvi::new(5).unwrap();
249        for i in 0..10 {
250            r.update(candle(10.0, 11.0, 9.0, 10.5, i));
251        }
252        assert!(r.is_ready());
253        r.reset();
254        assert!(!r.is_ready());
255        assert_eq!(r.update(candle(10.0, 11.0, 9.0, 10.5, 0)), None);
256    }
257
258    fn wave(len: i64) -> Vec<Candle> {
259        (0..len)
260            .map(|i| {
261                let step = f64::from(i32::try_from(i).unwrap());
262                let o = 100.0 + (step * 0.37).sin() * 6.0;
263                let cl = o + (step * 0.11).cos() * 1.5;
264                candle(o, o.max(cl) + 0.4, o.min(cl) - 0.4, cl, i)
265            })
266            .collect()
267    }
268
269    #[test]
270    fn rejects_period_above_max() {
271        let too_big = crate::error::MAX_PERIOD + 1;
272        assert!(matches!(
273            Rvi::new(too_big),
274            Err(Error::InvalidPeriod { .. })
275        ));
276    }
277
278    #[test]
279    fn warmup_is_exact_for_several_periods() {
280        let candles = wave(40);
281        for period in [1_usize, 2, 5, 10] {
282            let mut r = Rvi::new(period).unwrap();
283            let warmup = r.warmup_period();
284            let out = r.batch(&candles);
285            assert!(out[..warmup - 1].iter().all(Option::is_none));
286            assert!(out[warmup - 1..].iter().all(Option::is_some));
287        }
288    }
289
290    #[test]
291    fn reference_value_period_1_is_one_weighted_bar() {
292        // period = 1: RVI is the single 1-2-2-1 weighted ratio of the last 4 bars.
293        //   bars (C−O, H−L): (1, 2), (−0.5, 1), (2, 4), (0.5, 3)
294        //   num = (0.5 + 2·2 + 2·(−0.5) + 1) / 6 = 4.5 / 6
295        //   den = (3 + 2·4 + 2·1 + 2) / 6 = 15 / 6
296        //   RVI = 4.5 / 15 = 0.3
297        let mut r = Rvi::new(1).unwrap();
298        assert_eq!(r.update(candle(10.0, 11.5, 9.5, 11.0, 0)), None);
299        assert_eq!(r.update(candle(11.0, 11.5, 10.5, 10.5, 1)), None);
300        assert_eq!(r.update(candle(10.0, 13.0, 9.0, 12.0, 2)), None);
301        let v = r.update(candle(12.0, 14.0, 11.0, 12.5, 3)).unwrap();
302        assert_relative_eq!(v, 0.3, epsilon = 1e-12);
303    }
304
305    #[test]
306    fn downtrend_is_negative() {
307        let mut r = Rvi::new(3).unwrap();
308        let mut last = None;
309        for i in 0..10_i32 {
310            let o = 50.0 - f64::from(i);
311            let cl = o - 0.5;
312            last = r.update(candle(o, o + 0.2, cl - 0.2, cl, i64::from(i)));
313        }
314        assert!(last.unwrap() < 0.0);
315    }
316
317    #[test]
318    fn flat_stretch_after_movement_holds_previous_value() {
319        // period = 1: once the last four bars are all zero-range, the weighted
320        // denominator is exactly zero and the previous value is held.
321        let mut r = Rvi::new(1).unwrap();
322        for i in 0..4 {
323            r.update(candle(10.0, 11.0, 9.0, 10.5, i));
324        }
325        let before = r.value().unwrap();
326        assert_relative_eq!(before, 0.25, epsilon = 1e-12);
327        for i in 4..10 {
328            let v = r.update(candle(10.0, 10.0, 10.0, 10.0, i)).unwrap();
329            assert_eq!(v.to_bits(), before.to_bits());
330        }
331        assert!(r.is_ready());
332    }
333
334    #[test]
335    fn reset_reproduces_a_fresh_run() {
336        let candles = wave(50);
337        let mut r = Rvi::new(10).unwrap();
338        let first = r.batch(&candles);
339        r.reset();
340        assert_eq!(r.value(), None);
341        let second = r.batch(&candles);
342        assert_eq!(first, second);
343        assert_eq!(second, Rvi::new(10).unwrap().batch(&candles));
344    }
345
346    #[test]
347    fn batch_nan_into_matches_streaming_bits() {
348        let candles = wave(50);
349        let mut streaming = Rvi::new(4).unwrap();
350        let expected: Vec<u64> = candles
351            .iter()
352            .map(|c| streaming.update(*c).unwrap_or(f64::NAN).to_bits())
353            .collect();
354        let mut out = vec![0.0; candles.len()];
355        Rvi::new(4).unwrap().batch_nan_into(&candles, &mut out);
356        let got: Vec<u64> = out.iter().map(|v| v.to_bits()).collect();
357        assert_eq!(got, expected);
358    }
359}