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