Skip to main content

nautilus_indicators/volatility/
rvi.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{
17    collections::VecDeque,
18    fmt::{Debug, Display},
19};
20
21use nautilus_core::correctness::FAILED;
22use nautilus_model::data::{Bar, QuoteTick, TradeTick};
23
24use crate::{
25    average::{MovingAverageFactory, MovingAverageType},
26    indicator::{Indicator, MovingAverage},
27    support::{MAX_PERIOD, ShiftedMoments},
28};
29
30/// An indicator which calculates a Relative Volatility Index (RVI) across a rolling window.
31#[repr(C)]
32#[derive(Debug)]
33#[cfg_attr(
34    feature = "python",
35    pyo3::pyclass(module = "nautilus_trader.indicators", unsendable)
36)]
37#[cfg_attr(
38    feature = "python",
39    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
40)]
41pub struct RelativeVolatilityIndex {
42    pub period: usize,
43    pub scalar: f64,
44    pub ma_type: MovingAverageType,
45    pub value: f64,
46    pub initialized: bool,
47    prices: VecDeque<f64>,
48    moments: ShiftedMoments,
49    pos_ma: Box<dyn MovingAverage + Send + 'static>,
50    neg_ma: Box<dyn MovingAverage + Send + 'static>,
51    previous_close: f64,
52    has_inputs: bool,
53}
54
55impl Display for RelativeVolatilityIndex {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        write!(
58            f,
59            "{}({},{},{})",
60            self.name(),
61            self.period,
62            self.scalar,
63            self.ma_type,
64        )
65    }
66}
67
68impl Indicator for RelativeVolatilityIndex {
69    fn name(&self) -> String {
70        stringify!(RelativeVolatilityIndex).to_string()
71    }
72
73    fn has_inputs(&self) -> bool {
74        self.has_inputs
75    }
76
77    fn initialized(&self) -> bool {
78        self.initialized
79    }
80
81    fn handle_quote(&mut self, _quote: &QuoteTick) -> anyhow::Result<()> {
82        Ok(())
83    }
84
85    fn handle_trade(&mut self, _trade: &TradeTick) {}
86
87    fn handle_bar(&mut self, bar: &Bar) {
88        self.update_raw((&bar.close).into());
89    }
90
91    fn reset(&mut self) {
92        self.previous_close = 0.0;
93        self.value = 0.0;
94        self.has_inputs = false;
95        self.initialized = false;
96        self.prices.clear();
97        self.moments.reset();
98        self.pos_ma.reset();
99        self.neg_ma.reset();
100    }
101}
102
103impl RelativeVolatilityIndex {
104    /// Creates a new [`RelativeVolatilityIndex`] instance.
105    ///
106    /// # Panics
107    ///
108    /// Panics if:
109    /// - `period` is not in the range of 2 to `MAX_PERIOD` (inclusive).
110    /// - `scalar` is not in the range of 0.0 to 100.0 (inclusive).
111    /// - `ma_type` is not a valid [`MovingAverageType`].
112    #[must_use]
113    pub fn new(period: usize, scalar: Option<f64>, ma_type: Option<MovingAverageType>) -> Self {
114        Self::new_checked(period, scalar, ma_type).expect(FAILED)
115    }
116
117    pub(crate) fn new_checked(
118        period: usize,
119        scalar: Option<f64>,
120        ma_type: Option<MovingAverageType>,
121    ) -> anyhow::Result<Self> {
122        anyhow::ensure!(
123            (2..=MAX_PERIOD).contains(&period),
124            "period must be in 2..={MAX_PERIOD}"
125        );
126        let scalar = scalar.unwrap_or(100.0);
127        anyhow::ensure!(
128            scalar.is_finite() && (0.0..=100.0).contains(&scalar),
129            "scalar must be finite and in 0..=100"
130        );
131        Ok(Self {
132            period,
133            scalar,
134            ma_type: ma_type.unwrap_or(MovingAverageType::Wilder),
135            value: 0.0,
136            initialized: false,
137            prices: VecDeque::with_capacity(period),
138            moments: ShiftedMoments::new(),
139            pos_ma: MovingAverageFactory::create(
140                ma_type.unwrap_or(MovingAverageType::Wilder),
141                period,
142            ),
143            neg_ma: MovingAverageFactory::create(
144                ma_type.unwrap_or(MovingAverageType::Wilder),
145                period,
146            ),
147            previous_close: 0.0,
148            has_inputs: false,
149        })
150    }
151
152    pub fn update_raw(&mut self, close: f64) {
153        if !close.is_finite() {
154            return;
155        }
156
157        if self.prices.len() == self.period
158            && let Some(old) = self.prices.pop_front()
159        {
160            self.moments.evict(old);
161        }
162        self.prices.push_back(close);
163        self.moments.push(close);
164        if self.moments.needs_reseed(self.period) {
165            self.moments.reseed(self.prices.iter());
166        }
167        self.has_inputs = true;
168
169        if self.prices.len() < self.period {
170            self.previous_close = close;
171            return;
172        }
173
174        let std_dev = self.moments.std_dev(self.period);
175        self.pos_ma.update_raw(if close > self.previous_close {
176            std_dev
177        } else {
178            0.0
179        });
180        self.neg_ma.update_raw(if close < self.previous_close {
181            std_dev
182        } else {
183            0.0
184        });
185        self.previous_close = close;
186
187        if !self.pos_ma.initialized() || !self.neg_ma.initialized() {
188            return;
189        }
190        let denominator = self.pos_ma.value() + self.neg_ma.value();
191        self.value = if denominator == 0.0 {
192            self.scalar * 0.5
193        } else {
194            self.scalar * self.pos_ma.value() / denominator
195        };
196        self.initialized = true;
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use rstest::rstest;
203
204    use super::*;
205    use crate::{stubs::rvi_10, testing::assert_approx_equal};
206
207    #[rstest]
208    fn test_name_returns_expected_string(rvi_10: RelativeVolatilityIndex) {
209        assert_eq!(rvi_10.name(), "RelativeVolatilityIndex");
210    }
211
212    #[rstest]
213    fn test_str_repr_returns_expected_string(rvi_10: RelativeVolatilityIndex) {
214        assert_eq!(format!("{rvi_10}"), "RelativeVolatilityIndex(10,10,SIMPLE)");
215    }
216
217    #[rstest]
218    fn test_period_returns_expected_value(rvi_10: RelativeVolatilityIndex) {
219        assert_eq!(rvi_10.period, 10);
220        assert_eq!(rvi_10.scalar, 10.0);
221        assert_eq!(rvi_10.ma_type, MovingAverageType::Simple);
222    }
223
224    #[rstest]
225    fn test_initialized_without_inputs_returns_false(rvi_10: RelativeVolatilityIndex) {
226        assert!(!rvi_10.initialized());
227    }
228
229    #[rstest]
230    fn test_value_with_all_higher_inputs_returns_expected_value(
231        mut rvi_10: RelativeVolatilityIndex,
232    ) {
233        let close_values = [
234            105.25, 107.50, 109.75, 112.00, 114.25, 116.50, 118.75, 121.00, 123.25, 125.50, 127.75,
235            130.00, 132.25, 134.50, 136.75, 139.00, 141.25, 143.50, 145.75, 148.00, 150.25, 152.50,
236            154.75, 157.00, 159.25, 161.50, 163.75, 166.00, 168.25, 170.50,
237        ];
238
239        for close in close_values {
240            rvi_10.update_raw(close);
241        }
242
243        assert!(rvi_10.initialized());
244        assert_eq!(rvi_10.value, 10.0);
245    }
246
247    #[rstest]
248    fn test_prices_window_bounded_to_period(mut rvi_10: RelativeVolatilityIndex) {
249        // Regression: the price window must stay bounded to `period`. Previously the
250        // fixed-capacity deque grew to its 1024 capacity, so the standard deviation was
251        // computed over far more than `period` prices while using a `period`-window mean.
252        for i in 0..50 {
253            rvi_10.update_raw(100.0 + f64::from(i));
254        }
255
256        assert!(rvi_10.initialized());
257        assert_eq!(rvi_10.prices.len(), 10);
258        assert_eq!(rvi_10.value, 10.0);
259    }
260
261    #[rstest]
262    fn test_reset_successfully_returns_indicator_to_fresh_state(
263        mut rvi_10: RelativeVolatilityIndex,
264    ) {
265        rvi_10.update_raw(1.00020);
266        rvi_10.update_raw(1.00030);
267        rvi_10.update_raw(1.00070);
268
269        rvi_10.reset();
270
271        assert!(!rvi_10.initialized());
272        assert_eq!(rvi_10.value, 0.0);
273        assert!(!rvi_10.initialized);
274        assert!(!rvi_10.has_inputs);
275        assert_eq!(rvi_10.prices.len(), 0);
276        assert_eq!(rvi_10.moments.mean(rvi_10.period), 0.0);
277        assert_eq!(rvi_10.pos_ma.value(), 0.0);
278        assert_eq!(rvi_10.neg_ma.value(), 0.0);
279    }
280
281    #[rstest]
282    fn test_value_is_midpoint_for_flat_prices(mut rvi_10: RelativeVolatilityIndex) {
283        for _ in 0..30 {
284            rvi_10.update_raw(100.0);
285        }
286
287        assert!(rvi_10.initialized());
288        assert_eq!(rvi_10.value, 5.0);
289    }
290
291    #[rstest]
292    fn test_value_matches_wilder_smoothed_population_deviation() {
293        // Window 3, population deviations: sqrt(2/3) for the first three windows,
294        // sqrt(14)/3 for [13, 12, 15]. Directions after warmup: down, up, down, up.
295        let mut rvi = RelativeVolatilityIndex::new(3, Some(100.0), None);
296        for close in [10.0, 12.0, 11.0, 13.0, 12.0] {
297            rvi.update_raw(close);
298        }
299        assert!(rvi.initialized());
300        assert_approx_equal(rvi.value, 100.0 / 3.0);
301
302        rvi.update_raw(15.0);
303        let s = (2.0_f64 / 3.0).sqrt();
304        let t = 14.0_f64.sqrt() / 3.0;
305        let pos = (2.0 * s / 3.0 + t) / 3.0;
306        let neg = 4.0 * s / 9.0;
307        assert_approx_equal(rvi.value, 100.0 * pos / (pos + neg));
308    }
309}