Skip to main content

wickra_core/indicators/
realized_volatility.rs

1//! Realized Volatility from the sum of squared log returns.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Realized Volatility — the square root of the sum of squared log returns over
9/// the trailing `period` bars.
10///
11/// ```text
12/// r_t = ln(price_t / price_{t−1})
13/// RV  = √( Σ r_t²  over the last `period` returns )
14/// ```
15///
16/// Unlike [`HistoricalVolatility`](crate::HistoricalVolatility) — which reports
17/// the *annualised sample standard deviation* of log returns (mean-centred,
18/// divided by `n − 1`, scaled by `√trading_periods` and ×100) — realized
19/// volatility is the **raw, un-centred, un-annualised** quadratic variation
20/// estimator used in high-frequency econometrics. It makes no Gaussian
21/// assumption and no mean subtraction: it simply accumulates squared returns,
22/// which converges to the integrated variance of the price path as the
23/// sampling frequency rises. Multiply by `√trading_periods` yourself if an
24/// annual figure is wanted.
25///
26/// Non-finite and non-positive prices are ignored (the log return would be
27/// undefined): the tick is dropped, state is left untouched, and the last
28/// value is returned.
29///
30/// Each `update` is O(1): a running sum of squared returns is maintained over
31/// the rolling window.
32///
33/// # Example
34///
35/// ```
36/// use wickra_core::{Indicator, RealizedVolatility};
37///
38/// let mut indicator = RealizedVolatility::new(20).unwrap();
39/// let mut last = None;
40/// for i in 0..80 {
41///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
42/// }
43/// assert!(last.is_some());
44/// ```
45#[derive(Debug, Clone)]
46pub struct RealizedVolatility {
47    period: usize,
48    prev_price: Option<f64>,
49    /// Rolling window of the last `period` log returns.
50    window: VecDeque<f64>,
51    sum_sq: f64,
52    last: Option<f64>,
53}
54
55impl RealizedVolatility {
56    /// Construct a new realized-volatility indicator.
57    ///
58    /// `period` is the number of squared log returns accumulated in the window.
59    ///
60    /// # Errors
61    /// Returns [`Error::PeriodZero`] if `period == 0`.
62    pub fn new(period: usize) -> Result<Self> {
63        if period == 0 {
64            return Err(Error::PeriodZero);
65        }
66        Ok(Self {
67            period,
68            prev_price: None,
69            window: VecDeque::with_capacity(period),
70            sum_sq: 0.0,
71            last: None,
72        })
73    }
74
75    /// Configured period.
76    pub const fn period(&self) -> usize {
77        self.period
78    }
79}
80
81impl Indicator for RealizedVolatility {
82    type Input = f64;
83    type Output = f64;
84
85    fn update(&mut self, input: f64) -> Option<f64> {
86        // Non-finite / non-positive prices are skipped: `ln(input / prev)` is
87        // undefined, so the tick must not enter the return window.
88        if !input.is_finite() || input <= 0.0 {
89            return self.last;
90        }
91        let Some(prev) = self.prev_price else {
92            self.prev_price = Some(input);
93            return None;
94        };
95        self.prev_price = Some(input);
96        // `prev` came from `self.prev_price`, gated by the guard above, so it is
97        // finite and positive — the log return is always well-defined.
98        let r = (input / prev).ln();
99        if self.window.len() == self.period {
100            let old = self.window.pop_front().expect("window is non-empty");
101            self.sum_sq -= old * old;
102        }
103        self.window.push_back(r);
104        self.sum_sq += r * r;
105        if self.window.len() < self.period {
106            return None;
107        }
108        // Floating-point subtraction in the rolling sum can leave a tiny
109        // negative residual when every return is ~0; clamp before the sqrt.
110        let rv = self.sum_sq.max(0.0).sqrt();
111        self.last = Some(rv);
112        Some(rv)
113    }
114
115    fn reset(&mut self) {
116        self.prev_price = None;
117        self.window.clear();
118        self.sum_sq = 0.0;
119        self.last = None;
120    }
121
122    fn warmup_period(&self) -> usize {
123        // The first log return needs a previous price, then the window fills.
124        self.period + 1
125    }
126
127    fn is_ready(&self) -> bool {
128        self.last.is_some()
129    }
130
131    fn name(&self) -> &'static str {
132        "RealizedVolatility"
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::traits::BatchExt;
140    use approx::assert_relative_eq;
141
142    #[test]
143    fn rejects_zero_period() {
144        assert!(matches!(RealizedVolatility::new(0), Err(Error::PeriodZero)));
145    }
146
147    #[test]
148    fn accessors_and_metadata() {
149        let rv = RealizedVolatility::new(20).unwrap();
150        assert_eq!(rv.period(), 20);
151        assert_eq!(rv.warmup_period(), 21);
152        assert_eq!(rv.name(), "RealizedVolatility");
153        assert!(!rv.is_ready());
154    }
155
156    #[test]
157    fn first_emission_at_warmup_period() {
158        let mut rv = RealizedVolatility::new(5).unwrap();
159        let out = rv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
160        for v in out.iter().take(5) {
161            assert!(v.is_none());
162        }
163        assert!(out[5].is_some());
164    }
165
166    #[test]
167    fn known_value() {
168        // Two equal +10% steps: r = ln(1.1) each. RV = √(2·ln(1.1)²).
169        let mut rv = RealizedVolatility::new(2).unwrap();
170        let out = rv.batch(&[100.0, 110.0, 121.0]);
171        let expected = (2.0 * (1.1_f64).ln().powi(2)).sqrt();
172        assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-12);
173    }
174
175    #[test]
176    fn constant_series_yields_zero() {
177        let mut rv = RealizedVolatility::new(10).unwrap();
178        for v in rv.batch(&[100.0; 40]).into_iter().flatten() {
179            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
180        }
181    }
182
183    #[test]
184    fn output_is_non_negative() {
185        let mut rv = RealizedVolatility::new(20).unwrap();
186        let prices: Vec<f64> = (1..=200)
187            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
188            .collect();
189        for v in rv.batch(&prices).into_iter().flatten() {
190            assert!(
191                v >= 0.0,
192                "realized volatility must be non-negative, got {v}"
193            );
194        }
195    }
196
197    #[test]
198    fn ignores_non_finite_input() {
199        let mut rv = RealizedVolatility::new(5).unwrap();
200        let out = rv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
201        let last = *out.last().unwrap();
202        assert!(last.is_some());
203        assert_eq!(rv.update(f64::NAN), last);
204        assert_eq!(rv.update(f64::INFINITY), last);
205    }
206
207    #[test]
208    fn skips_non_positive_prices() {
209        let mut rv = RealizedVolatility::new(5).unwrap();
210        let warmup = rv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
211        let baseline = warmup.last().copied().flatten().expect("warmed up");
212        assert_eq!(rv.update(-5.0), Some(baseline));
213        assert_eq!(rv.update(0.0), Some(baseline));
214        // State untouched: a clone advanced by the same real tick agrees.
215        let mut control = rv.clone();
216        let after = rv.update(21.0).expect("ready");
217        assert_eq!(control.update(21.0).expect("ready"), after);
218    }
219
220    #[test]
221    fn reset_clears_state() {
222        let mut rv = RealizedVolatility::new(5).unwrap();
223        rv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
224        assert!(rv.is_ready());
225        rv.reset();
226        assert!(!rv.is_ready());
227        assert_eq!(rv.update(1.0), None);
228    }
229
230    #[test]
231    fn batch_equals_streaming() {
232        let prices: Vec<f64> = (1..=120)
233            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
234            .collect();
235        let batch = RealizedVolatility::new(20).unwrap().batch(&prices);
236        let mut b = RealizedVolatility::new(20).unwrap();
237        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
238        assert_eq!(batch, streamed);
239    }
240}