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        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    /// Configured period.
84    pub const fn period(&self) -> usize {
85        self.period
86    }
87
88    /// Current value if available.
89    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        // The first bar has no previous close, so no true range can be formed.
100        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                // Seeding the EMA with the simple average of the first `period`
110                // true ranges; emit nothing until it is established.
111                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                // Denominator excludes the current bar (it is the EMA through the
120                // previous bar). A flat benchmark yields 0.0, not 0/0.
121                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        // Bar 1 sets the previous close; bars 2..=period+1 seed the EMA; the
139        // first ratio is emitted on bar period + 2.
140        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    /// Build a candle with the given high/low/close (open = low, fixed volume).
159    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        // Build enough constant-range candles to reach warmup.
182        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        // warmup_period == period + 2 == 5: the first emission is at index 4.
190        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        // Steady true range of 2.0 seeds the EMA, then one bar with a far wider
201        // range pushes the ratio above 2.0.
202        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) // TR = 2.0 each
207            })
208            .collect();
209        // A wide bar: range 10 around the last close (~105).
210        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        // Constant true range -> EMA equals it -> ratio is exactly 1.0.
219        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) // TR = 2.0 each
224            })
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        // Zero-range candles: TR = 0, EMA = 0, ratio guarded to 0.0.
233        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}