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::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Output of [`VolatilityCone`]: the current realized volatility together with
10/// the envelope (the "cone") it sits inside over the lookback window.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct VolatilityConeOutput {
13    /// Latest realized volatility (sample stddev of log returns over `window`).
14    pub current: f64,
15    /// Lowest realized volatility seen over the `lookback` window.
16    pub min: f64,
17    /// Median realized volatility over the `lookback` window.
18    pub median: f64,
19    /// Highest realized volatility seen over the `lookback` window.
20    pub max: f64,
21    /// Percentile rank of `current` within the lookback distribution, in
22    /// `[0, 100]` — the share of stored volatilities `<= current`, times 100.
23    pub percentile: f64,
24}
25
26/// Sample standard deviation from a running `(sum, sum_of_squares, count)`.
27fn sample_stddev(sum: f64, sum_sq: f64, count: usize) -> f64 {
28    let n = count as f64;
29    let mean = sum / n;
30    let variance = ((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
31    variance.sqrt()
32}
33
34/// Volatility Cone — the current realized volatility positioned within the
35/// historical range ("cone") of realized volatilities over a lookback window.
36///
37/// ```text
38/// r_t     = ln(close_t / close_{t−1})
39/// vol_t   = stddev_sample(r over window)            (rolling realized volatility)
40/// cone    = { min, median, max, percentile } of vol over the last `lookback`
41/// ```
42///
43/// A volatility cone (Burghardt & Lane 1990) shows whether current volatility is
44/// high or low *relative to its own history*, rather than as an absolute number.
45/// This streaming form tracks one horizon: it maintains the rolling realized
46/// volatility of log returns over `window`, then reports the latest reading
47/// (`current`) alongside the `min`, `median`, `max` and percentile rank of that
48/// volatility series over the trailing `lookback`. `current` always lies within
49/// `[min, max]` because it is itself the newest member of the lookback set.
50///
51/// Only the candle's **close** is used (the log-return series); the high and low
52/// are ignored. The volatility is per-period (sample stddev of log returns, not
53/// annualised) — multiply by `√trading_periods` for an annual figure. Each
54/// `update` is O(`lookback log lookback`) from sorting the envelope.
55///
56/// Non-positive closes are ignored (the log return would be undefined): the tick
57/// is dropped, state is left untouched, and the last value is returned.
58///
59/// # Example
60///
61/// ```
62/// use wickra_core::{Candle, Indicator, VolatilityCone};
63///
64/// let mut indicator = VolatilityCone::new(20, 60).unwrap();
65/// let mut last = None;
66/// for i in 0..120 {
67///     let c = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
68///     let candle = Candle::new(c, c + 1.0, c - 1.0, c, 1_000.0, 0).unwrap();
69///     last = indicator.update(candle);
70/// }
71/// assert!(last.is_some());
72/// ```
73#[derive(Debug, Clone)]
74pub struct VolatilityCone {
75    window: usize,
76    lookback: usize,
77    prev_close: Option<f64>,
78    /// Rolling window of log returns for the inner realized-volatility series.
79    returns: VecDeque<f64>,
80    ret_sum: f64,
81    ret_sum_sq: f64,
82    /// Rolling window of realized-volatility readings (the cone envelope).
83    vols: VecDeque<f64>,
84    last: Option<VolatilityConeOutput>,
85}
86
87impl VolatilityCone {
88    /// Construct a new volatility-cone indicator.
89    ///
90    /// `window` is the realized-volatility estimation window; `lookback` is the
91    /// number of volatility readings forming the historical cone.
92    ///
93    /// # Errors
94    /// Returns [`Error::PeriodZero`] if either argument is `0`, or
95    /// [`Error::InvalidPeriod`] if `window < 2` (a sample stddev needs two
96    /// returns) or `lookback < 2` (an envelope needs at least two readings).
97    pub fn new(window: usize, lookback: usize) -> Result<Self> {
98        if window == 0 || lookback == 0 {
99            return Err(Error::PeriodZero);
100        }
101        if window < 2 || lookback < 2 {
102            return Err(Error::InvalidPeriod {
103                message: "volatility cone window and lookback must both be >= 2",
104            });
105        }
106        Ok(Self {
107            window,
108            lookback,
109            prev_close: None,
110            returns: VecDeque::with_capacity(window),
111            ret_sum: 0.0,
112            ret_sum_sq: 0.0,
113            vols: VecDeque::with_capacity(lookback),
114            last: None,
115        })
116    }
117
118    /// Configured `(window, lookback)`.
119    pub const fn windows(&self) -> (usize, usize) {
120        (self.window, self.lookback)
121    }
122
123    /// Current value if available.
124    pub const fn value(&self) -> Option<VolatilityConeOutput> {
125        self.last
126    }
127}
128
129impl Indicator for VolatilityCone {
130    type Input = Candle;
131    type Output = VolatilityConeOutput;
132
133    fn update(&mut self, candle: Candle) -> Option<VolatilityConeOutput> {
134        let price = candle.close;
135        // A log return is undefined for a non-positive close; skip the tick.
136        if price <= 0.0 {
137            return self.last;
138        }
139        let Some(prev) = self.prev_close else {
140            self.prev_close = Some(price);
141            return None;
142        };
143        self.prev_close = Some(price);
144        // `prev` came from `self.prev_close`, gated by the guard above, so it is
145        // positive — the log return is always well-defined.
146        let r = (price / prev).ln();
147
148        // Stage one: rolling sample volatility of log returns.
149        if self.returns.len() == self.window {
150            let old = self.returns.pop_front().expect("returns window non-empty");
151            self.ret_sum -= old;
152            self.ret_sum_sq -= old * old;
153        }
154        self.returns.push_back(r);
155        self.ret_sum += r;
156        self.ret_sum_sq += r * r;
157        if self.returns.len() < self.window {
158            return None;
159        }
160        let current = sample_stddev(self.ret_sum, self.ret_sum_sq, self.window);
161
162        // Stage two: maintain the lookback envelope of volatility readings.
163        if self.vols.len() == self.lookback {
164            self.vols.pop_front();
165        }
166        self.vols.push_back(current);
167        if self.vols.len() < self.lookback {
168            return None;
169        }
170
171        let mut sorted: Vec<f64> = self.vols.iter().copied().collect();
172        sorted.sort_by(f64::total_cmp);
173        let min = sorted[0];
174        let max = sorted[self.lookback - 1];
175        let mid = self.lookback / 2;
176        let median = if self.lookback % 2 == 1 {
177            sorted[mid]
178        } else {
179            f64::midpoint(sorted[mid - 1], sorted[mid])
180        };
181        let count_le = self.vols.iter().filter(|&&v| v <= current).count();
182        let percentile = count_le as f64 / self.lookback as f64 * 100.0;
183
184        let out = VolatilityConeOutput {
185            current,
186            min,
187            median,
188            max,
189            percentile,
190        };
191        self.last = Some(out);
192        Some(out)
193    }
194
195    fn reset(&mut self) {
196        self.prev_close = None;
197        self.returns.clear();
198        self.ret_sum = 0.0;
199        self.ret_sum_sq = 0.0;
200        self.vols.clear();
201        self.last = None;
202    }
203
204    fn warmup_period(&self) -> usize {
205        // One previous close for the first return, `window` returns for the
206        // first volatility, then `lookback` volatilities for the envelope.
207        self.window + self.lookback
208    }
209
210    fn is_ready(&self) -> bool {
211        self.last.is_some()
212    }
213
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}