Skip to main content

wickra_core/indicators/
volatility_cone.rs

1//! Volatility Cone — current realized volatility within its historical envelope.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::ShiftedMoments;
7use crate::ohlcv::Candle;
8use crate::traits::Indicator;
9
10/// Output of [`VolatilityCone`]: the current realized volatility together with
11/// the envelope (the "cone") it sits inside over the lookback window.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct VolatilityConeOutput {
14    /// Latest realized volatility (sample stddev of log returns over `window`).
15    pub current: f64,
16    /// Lowest realized volatility seen over the `lookback` window.
17    pub min: f64,
18    /// Median realized volatility over the `lookback` window.
19    pub median: f64,
20    /// Highest realized volatility seen over the `lookback` window.
21    pub max: f64,
22    /// Percentile rank of `current` within the lookback distribution, in
23    /// `[0, 100]` — the share of stored volatilities `<= current`, times 100.
24    pub percentile: f64,
25}
26
27/// Volatility Cone — the current realized volatility positioned within the
28/// historical range ("cone") of realized volatilities over a lookback window.
29///
30/// ```text
31/// r_t     = ln(close_t / close_{t−1})
32/// vol_t   = stddev_sample(r over window)            (rolling realized volatility)
33/// cone    = { min, median, max, percentile } of vol over the last `lookback`
34/// ```
35///
36/// A volatility cone (Burghardt & Lane 1990) shows whether current volatility is
37/// high or low *relative to its own history*, rather than as an absolute number.
38/// This streaming form tracks one horizon: it maintains the rolling realized
39/// volatility of log returns over `window`, then reports the latest reading
40/// (`current`) alongside the `min`, `median`, `max` and percentile rank of that
41/// volatility series over the trailing `lookback`. `current` always lies within
42/// `[min, max]` because it is itself the newest member of the lookback set.
43///
44/// Only the candle's **close** is used (the log-return series); the high and low
45/// are ignored. The volatility is per-period (sample stddev of log returns, not
46/// annualised) — multiply by `√trading_periods` for an annual figure. Each
47/// `update` is O(`lookback log lookback`) from sorting the envelope.
48///
49/// Non-positive closes are ignored (the log return would be undefined): the tick
50/// is dropped, state is left untouched, and the last value is returned.
51///
52/// # Example
53///
54/// ```
55/// use wickra_core::{Candle, Indicator, VolatilityCone};
56///
57/// let mut indicator = VolatilityCone::new(20, 60).unwrap();
58/// let mut last = None;
59/// for i in 0..120 {
60///     let c = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
61///     let candle = Candle::new(c, c + 1.0, c - 1.0, c, 1_000.0, 0).unwrap();
62///     last = indicator.update(candle);
63/// }
64/// assert!(last.is_some());
65/// ```
66#[derive(Debug, Clone)]
67pub struct VolatilityCone {
68    window: usize,
69    lookback: usize,
70    prev_close: Option<f64>,
71    /// Rolling window of log returns for the inner realized-volatility series.
72    returns: VecDeque<f64>,
73    ret_moments: ShiftedMoments,
74    /// Rolling window of realized-volatility readings (the cone envelope).
75    vols: VecDeque<f64>,
76    /// Reusable scratch buffer to avoid allocating per `update`.
77    scratch: Vec<f64>,
78    last: Option<VolatilityConeOutput>,
79}
80
81impl VolatilityCone {
82    /// Construct a new volatility-cone indicator.
83    ///
84    /// `window` is the realized-volatility estimation window; `lookback` is the
85    /// number of volatility readings forming the historical cone.
86    ///
87    /// # Errors
88    /// Returns [`Error::PeriodZero`] if either argument is `0`, or
89    /// [`Error::InvalidPeriod`] if `window < 2` (a sample stddev needs two
90    /// returns) or `lookback < 2` (an envelope needs at least two readings).
91    pub fn new(window: usize, lookback: usize) -> Result<Self> {
92        if window == 0 || lookback == 0 {
93            return Err(Error::PeriodZero);
94        }
95        if window < 2 || lookback < 2 {
96            return Err(Error::InvalidPeriod {
97                message: "volatility cone window and lookback must both be >= 2",
98            });
99        }
100        Ok(Self {
101            window,
102            lookback,
103            prev_close: None,
104            returns: VecDeque::with_capacity(window),
105            ret_moments: ShiftedMoments::new(),
106            vols: VecDeque::with_capacity(lookback),
107            scratch: Vec::with_capacity(lookback),
108            last: None,
109        })
110    }
111
112    /// Configured `(window, lookback)`.
113    pub const fn windows(&self) -> (usize, usize) {
114        (self.window, self.lookback)
115    }
116
117    /// Current value if available.
118    pub const fn value(&self) -> Option<VolatilityConeOutput> {
119        self.last
120    }
121}
122
123impl Indicator for VolatilityCone {
124    type Input = Candle;
125    type Output = VolatilityConeOutput;
126
127    fn update(&mut self, candle: Candle) -> Option<VolatilityConeOutput> {
128        let price = candle.close;
129        // A log return is undefined for a non-positive close; skip the tick.
130        if price <= 0.0 {
131            return self.last;
132        }
133        let Some(prev) = self.prev_close else {
134            self.prev_close = Some(price);
135            return None;
136        };
137        self.prev_close = Some(price);
138        // `prev` came from `self.prev_close`, gated by the guard above, so it is
139        // positive — the log return is always well-defined.
140        let r = (price / prev).ln();
141
142        // Stage one: rolling sample volatility of log returns.
143        if self.returns.len() == self.window {
144            let old = self.returns.pop_front().expect("returns window non-empty");
145            self.ret_moments.evict(old);
146        }
147        self.returns.push_back(r);
148        self.ret_moments.push(r);
149        if self.ret_moments.needs_reseed(self.window) {
150            self.ret_moments.reseed(self.returns.iter().copied());
151        }
152        if self.returns.len() < self.window {
153            return None;
154        }
155        let current = self.ret_moments.sample_variance(self.window).sqrt();
156
157        // Stage two: maintain the lookback envelope of volatility readings.
158        if self.vols.len() == self.lookback {
159            self.vols.pop_front();
160        }
161        self.vols.push_back(current);
162        if self.vols.len() < self.lookback {
163            return None;
164        }
165
166        self.scratch.clear();
167        self.scratch.extend(self.vols.iter().copied());
168        self.scratch.sort_unstable_by(f64::total_cmp);
169        let min = self.scratch[0];
170        let max = self.scratch[self.lookback - 1];
171        let mid = self.lookback / 2;
172        let median = if self.lookback % 2 == 1 {
173            self.scratch[mid]
174        } else {
175            f64::midpoint(self.scratch[mid - 1], self.scratch[mid])
176        };
177        let count_le = self.vols.iter().filter(|&&v| v <= current).count();
178        let percentile = count_le as f64 / self.lookback as f64 * 100.0;
179
180        let out = VolatilityConeOutput {
181            current,
182            min,
183            median,
184            max,
185            percentile,
186        };
187        self.last = Some(out);
188        Some(out)
189    }
190
191    fn reset(&mut self) {
192        self.prev_close = None;
193        self.returns.clear();
194        self.ret_moments.reset();
195        self.vols.clear();
196        self.scratch.clear();
197        self.last = None;
198    }
199
200    #[inline]
201    fn warmup_period(&self) -> usize {
202        // One previous close for the first return, `window` returns for the
203        // first volatility, then `lookback` volatilities for the envelope.
204        self.window + self.lookback
205    }
206
207    #[inline]
208    fn is_ready(&self) -> bool {
209        self.last.is_some()
210    }
211
212    #[inline]
213    fn name(&self) -> &'static str {
214        "VolatilityCone"
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::traits::BatchExt;
222    use approx::assert_relative_eq;
223
224    /// Candle whose close drives the indicator (open = high = low = close here).
225    fn close_candle(close: f64) -> Candle {
226        Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
227    }
228
229    #[test]
230    fn rejects_zero_window() {
231        assert!(matches!(VolatilityCone::new(0, 10), Err(Error::PeriodZero)));
232        assert!(matches!(VolatilityCone::new(10, 0), Err(Error::PeriodZero)));
233    }
234
235    #[test]
236    fn rejects_window_one() {
237        assert!(matches!(
238            VolatilityCone::new(1, 10),
239            Err(Error::InvalidPeriod { .. })
240        ));
241        assert!(matches!(
242            VolatilityCone::new(10, 1),
243            Err(Error::InvalidPeriod { .. })
244        ));
245    }
246
247    #[test]
248    fn accessors_and_metadata() {
249        let vc = VolatilityCone::new(20, 60).unwrap();
250        assert_eq!(vc.windows(), (20, 60));
251        assert_eq!(vc.warmup_period(), 80);
252        assert_eq!(vc.name(), "VolatilityCone");
253        assert!(!vc.is_ready());
254        assert_eq!(vc.value(), None);
255    }
256
257    #[test]
258    fn first_emission_at_warmup_period() {
259        let mut vc = VolatilityCone::new(2, 2).unwrap();
260        let prices = [100.0, 110.0, 121.0, 100.0, 105.0, 99.0];
261        let candles: Vec<Candle> = prices.iter().map(|p| close_candle(*p)).collect();
262        let out = vc.batch(&candles);
263        let warmup = vc.warmup_period(); // 4
264        assert_eq!(warmup, 4);
265        for v in out.iter().take(warmup - 1) {
266            assert!(v.is_none());
267        }
268        assert!(out[warmup - 1].is_some());
269    }
270
271    #[test]
272    fn known_value() {
273        // window = 2 -> vol = |r_t − r_{t−1}| / √2; lookback = 2.
274        // prices: r1 = r2 = ln(1.1), r3 = ln(100/121).
275        let mut vc = VolatilityCone::new(2, 2).unwrap();
276        let candles: Vec<Candle> = [100.0, 110.0, 121.0, 100.0]
277            .iter()
278            .map(|p| close_candle(*p))
279            .collect();
280        let out = vc.batch(&candles);
281        let r2 = (121.0_f64 / 110.0).ln();
282        let r3 = (100.0_f64 / 121.0).ln();
283        let vol2 = (r2 - r3).abs() / 2.0_f64.sqrt();
284        let o = out[3].unwrap();
285        assert_relative_eq!(o.current, vol2, epsilon = 1e-9);
286        assert_relative_eq!(o.min, 0.0, epsilon = 1e-9); // vol1 = 0 (r1 == r2)
287        assert_relative_eq!(o.max, vol2, epsilon = 1e-9);
288        assert_relative_eq!(o.median, vol2 / 2.0, epsilon = 1e-9);
289        assert_relative_eq!(o.percentile, 100.0, epsilon = 1e-9);
290    }
291
292    #[test]
293    fn odd_lookback_median_is_middle() {
294        // lookback = 3 picks the middle of the sorted envelope.
295        let mut vc = VolatilityCone::new(2, 3).unwrap();
296        let candles: Vec<Candle> = [100.0, 101.0, 103.0, 100.0, 104.0, 99.0, 106.0]
297            .iter()
298            .map(|p| close_candle(*p))
299            .collect();
300        let out = vc.batch(&candles);
301        let o = out.last().unwrap().unwrap();
302        assert!(o.min <= o.median && o.median <= o.max);
303    }
304
305    #[test]
306    fn envelope_brackets_current() {
307        let mut vc = VolatilityCone::new(10, 30).unwrap();
308        let candles: Vec<Candle> = (0..200)
309            .map(|i| close_candle(100.0 + (f64::from(i) * 0.3).sin() * 12.0))
310            .collect();
311        for o in vc.batch(&candles).into_iter().flatten() {
312            assert!(o.min <= o.current && o.current <= o.max);
313            assert!(o.min <= o.median && o.median <= o.max);
314            assert!(o.percentile > 0.0 && o.percentile <= 100.0);
315        }
316    }
317
318    #[test]
319    fn constant_series_yields_zero_cone() {
320        let mut vc = VolatilityCone::new(5, 5).unwrap();
321        let candles: Vec<Candle> = (0..40).map(|_| close_candle(100.0)).collect();
322        for o in vc.batch(&candles).into_iter().flatten() {
323            assert_relative_eq!(o.current, 0.0, epsilon = 1e-12);
324            assert_relative_eq!(o.min, 0.0, epsilon = 1e-12);
325            assert_relative_eq!(o.max, 0.0, epsilon = 1e-12);
326            assert_relative_eq!(o.median, 0.0, epsilon = 1e-12);
327            assert_relative_eq!(o.percentile, 100.0, epsilon = 1e-12);
328        }
329    }
330
331    #[test]
332    fn skips_non_positive_close() {
333        let mut vc = VolatilityCone::new(2, 2).unwrap();
334        let candles: Vec<Candle> = [100.0, 110.0, 121.0, 100.0]
335            .iter()
336            .map(|p| close_candle(*p))
337            .collect();
338        let warmup = vc.batch(&candles);
339        let baseline = warmup.last().copied().flatten().expect("warmed up");
340        // A non-positive close is skipped and the previous value is returned.
341        assert_eq!(vc.update(close_candle(0.0)), Some(baseline));
342        // State untouched: a clone advanced by the same real tick agrees.
343        let mut control = vc.clone();
344        let after = vc.update(close_candle(105.0)).expect("ready");
345        assert_eq!(control.update(close_candle(105.0)).expect("ready"), after);
346    }
347
348    #[test]
349    fn skips_non_positive_before_first_close() {
350        let mut vc = VolatilityCone::new(2, 2).unwrap();
351        assert_eq!(vc.update(close_candle(0.0)), None);
352        assert_eq!(vc.update(close_candle(100.0)), None);
353    }
354
355    #[test]
356    fn reset_clears_state() {
357        let mut vc = VolatilityCone::new(2, 2).unwrap();
358        let candles: Vec<Candle> = [100.0, 110.0, 121.0, 100.0, 105.0]
359            .iter()
360            .map(|p| close_candle(*p))
361            .collect();
362        vc.batch(&candles);
363        assert!(vc.is_ready());
364        vc.reset();
365        assert!(!vc.is_ready());
366        assert_eq!(vc.value(), None);
367        assert_eq!(vc.update(close_candle(100.0)), None);
368    }
369
370    #[test]
371    fn batch_equals_streaming() {
372        let candles: Vec<Candle> = (0..200)
373            .map(|i| close_candle(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
374            .collect();
375        let batch = VolatilityCone::new(10, 30).unwrap().batch(&candles);
376        let mut b = VolatilityCone::new(10, 30).unwrap();
377        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
378        assert_eq!(batch, streamed);
379    }
380}