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