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::fmt::{Debug, Display};
17
18use arraydeque::{ArrayDeque, Wrapping};
19use nautilus_model::data::{Bar, QuoteTick, TradeTick};
20
21use crate::{
22    average::{MovingAverageFactory, MovingAverageType},
23    indicator::{Indicator, MovingAverage},
24};
25
26/// An indicator which calculates a Relative Volatility Index (RVI) across a rolling window.
27#[repr(C)]
28#[derive(Debug)]
29#[cfg_attr(
30    feature = "python",
31    pyo3::pyclass(module = "nautilus_trader.indicators", unsendable)
32)]
33#[cfg_attr(
34    feature = "python",
35    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
36)]
37pub struct RelativeVolatilityIndex {
38    pub period: usize,
39    pub scalar: f64,
40    pub ma_type: MovingAverageType,
41    pub value: f64,
42    pub initialized: bool,
43    prices: ArrayDeque<f64, 1024, Wrapping>,
44    ma: Box<dyn MovingAverage + Send + 'static>,
45    pos_ma: Box<dyn MovingAverage + Send + 'static>,
46    neg_ma: Box<dyn MovingAverage + Send + 'static>,
47    previous_close: f64,
48    std: f64,
49    has_inputs: bool,
50}
51
52impl Display for RelativeVolatilityIndex {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(
55            f,
56            "{}({},{},{})",
57            self.name(),
58            self.period,
59            self.scalar,
60            self.ma_type,
61        )
62    }
63}
64
65impl Indicator for RelativeVolatilityIndex {
66    fn name(&self) -> String {
67        stringify!(RelativeVolatilityIndex).to_string()
68    }
69
70    fn has_inputs(&self) -> bool {
71        self.has_inputs
72    }
73
74    fn initialized(&self) -> bool {
75        self.initialized
76    }
77
78    fn handle_quote(&mut self, _quote: &QuoteTick) -> anyhow::Result<()> {
79        Ok(())
80    }
81
82    fn handle_trade(&mut self, _trade: &TradeTick) {}
83
84    fn handle_bar(&mut self, bar: &Bar) {
85        self.update_raw((&bar.close).into());
86    }
87
88    fn reset(&mut self) {
89        self.previous_close = 0.0;
90        self.value = 0.0;
91        self.has_inputs = false;
92        self.initialized = false;
93        self.std = 0.0;
94        self.prices.clear();
95        self.ma.reset();
96        self.pos_ma.reset();
97        self.neg_ma.reset();
98    }
99}
100
101impl RelativeVolatilityIndex {
102    /// Creates a new [`RelativeVolatilityIndex`] instance.
103    ///
104    /// # Panics
105    ///
106    /// This function panics if:
107    /// - `period` is not in the range of 1 to 1024 (inclusive).
108    /// - `scalar` is not in the range of 0.0 to 100.0 (inclusive).
109    /// - `ma_type` is not a valid [`MovingAverageType`].
110    #[must_use]
111    pub fn new(period: usize, scalar: Option<f64>, ma_type: Option<MovingAverageType>) -> Self {
112        assert!(
113            period <= 1024,
114            "period {period} exceeds maximum capacity of price deque"
115        );
116
117        Self {
118            period,
119            scalar: scalar.unwrap_or(100.0),
120            ma_type: ma_type.unwrap_or(MovingAverageType::Simple),
121            value: 0.0,
122            initialized: false,
123            prices: ArrayDeque::new(),
124            ma: MovingAverageFactory::create(ma_type.unwrap_or(MovingAverageType::Simple), period),
125            pos_ma: MovingAverageFactory::create(
126                ma_type.unwrap_or(MovingAverageType::Simple),
127                period,
128            ),
129            neg_ma: MovingAverageFactory::create(
130                ma_type.unwrap_or(MovingAverageType::Simple),
131                period,
132            ),
133            previous_close: 0.0,
134            std: 0.0,
135            has_inputs: false,
136        }
137    }
138
139    pub fn update_raw(&mut self, close: f64) {
140        // Bound the price window to `period`. The fixed-capacity deque otherwise retains
141        // up to 1024 prices, so the standard deviation below is computed over far
142        // more than `period` observations while using a `period`-window mean.
143        if self.prices.len() == self.period {
144            self.prices.pop_front();
145        }
146
147        self.prices.push_back(close);
148        self.ma.update_raw(close);
149
150        if self.prices.is_empty() {
151            self.std = 0.0;
152        } else {
153            let mean = self.ma.value();
154            let mut var_sum = 0.0;
155
156            for &price in &self.prices {
157                let diff = price - mean;
158                var_sum += diff * diff;
159            }
160            self.std = (var_sum / self.prices.len() as f64).sqrt();
161            self.std = self.std * (self.period as f64).sqrt() / ((self.period - 1) as f64).sqrt();
162        }
163
164        if self.ma.initialized() {
165            if close > self.previous_close {
166                self.pos_ma.update_raw(self.std);
167                self.neg_ma.update_raw(0.0);
168            } else if close < self.previous_close {
169                self.pos_ma.update_raw(0.0);
170                self.neg_ma.update_raw(self.std);
171            } else {
172                self.pos_ma.update_raw(0.0);
173                self.neg_ma.update_raw(0.0);
174            }
175
176            self.value = self.scalar * self.pos_ma.value();
177            self.value /= self.pos_ma.value() + self.neg_ma.value();
178        }
179
180        self.previous_close = close;
181
182        if !self.initialized {
183            self.has_inputs = true;
184
185            if self.pos_ma.initialized() {
186                self.initialized = true;
187            }
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use rstest::rstest;
195
196    use super::*;
197    use crate::stubs::rvi_10;
198
199    #[rstest]
200    fn test_name_returns_expected_string(rvi_10: RelativeVolatilityIndex) {
201        assert_eq!(rvi_10.name(), "RelativeVolatilityIndex");
202    }
203
204    #[rstest]
205    fn test_str_repr_returns_expected_string(rvi_10: RelativeVolatilityIndex) {
206        assert_eq!(format!("{rvi_10}"), "RelativeVolatilityIndex(10,10,SIMPLE)");
207    }
208
209    #[rstest]
210    fn test_period_returns_expected_value(rvi_10: RelativeVolatilityIndex) {
211        assert_eq!(rvi_10.period, 10);
212        assert_eq!(rvi_10.scalar, 10.0);
213        assert_eq!(rvi_10.ma_type, MovingAverageType::Simple);
214    }
215
216    #[rstest]
217    fn test_initialized_without_inputs_returns_false(rvi_10: RelativeVolatilityIndex) {
218        assert!(!rvi_10.initialized());
219    }
220
221    #[rstest]
222    fn test_value_with_all_higher_inputs_returns_expected_value(
223        mut rvi_10: RelativeVolatilityIndex,
224    ) {
225        let close_values = [
226            105.25, 107.50, 109.75, 112.00, 114.25, 116.50, 118.75, 121.00, 123.25, 125.50, 127.75,
227            130.00, 132.25, 134.50, 136.75, 139.00, 141.25, 143.50, 145.75, 148.00, 150.25, 152.50,
228            154.75, 157.00, 159.25, 161.50, 163.75, 166.00, 168.25, 170.50,
229        ];
230
231        for close in close_values {
232            rvi_10.update_raw(close);
233        }
234
235        assert!(rvi_10.initialized());
236        assert_eq!(rvi_10.value, 10.0);
237    }
238
239    #[rstest]
240    fn test_prices_window_bounded_to_period(mut rvi_10: RelativeVolatilityIndex) {
241        // Regression: the price window must stay bounded to `period`. Previously the
242        // fixed-capacity deque grew to its 1024 capacity, so the standard deviation was
243        // computed over far more than `period` prices while using a `period`-window mean.
244        for i in 0..50 {
245            rvi_10.update_raw(100.0 + f64::from(i));
246        }
247
248        assert!(rvi_10.initialized());
249        assert_eq!(rvi_10.prices.len(), 10);
250        assert_eq!(rvi_10.value, 10.0);
251    }
252
253    #[rstest]
254    fn test_reset_successfully_returns_indicator_to_fresh_state(
255        mut rvi_10: RelativeVolatilityIndex,
256    ) {
257        rvi_10.update_raw(1.00020);
258        rvi_10.update_raw(1.00030);
259        rvi_10.update_raw(1.00070);
260
261        rvi_10.reset();
262
263        assert!(!rvi_10.initialized());
264        assert_eq!(rvi_10.value, 0.0);
265        assert!(!rvi_10.initialized);
266        assert!(!rvi_10.has_inputs);
267        assert_eq!(rvi_10.std, 0.0);
268        assert_eq!(rvi_10.prices.len(), 0);
269        assert_eq!(rvi_10.ma.value(), 0.0);
270        assert_eq!(rvi_10.pos_ma.value(), 0.0);
271        assert_eq!(rvi_10.neg_ma.value(), 0.0);
272    }
273}