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