nautilus_indicators/volatility/
rvi.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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;
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.core.nautilus_pyo3.indicators", unsendable)
32)]
33pub struct RelativeVolatilityIndex {
34    pub period: usize,
35    pub scalar: f64,
36    pub ma_type: MovingAverageType,
37    pub value: f64,
38    pub initialized: bool,
39    prices: ArrayDeque<f64, 1024, Wrapping>,
40    ma: Box<dyn MovingAverage + Send + 'static>,
41    pos_ma: Box<dyn MovingAverage + Send + 'static>,
42    neg_ma: Box<dyn MovingAverage + Send + 'static>,
43    previous_close: f64,
44    std: f64,
45    has_inputs: bool,
46}
47
48impl Display for RelativeVolatilityIndex {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        write!(
51            f,
52            "{}({},{},{})",
53            self.name(),
54            self.period,
55            self.scalar,
56            self.ma_type,
57        )
58    }
59}
60
61impl Indicator for RelativeVolatilityIndex {
62    fn name(&self) -> String {
63        stringify!(RelativeVolatilityIndex).to_string()
64    }
65
66    fn has_inputs(&self) -> bool {
67        self.has_inputs
68    }
69
70    fn initialized(&self) -> bool {
71        self.initialized
72    }
73
74    fn handle_bar(&mut self, bar: &Bar) {
75        self.update_raw((&bar.close).into());
76    }
77
78    fn reset(&mut self) {
79        self.previous_close = 0.0;
80        self.value = 0.0;
81        self.has_inputs = false;
82        self.initialized = false;
83        self.std = 0.0;
84        self.prices.clear();
85        self.ma.reset();
86        self.pos_ma.reset();
87        self.neg_ma.reset();
88    }
89}
90
91impl RelativeVolatilityIndex {
92    /// Creates a new [`RelativeVolatilityIndex`] instance.
93    ///
94    /// # Panics
95    ///
96    /// This function panics if:
97    /// - `period` is not in the range of 1 to 1024 (inclusive).
98    /// - `scalar` is not in the range of 0.0 to 100.0 (inclusive).
99    /// - `ma_type` is not a valid [`MovingAverageType`].
100    #[must_use]
101    pub fn new(period: usize, scalar: Option<f64>, ma_type: Option<MovingAverageType>) -> Self {
102        assert!(
103            period <= 1024,
104            "period {period} exceeds maximum capacity of price deque"
105        );
106
107        Self {
108            period,
109            scalar: scalar.unwrap_or(100.0),
110            ma_type: ma_type.unwrap_or(MovingAverageType::Simple),
111            value: 0.0,
112            initialized: false,
113            prices: ArrayDeque::new(),
114            ma: MovingAverageFactory::create(ma_type.unwrap_or(MovingAverageType::Simple), period),
115            pos_ma: MovingAverageFactory::create(
116                ma_type.unwrap_or(MovingAverageType::Simple),
117                period,
118            ),
119            neg_ma: MovingAverageFactory::create(
120                ma_type.unwrap_or(MovingAverageType::Simple),
121                period,
122            ),
123            previous_close: 0.0,
124            std: 0.0,
125            has_inputs: false,
126        }
127    }
128
129    pub fn update_raw(&mut self, close: f64) {
130        self.prices.push_back(close);
131        self.ma.update_raw(close);
132
133        if self.prices.is_empty() {
134            self.std = 0.0;
135        } else {
136            let mean = self.ma.value();
137            let mut var_sum = 0.0;
138            for &price in &self.prices {
139                let diff = price - mean;
140                var_sum += diff * diff;
141            }
142            self.std = (var_sum / self.prices.len() as f64).sqrt();
143            self.std = self.std * (self.period as f64).sqrt() / ((self.period - 1) as f64).sqrt();
144        }
145
146        if self.ma.initialized() {
147            if close > self.previous_close {
148                self.pos_ma.update_raw(self.std);
149                self.neg_ma.update_raw(0.0);
150            } else if close < self.previous_close {
151                self.pos_ma.update_raw(0.0);
152                self.neg_ma.update_raw(self.std);
153            } else {
154                self.pos_ma.update_raw(0.0);
155                self.neg_ma.update_raw(0.0);
156            }
157
158            self.value = self.scalar * self.pos_ma.value();
159            self.value /= self.pos_ma.value() + self.neg_ma.value();
160        }
161
162        self.previous_close = close;
163
164        if !self.initialized {
165            self.has_inputs = true;
166            if self.pos_ma.initialized() {
167                self.initialized = true;
168            }
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use rstest::rstest;
176
177    use super::*;
178    use crate::stubs::rvi_10;
179
180    #[rstest]
181    fn test_name_returns_expected_string(rvi_10: RelativeVolatilityIndex) {
182        assert_eq!(rvi_10.name(), "RelativeVolatilityIndex");
183    }
184
185    #[rstest]
186    fn test_str_repr_returns_expected_string(rvi_10: RelativeVolatilityIndex) {
187        assert_eq!(format!("{rvi_10}"), "RelativeVolatilityIndex(10,10,SIMPLE)");
188    }
189
190    #[rstest]
191    fn test_period_returns_expected_value(rvi_10: RelativeVolatilityIndex) {
192        assert_eq!(rvi_10.period, 10);
193        assert_eq!(rvi_10.scalar, 10.0);
194        assert_eq!(rvi_10.ma_type, MovingAverageType::Simple);
195    }
196
197    #[rstest]
198    fn test_initialized_without_inputs_returns_false(rvi_10: RelativeVolatilityIndex) {
199        assert!(!rvi_10.initialized());
200    }
201
202    #[rstest]
203    fn test_value_with_all_higher_inputs_returns_expected_value(
204        mut rvi_10: RelativeVolatilityIndex,
205    ) {
206        let close_values = [
207            105.25, 107.50, 109.75, 112.00, 114.25, 116.50, 118.75, 121.00, 123.25, 125.50, 127.75,
208            130.00, 132.25, 134.50, 136.75, 139.00, 141.25, 143.50, 145.75, 148.00, 150.25, 152.50,
209            154.75, 157.00, 159.25, 161.50, 163.75, 166.00, 168.25, 170.50,
210        ];
211
212        for close in close_values {
213            rvi_10.update_raw(close);
214        }
215
216        assert!(rvi_10.initialized());
217        assert_eq!(rvi_10.value, 10.0);
218    }
219
220    #[rstest]
221    fn test_reset_successfully_returns_indicator_to_fresh_state(
222        mut rvi_10: RelativeVolatilityIndex,
223    ) {
224        rvi_10.update_raw(1.00020);
225        rvi_10.update_raw(1.00030);
226        rvi_10.update_raw(1.00070);
227
228        rvi_10.reset();
229
230        assert!(!rvi_10.initialized());
231        assert_eq!(rvi_10.value, 0.0);
232        assert!(!rvi_10.initialized);
233        assert!(!rvi_10.has_inputs);
234        assert_eq!(rvi_10.std, 0.0);
235        assert_eq!(rvi_10.prices.len(), 0);
236        assert_eq!(rvi_10.ma.value(), 0.0);
237        assert_eq!(rvi_10.pos_ma.value(), 0.0);
238        assert_eq!(rvi_10.neg_ma.value(), 0.0);
239    }
240}