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