wickra_core/indicators/
volatility_ratio.rs1use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7#[derive(Debug, Clone)]
48pub struct VolatilityRatio {
49 period: usize,
50 alpha: f64,
51 prev_close: Option<f64>,
52 seed_sum: f64,
54 seed_count: usize,
55 ema: Option<f64>,
57 last: Option<f64>,
58}
59
60impl VolatilityRatio {
61 pub fn new(period: usize) -> Result<Self> {
69 if period == 0 {
70 return Err(Error::PeriodZero);
71 }
72 if period > crate::error::MAX_PERIOD {
73 return Err(Error::InvalidPeriod {
74 message: crate::error::PERIOD_ABOVE_MAX,
75 });
76 }
77 Ok(Self {
78 period,
79 alpha: 2.0 / (period as f64 + 1.0),
80 prev_close: None,
81 seed_sum: 0.0,
82 seed_count: 0,
83 ema: None,
84 last: None,
85 })
86 }
87
88 pub const fn period(&self) -> usize {
90 self.period
91 }
92
93 pub const fn value(&self) -> Option<f64> {
95 self.last
96 }
97}
98
99impl Indicator for VolatilityRatio {
100 type Input = Candle;
101 type Output = f64;
102
103 #[inline]
104 fn update(&mut self, candle: Candle) -> Option<f64> {
105 let Some(prev_close) = self.prev_close else {
107 self.prev_close = Some(candle.close);
108 return None;
109 };
110 let tr = candle.true_range(Some(prev_close));
111 self.prev_close = Some(candle.close);
112
113 match self.ema {
114 None => {
115 self.seed_sum += tr;
118 self.seed_count += 1;
119 if self.seed_count == self.period {
120 self.ema = Some(self.seed_sum / self.period as f64);
121 }
122 None
123 }
124 Some(prev_ema) => {
125 let vr = if prev_ema > 0.0 { tr / prev_ema } else { 0.0 };
128 self.ema = Some(self.alpha * tr + (1.0 - self.alpha) * prev_ema);
129 self.last = Some(vr);
130 Some(vr)
131 }
132 }
133 }
134
135 fn reset(&mut self) {
136 self.prev_close = None;
137 self.seed_sum = 0.0;
138 self.seed_count = 0;
139 self.ema = None;
140 self.last = None;
141 }
142
143 #[inline]
144 fn warmup_period(&self) -> usize {
145 self.period + 2
148 }
149
150 #[inline]
151 fn is_ready(&self) -> bool {
152 self.last.is_some()
153 }
154
155 #[inline]
156 fn name(&self) -> &'static str {
157 "VolatilityRatio"
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::traits::BatchExt;
165 use approx::assert_relative_eq;
166
167 fn candle(high: f64, low: f64, close: f64) -> Candle {
169 Candle::new_unchecked(low, high, low, close, 1_000.0, 0)
170 }
171
172 #[test]
173 fn rejects_zero_period() {
174 assert!(matches!(VolatilityRatio::new(0), Err(Error::PeriodZero)));
175 }
176
177 #[test]
178 fn accessors_and_metadata() {
179 let vr = VolatilityRatio::new(14).unwrap();
180 assert_eq!(vr.period(), 14);
181 assert_eq!(vr.warmup_period(), 16);
182 assert_eq!(vr.name(), "VolatilityRatio");
183 assert!(!vr.is_ready());
184 assert_eq!(vr.value(), None);
185 }
186
187 #[test]
188 fn first_emission_at_warmup_period() {
189 let mut vr = VolatilityRatio::new(3).unwrap();
190 let candles: Vec<Candle> = (0..10)
192 .map(|i| {
193 let base = 100.0 + f64::from(i);
194 candle(base + 1.0, base - 1.0, base)
195 })
196 .collect();
197 let out = vr.batch(&candles);
198 let warmup = vr.warmup_period();
200 assert_eq!(warmup, 5);
201 for v in out.iter().take(warmup - 1) {
202 assert!(v.is_none());
203 }
204 assert!(out[warmup - 1].is_some());
205 }
206
207 #[test]
208 fn wide_ranging_day_exceeds_two() {
209 let mut vr = VolatilityRatio::new(3).unwrap();
212 let mut candles: Vec<Candle> = (0..6)
213 .map(|i| {
214 let base = 100.0 + f64::from(i);
215 candle(base + 1.0, base - 1.0, base) })
217 .collect();
218 candles.push(candle(110.0, 100.0, 105.0));
220 let out = vr.batch(&candles);
221 let last = out.last().unwrap().unwrap();
222 assert!(last > 2.0, "wide-ranging day should exceed 2.0, got {last}");
223 }
224
225 #[test]
226 fn steady_range_ratio_is_one() {
227 let mut vr = VolatilityRatio::new(3).unwrap();
229 let candles: Vec<Candle> = (0..12)
230 .map(|i| {
231 let base = 100.0 + f64::from(i);
232 candle(base + 1.0, base - 1.0, base) })
234 .collect();
235 let out = vr.batch(&candles);
236 assert_relative_eq!(out.last().unwrap().unwrap(), 1.0, epsilon = 1e-9);
237 }
238
239 #[test]
240 fn flat_market_yields_zero() {
241 let mut vr = VolatilityRatio::new(3).unwrap();
243 let candles: Vec<Candle> = (0..10).map(|_| candle(100.0, 100.0, 100.0)).collect();
244 let out = vr.batch(&candles);
245 for v in out.into_iter().flatten() {
246 assert_relative_eq!(v, 0.0, epsilon = 1e-12);
247 }
248 }
249
250 #[test]
251 fn output_is_non_negative() {
252 let mut vr = VolatilityRatio::new(14).unwrap();
253 let candles: Vec<Candle> = (0..200)
254 .map(|i| {
255 let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
256 candle(base + 2.0, base - 2.0, base + 0.5)
257 })
258 .collect();
259 for v in vr.batch(&candles).into_iter().flatten() {
260 assert!(v >= 0.0, "volatility ratio must be non-negative, got {v}");
261 }
262 }
263
264 #[test]
265 fn reset_clears_state() {
266 let mut vr = VolatilityRatio::new(3).unwrap();
267 let candles: Vec<Candle> = (0..10)
268 .map(|i| {
269 let base = 100.0 + f64::from(i);
270 candle(base + 1.0, base - 1.0, base)
271 })
272 .collect();
273 vr.batch(&candles);
274 assert!(vr.is_ready());
275 vr.reset();
276 assert!(!vr.is_ready());
277 assert_eq!(vr.value(), None);
278 assert_eq!(vr.update(candle(101.0, 99.0, 100.0)), None);
279 }
280
281 #[test]
282 fn batch_equals_streaming() {
283 let candles: Vec<Candle> = (0..120)
284 .map(|i| {
285 let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
286 candle(base + 2.0, base - 1.5, base + 0.5)
287 })
288 .collect();
289 let batch = VolatilityRatio::new(14).unwrap().batch(&candles);
290 let mut b = VolatilityRatio::new(14).unwrap();
291 let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
292 assert_eq!(batch, streamed);
293 }
294}