Skip to main content

wickra_core/indicators/
historical_volatility.rs

1//! Historical Volatility.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::ShiftedMoments;
7use crate::traits::Indicator;
8
9/// Historical Volatility — the annualised standard deviation of log returns.
10///
11/// This is the realised (backward-looking) volatility used to price options
12/// and size risk:
13///
14/// ```text
15/// r_t = ln(price_t / price_{t−1})
16/// HV  = stddev_sample(r over period) · √trading_periods · 100
17/// ```
18///
19/// The log returns over the window are measured with the **sample** standard
20/// deviation (divisor `n − 1`, the unbiased estimator), then scaled to an
21/// annual figure by `√trading_periods` — `252` for daily bars, `52` for
22/// weekly, `12` for monthly — and expressed as a percentage.
23///
24/// # Example
25///
26/// ```
27/// use wickra_core::{Indicator, HistoricalVolatility};
28///
29/// // 20-bar window, 252 trading days per year.
30/// let mut indicator = HistoricalVolatility::new(20, 252).unwrap();
31/// let mut last = None;
32/// for i in 0..80 {
33///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
34/// }
35/// assert!(last.is_some());
36/// ```
37#[derive(Debug, Clone)]
38pub struct HistoricalVolatility {
39    period: usize,
40    trading_periods: usize,
41    prev_price: Option<f64>,
42    /// Rolling window of the last `period` log returns.
43    window: VecDeque<f64>,
44    moments: ShiftedMoments,
45    last: Option<f64>,
46}
47
48impl HistoricalVolatility {
49    /// Construct a new Historical Volatility indicator.
50    ///
51    /// `period` is the number of log returns in the rolling window;
52    /// `trading_periods` is the annualisation factor (`252` daily, `52`
53    /// weekly, `12` monthly).
54    ///
55    /// # Errors
56    ///
57    /// Returns [`Error::PeriodZero`] if `period` or `trading_periods` is `0`,
58    /// or [`Error::InvalidPeriod`] if `period == 1` (the sample standard
59    /// deviation needs at least two returns).
60    pub fn new(period: usize, trading_periods: usize) -> Result<Self> {
61        if period == 0 || trading_periods == 0 {
62            return Err(Error::PeriodZero);
63        }
64        if period < 2 {
65            return Err(Error::InvalidPeriod {
66                message: "historical volatility period must be >= 2",
67            });
68        }
69        if period > crate::error::MAX_PERIOD {
70            return Err(Error::InvalidPeriod {
71                message: crate::error::PERIOD_ABOVE_MAX,
72            });
73        }
74        Ok(Self {
75            period,
76            trading_periods,
77            prev_price: None,
78            window: VecDeque::with_capacity(period),
79            moments: ShiftedMoments::new(),
80            last: None,
81        })
82    }
83
84    /// Configured `(period, trading_periods)`.
85    pub const fn periods(&self) -> (usize, usize) {
86        (self.period, self.trading_periods)
87    }
88
89    /// Current value if available.
90    pub const fn value(&self) -> Option<f64> {
91        self.last
92    }
93}
94
95impl Indicator for HistoricalVolatility {
96    type Input = f64;
97    type Output = f64;
98
99    #[inline]
100    fn update(&mut self, input: f64) -> Option<f64> {
101        // Non-finite *and* non-positive prices are both ignored: state is left
102        // untouched and `self.last` is returned. The log-return `ln(input /
103        // prev)` is undefined for non-positive prices, and silently
104        // substituting `0.0` (the previous behaviour, audit finding R13) would
105        // underreport realised volatility by treating bad ticks as "no
106        // movement". Skipping them entirely is consistent with how the rest
107        // of the library handles invalid inputs (see SMA / EMA / ROC).
108        if !input.is_finite() || input <= 0.0 {
109            return None;
110        }
111        let Some(prev) = self.prev_price else {
112            self.prev_price = Some(input);
113            return None;
114        };
115        // `prev` was assigned from `self.prev_price`, which only ever holds
116        // valid (finite, positive) inputs because the guard above gates every
117        // assignment to it — so `(input / prev).ln()` is always well-defined.
118        self.prev_price = Some(input);
119
120        let log_return = (input / prev).ln();
121        if self.window.len() == self.period {
122            let old = self.window.pop_front().expect("window is non-empty");
123            self.moments.evict(old);
124        }
125        self.window.push_back(log_return);
126        self.moments.push(log_return);
127        if self.moments.needs_reseed(self.period) {
128            self.moments.reseed(self.window.iter().copied());
129        }
130        if self.window.len() < self.period {
131            return None;
132        }
133        let variance = self.moments.sample_variance(self.period);
134        let hv = variance.sqrt() * (self.trading_periods as f64).sqrt() * 100.0;
135        self.last = Some(hv);
136        Some(hv)
137    }
138
139    fn reset(&mut self) {
140        self.prev_price = None;
141        self.window.clear();
142        self.moments.reset();
143        self.last = None;
144    }
145
146    #[inline]
147    fn warmup_period(&self) -> usize {
148        // The first log return needs a previous price, then the window fills.
149        self.period + 1
150    }
151
152    #[inline]
153    fn is_ready(&self) -> bool {
154        self.last.is_some()
155    }
156
157    #[inline]
158    fn name(&self) -> &'static str {
159        "HistoricalVolatility"
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::traits::BatchExt;
167    use approx::assert_relative_eq;
168
169    #[test]
170    fn new_rejects_zero_period() {
171        assert!(matches!(
172            HistoricalVolatility::new(0, 252),
173            Err(Error::PeriodZero)
174        ));
175        assert!(matches!(
176            HistoricalVolatility::new(20, 0),
177            Err(Error::PeriodZero)
178        ));
179    }
180
181    /// Cover the const accessors `periods` / `value` (80-88) and the
182    /// Indicator-impl `name` body (153-155). Existing tests inspect HV
183    /// output but never query the metadata.
184    #[test]
185    fn accessors_and_metadata() {
186        let mut hv = HistoricalVolatility::new(20, 252).unwrap();
187        assert_eq!(hv.periods(), (20, 252));
188        assert_eq!(hv.name(), "HistoricalVolatility");
189        assert_eq!(hv.value(), None);
190        for i in 1..=hv.warmup_period() {
191            hv.update(100.0 + f64::from(u32::try_from(i).unwrap()));
192        }
193        assert!(hv.value().is_some());
194    }
195
196    #[test]
197    fn new_rejects_period_one() {
198        assert!(matches!(
199            HistoricalVolatility::new(1, 252),
200            Err(Error::InvalidPeriod { .. })
201        ));
202    }
203
204    #[test]
205    fn first_emission_at_warmup_period() {
206        let mut hv = HistoricalVolatility::new(5, 252).unwrap();
207        assert_eq!(hv.warmup_period(), 6);
208        let out = hv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
209        for v in out.iter().take(5) {
210            assert!(v.is_none());
211        }
212        assert!(out[5].is_some());
213    }
214
215    #[test]
216    fn constant_series_yields_zero() {
217        // Flat prices -> all log returns are 0 -> zero volatility.
218        let mut hv = HistoricalVolatility::new(10, 252).unwrap();
219        let out = hv.batch(&[100.0; 40]);
220        for v in out.iter().skip(10).flatten() {
221            assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
222        }
223    }
224
225    #[test]
226    fn geometric_series_yields_zero() {
227        // A constant growth factor gives a constant log return -> zero stddev.
228        // The mathematical result is exactly zero, but `1.01_f64.powi(i)` and
229        // the subsequent log / std-dev cascade accumulate platform-sensitive
230        // floating-point drift on the order of 1e-7 (observed on x86_64 Linux
231        // and macOS; Windows happens to round closer to zero). The 1e-6
232        // tolerance stays four decimal places below any realistic volatility
233        // value while absorbing this drift across every supported platform.
234        let mut hv = HistoricalVolatility::new(10, 252).unwrap();
235        let prices: Vec<f64> = (0..40).map(|i| 100.0 * 1.01_f64.powi(i)).collect();
236        let out = hv.batch(&prices);
237        for v in out.iter().skip(10).flatten() {
238            assert_relative_eq!(*v, 0.0, epsilon = 1e-6);
239        }
240    }
241
242    #[test]
243    fn output_is_non_negative() {
244        let mut hv = HistoricalVolatility::new(20, 252).unwrap();
245        let prices: Vec<f64> = (1..=200)
246            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
247            .collect();
248        for v in hv.batch(&prices).into_iter().flatten() {
249            assert!(v >= 0.0, "volatility must be non-negative, got {v}");
250        }
251    }
252
253    #[test]
254    fn ignores_non_finite_input() {
255        let mut hv = HistoricalVolatility::new(5, 252).unwrap();
256        let out = hv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
257        let last = *out.last().unwrap();
258        assert!(last.is_some());
259        assert_eq!(hv.update(f64::NAN), None);
260        assert_eq!(hv.update(f64::INFINITY), None);
261    }
262
263    /// Audit finding R13. Non-positive prices are now skipped (state left
264    /// untouched) instead of silently treated as a `0.0` log-return — the old
265    /// behaviour underreported realised volatility by treating bad ticks as
266    /// "no movement".
267    #[test]
268    fn skips_non_positive_prices() {
269        let mut hv = HistoricalVolatility::new(5, 252).unwrap();
270        // Warm up with positive prices.
271        let warmup_prices = (1..=20).map(f64::from).collect::<Vec<_>>();
272        let warmup = hv.batch(&warmup_prices);
273        let _baseline = warmup
274            .last()
275            .copied()
276            .flatten()
277            .expect("warmed up by index 5");
278
279        // A negative tick must be ignored: returned value equals the previous
280        // baseline, and the next real positive tick must use the previous
281        // valid price as `prev` (not the bad one), so the next log return is
282        // exactly `ln(21 / 20)`, not `ln(21 / -5)` or anything else.
283        assert_eq!(hv.update(-5.0), None);
284        assert_eq!(hv.update(0.0), None);
285
286        // Snapshot the indicator's state, then advance with a real positive
287        // tick on a clone. The clone must agree with a from-scratch run that
288        // simply skipped the bad ticks — proving the state was untouched.
289        let mut control = hv.clone();
290        let after_real = hv.update(21.0).expect("ready");
291        assert_eq!(control.update(21.0).expect("ready"), after_real);
292    }
293
294    #[test]
295    fn reset_clears_state() {
296        let mut hv = HistoricalVolatility::new(5, 252).unwrap();
297        hv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
298        assert!(hv.is_ready());
299        hv.reset();
300        assert!(!hv.is_ready());
301        assert_eq!(hv.update(1.0), None);
302    }
303
304    #[test]
305    fn batch_equals_streaming() {
306        let prices: Vec<f64> = (1..=120)
307            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
308            .collect();
309        let batch = HistoricalVolatility::new(20, 252).unwrap().batch(&prices);
310        let mut b = HistoricalVolatility::new(20, 252).unwrap();
311        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
312        assert_eq!(batch, streamed);
313    }
314}