Skip to main content

nautilus_indicators/volatility/
vr.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 nautilus_core::correctness::FAILED;
19use nautilus_model::data::{Bar, QuoteTick, TradeTick};
20
21use crate::{indicator::Indicator, support::MAX_PERIOD};
22
23/// Schwager volatility ratio.
24#[repr(C)]
25#[derive(Debug)]
26#[cfg_attr(
27    feature = "python",
28    pyo3::pyclass(module = "nautilus_trader.indicators")
29)]
30#[cfg_attr(
31    feature = "python",
32    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
33)]
34pub struct VolatilityRatio {
35    pub period: usize,
36    pub value: f64,
37    pub count: usize,
38    pub initialized: bool,
39    has_inputs: bool,
40    alpha: f64,
41    previous_close: Option<f64>,
42    seed_sum: f64,
43    seed_count: usize,
44    average: Option<f64>,
45}
46
47impl Display for VolatilityRatio {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        write!(f, "{}({})", self.name(), self.period)
50    }
51}
52
53impl Indicator for VolatilityRatio {
54    fn name(&self) -> String {
55        stringify!(VolatilityRatio).to_string()
56    }
57
58    fn has_inputs(&self) -> bool {
59        self.has_inputs
60    }
61
62    fn initialized(&self) -> bool {
63        self.initialized
64    }
65
66    fn handle_quote(&mut self, _quote: &QuoteTick) -> anyhow::Result<()> {
67        Ok(())
68    }
69
70    fn handle_trade(&mut self, _trade: &TradeTick) {}
71
72    fn handle_bar(&mut self, bar: &Bar) {
73        self.update_raw((&bar.high).into(), (&bar.low).into(), (&bar.close).into());
74    }
75
76    fn reset(&mut self) {
77        self.value = 0.0;
78        self.count = 0;
79        self.initialized = false;
80        self.has_inputs = false;
81        self.previous_close = None;
82        self.seed_sum = 0.0;
83        self.seed_count = 0;
84        self.average = None;
85    }
86}
87
88impl VolatilityRatio {
89    /// Creates Schwager's volatility ratio.
90    ///
91    /// # Panics
92    ///
93    /// Panics if `period` is zero.
94    #[must_use]
95    pub fn new(period: usize) -> Self {
96        Self::new_checked(period).expect(FAILED)
97    }
98
99    pub(crate) fn new_checked(period: usize) -> anyhow::Result<Self> {
100        anyhow::ensure!(period <= MAX_PERIOD, "period cannot exceed {MAX_PERIOD}");
101        anyhow::ensure!(
102            period > 0,
103            "VolatilityRatio: period must be > 0 (received {period})"
104        );
105        Ok(Self {
106            period,
107            value: 0.0,
108            count: 0,
109            initialized: false,
110            has_inputs: false,
111            alpha: 2.0 / (period as f64 + 1.0),
112            previous_close: None,
113            seed_sum: 0.0,
114            seed_count: 0,
115            average: None,
116        })
117    }
118
119    /// Updates the indicator from high, low, and close prices.
120    pub fn update_raw(&mut self, high: f64, low: f64, close: f64) {
121        if !high.is_finite()
122            || !low.is_finite()
123            || !close.is_finite()
124            || high < low
125            || close < low
126            || close > high
127        {
128            return;
129        }
130        let true_range = self.previous_close.map(|previous_close| {
131            (high - low)
132                .max((high - previous_close).abs())
133                .max((low - previous_close).abs())
134        });
135
136        self.previous_close = Some(close);
137        self.count += 1;
138        self.has_inputs = true;
139        let Some(true_range) = true_range else {
140            return;
141        };
142        let Some(previous_average) = self.average else {
143            self.seed_sum += true_range;
144            self.seed_count += 1;
145            if self.seed_count == self.period {
146                self.average = Some(self.seed_sum / self.period as f64);
147            }
148            return;
149        };
150
151        self.value = if previous_average > 0.0 {
152            true_range / previous_average
153        } else {
154            0.0
155        };
156        self.average = Some(
157            self.alpha
158                .mul_add(true_range, (1.0 - self.alpha) * previous_average),
159        );
160        self.initialized = true;
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use rstest::rstest;
167
168    use super::*;
169    use crate::stubs::{stub_quote, stub_trade, vr_10};
170
171    const BARS: [(f64, f64, f64); 5] = [
172        (10.0, 8.0, 9.0),
173        (11.0, 9.0, 10.0),
174        (12.0, 10.0, 11.0),
175        (14.0, 11.0, 13.0),
176        (13.0, 10.0, 12.0),
177    ];
178
179    fn feed(indicator: &mut VolatilityRatio, count: usize) {
180        for &(high, low, close) in &BARS[..count] {
181            indicator.update_raw(high, low, close);
182        }
183    }
184
185    #[rstest]
186    fn test_name_and_display(vr_10: VolatilityRatio) {
187        assert_eq!(vr_10.name(), "VolatilityRatio");
188        assert_eq!(format!("{vr_10}"), "VolatilityRatio(10)");
189        assert_eq!(vr_10.period, 10);
190        assert!(!vr_10.initialized());
191        assert!(!vr_10.has_inputs());
192    }
193
194    #[rstest]
195    #[should_panic(expected = "period must be > 0")]
196    fn test_zero_period_panics() {
197        let _ = VolatilityRatio::new(0);
198    }
199
200    #[rstest]
201    fn test_period_above_maximum_is_rejected() {
202        assert!(VolatilityRatio::new_checked(MAX_PERIOD + 1).is_err());
203    }
204
205    #[rstest]
206    #[case(1, 0.0, false)]
207    #[case(2, 0.0, false)]
208    #[case(3, 0.0, false)]
209    #[case(4, 1.5, true)]
210    fn test_readiness_follows_seeded_true_range_average(
211        #[case] inputs: usize,
212        #[case] expected: f64,
213        #[case] initialized: bool,
214    ) {
215        let mut indicator = VolatilityRatio::new(2);
216        feed(&mut indicator, inputs);
217
218        assert_eq!(indicator.count, inputs);
219        assert_eq!(indicator.value, expected);
220        assert_eq!(indicator.initialized(), initialized);
221        assert!(indicator.has_inputs());
222    }
223
224    #[rstest]
225    fn test_value_divides_true_range_by_prior_average() {
226        let mut indicator = VolatilityRatio::new(2);
227        feed(&mut indicator, 5);
228
229        assert_eq!(indicator.value, 3.0 / (8.0 / 3.0));
230    }
231
232    #[rstest]
233    fn test_flat_market_yields_zero() {
234        let mut indicator = VolatilityRatio::new(2);
235        for _ in 0..6 {
236            indicator.update_raw(5.0, 5.0, 5.0);
237        }
238
239        assert!(indicator.initialized());
240        assert_eq!(indicator.value, 0.0);
241    }
242
243    #[rstest]
244    #[case(f64::NAN, 1.0, 1.0)]
245    #[case(2.0, f64::INFINITY, 1.0)]
246    #[case(1.0, 2.0, 1.5)]
247    #[case(2.0, 1.0, 3.0)]
248    #[case(2.0, 1.0, 0.5)]
249    fn test_rejected_candle_leaves_state_unchanged(
250        #[case] high: f64,
251        #[case] low: f64,
252        #[case] close: f64,
253    ) {
254        let mut indicator = VolatilityRatio::new(2);
255        feed(&mut indicator, 3);
256        indicator.update_raw(high, low, close);
257        indicator.update_raw(BARS[3].0, BARS[3].1, BARS[3].2);
258
259        assert_eq!(indicator.count, 4);
260        assert_eq!(indicator.value, 1.5);
261    }
262
263    #[rstest]
264    fn test_reset_restores_fresh_state() {
265        let mut indicator = VolatilityRatio::new(2);
266        feed(&mut indicator, 5);
267        indicator.reset();
268
269        assert_eq!(indicator.count, 0);
270        assert_eq!(indicator.value, 0.0);
271        assert!(!indicator.initialized());
272        assert!(!indicator.has_inputs());
273
274        feed(&mut indicator, 4);
275        assert_eq!(indicator.value, 1.5);
276    }
277
278    #[rstest]
279    fn test_quote_and_trade_are_ignored(stub_quote: QuoteTick, stub_trade: TradeTick) {
280        let mut indicator = VolatilityRatio::new(2);
281
282        let result = indicator.handle_quote(&stub_quote);
283        indicator.handle_trade(&stub_trade);
284
285        assert!(result.is_ok());
286        assert!(!indicator.has_inputs());
287        assert_eq!(indicator.count, 0);
288        assert_eq!(indicator.value, 0.0);
289    }
290}