Skip to main content

wickra_core/indicators/
volatility_ratio.rs

1//! Schwager's Volatility Ratio — today's true range versus its typical level.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// Schwager's Volatility Ratio — the current bar's true range divided by the
8/// exponential moving average of the *prior* true ranges.
9///
10/// ```text
11/// TR_t = true range of bar t
12/// VR_t = TR_t / EMA_n(TR through bar t−1)
13/// ```
14///
15/// Jack Schwager's volatility ratio measures how today's range compares to its
16/// recent typical level: a reading above `2.0` marks a **wide-ranging day** —
17/// today's true range is more than twice the smoothed average — which often
18/// precedes or accompanies a reversal. The denominator is the exponential
19/// moving average of true range *excluding the current bar*, seeded with the
20/// simple average of the first `period` true ranges, so a single large bar
21/// stands out instead of inflating its own benchmark.
22///
23/// True range is `max(high − low, |high − prev_close|, |low − prev_close|)`,
24/// identical to the [`Atr`](crate::Atr) building block, but here it is compared
25/// to a *standard* EMA (smoothing `2 / (period + 1)`) rather than Wilder
26/// smoothing, which keeps the ratio distinct from `TR / ATR`. Each `update` is
27/// O(1).
28///
29/// A flat market drives every true range — and the EMA — to `0`; the ratio is
30/// then `0.0` rather than an undefined `0 / 0`. `Candle::new` rejects non-finite
31/// fields, so no in-method finiteness guard is needed.
32///
33/// # Example
34///
35/// ```
36/// use wickra_core::{Candle, Indicator, VolatilityRatio};
37///
38/// let mut indicator = VolatilityRatio::new(14).unwrap();
39/// let mut last = None;
40/// for i in 0..40 {
41///     let base = 100.0 + f64::from(i);
42///     let candle = Candle::new(base, base + 2.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
43///     last = indicator.update(candle);
44/// }
45/// assert!(last.is_some());
46/// ```
47#[derive(Debug, Clone)]
48pub struct VolatilityRatio {
49    period: usize,
50    alpha: f64,
51    prev_close: Option<f64>,
52    /// Sum and count of the first `period` true ranges, used to seed the EMA.
53    seed_sum: f64,
54    seed_count: usize,
55    /// EMA of true range through the previous bar; `None` until seeded.
56    ema: Option<f64>,
57    last: Option<f64>,
58}
59
60impl VolatilityRatio {
61    /// Construct a new volatility-ratio indicator.
62    ///
63    /// `period` is the number of true ranges that seed and smooth the
64    /// denominator EMA.
65    ///
66    /// # Errors
67    /// Returns [`Error::PeriodZero`] if `period == 0`.
68    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    /// Configured period.
89    pub const fn period(&self) -> usize {
90        self.period
91    }
92
93    /// Current value if available.
94    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        // The first bar has no previous close, so no true range can be formed.
106        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                // Seeding the EMA with the simple average of the first `period`
116                // true ranges; emit nothing until it is established.
117                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                // Denominator excludes the current bar (it is the EMA through the
126                // previous bar). A flat benchmark yields 0.0, not 0/0.
127                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        // Bar 1 sets the previous close; bars 2..=period+1 seed the EMA; the
146        // first ratio is emitted on bar period + 2.
147        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    /// Build a candle with the given high/low/close (open = low, fixed volume).
168    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        // Build enough constant-range candles to reach warmup.
191        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        // warmup_period == period + 2 == 5: the first emission is at index 4.
199        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        // Steady true range of 2.0 seeds the EMA, then one bar with a far wider
210        // range pushes the ratio above 2.0.
211        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) // TR = 2.0 each
216            })
217            .collect();
218        // A wide bar: range 10 around the last close (~105).
219        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        // Constant true range -> EMA equals it -> ratio is exactly 1.0.
228        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) // TR = 2.0 each
233            })
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        // Zero-range candles: TR = 0, EMA = 0, ratio guarded to 0.0.
242        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}