nautilus_indicators/volatility/
vr.rs1use 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#[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 #[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 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}