Skip to main content

wickra_core/indicators/
ewma_volatility.rs

1//! EWMA Volatility — `RiskMetrics` exponentially-weighted volatility.
2
3use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6/// EWMA Volatility — the `RiskMetrics` exponentially-weighted estimate of the
7/// volatility of log returns.
8///
9/// ```text
10/// r_t  = ln(price_t / price_{t−1})
11/// σ²_t = λ · σ²_{t−1} + (1 − λ) · r²_t
12/// EWMA = √σ²_t
13/// ```
14///
15/// Unlike [`HistoricalVolatility`](crate::HistoricalVolatility) — an equally
16/// weighted, mean-centred sample standard deviation over a fixed window — the
17/// EWMA estimator weights recent squared returns geometrically by the decay
18/// factor `λ`. The most recent return carries weight `1 − λ`, the one before it
19/// `λ(1 − λ)`, and so on, so the estimate reacts to a volatility shock
20/// immediately and then forgets it at rate `λ`. This is the J.P. Morgan
21/// `RiskMetrics` one-parameter model; the standard daily decay is `λ = 0.94`
22/// (monthly `0.97`). No mean is subtracted: squared returns *are* the variance
23/// contribution, which matches the `RiskMetrics` assumption of a zero conditional
24/// mean over short horizons.
25///
26/// The recursion is seeded with the first squared return (`σ²₁ = r²₁`) and emits
27/// from the first return onward, so the very first reading is a one-observation
28/// estimate that the decay then refines. Each `update` is O(1).
29///
30/// Non-finite and non-positive prices are ignored (the log return would be
31/// undefined): the tick is dropped, state is left untouched, and the last value
32/// is returned.
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{EwmaVolatility, Indicator};
38///
39/// let mut indicator = EwmaVolatility::new(0.94).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 EwmaVolatility {
48    lambda: f64,
49    prev_price: Option<f64>,
50    /// Exponentially-weighted variance of log returns; `None` until seeded.
51    variance: Option<f64>,
52    last: Option<f64>,
53}
54
55impl EwmaVolatility {
56    /// Construct a new EWMA-volatility indicator.
57    ///
58    /// `lambda` is the decay factor, strictly between `0` and `1` (`RiskMetrics`
59    /// uses `0.94` for daily data). Larger `lambda` means a longer memory and a
60    /// smoother estimate.
61    ///
62    /// # Errors
63    /// Returns [`Error::InvalidParameter`] if `lambda` is not finite or not in
64    /// the open interval `(0, 1)`.
65    pub fn new(lambda: f64) -> Result<Self> {
66        if !lambda.is_finite() || lambda <= 0.0 || lambda >= 1.0 {
67            return Err(Error::InvalidParameter {
68                message: "EWMA volatility lambda must be in the open interval (0, 1)",
69            });
70        }
71        Ok(Self {
72            lambda,
73            prev_price: None,
74            variance: None,
75            last: None,
76        })
77    }
78
79    /// Configured decay factor.
80    pub const fn lambda(&self) -> f64 {
81        self.lambda
82    }
83
84    /// Current value if available.
85    pub const fn value(&self) -> Option<f64> {
86        self.last
87    }
88}
89
90impl Indicator for EwmaVolatility {
91    type Input = f64;
92    type Output = f64;
93
94    #[inline]
95    fn update(&mut self, input: f64) -> Option<f64> {
96        // Non-finite / non-positive prices are skipped: `ln(input / prev)` is
97        // undefined, so the tick must not enter the variance recursion.
98        if !input.is_finite() || input <= 0.0 {
99            return None;
100        }
101        let Some(prev) = self.prev_price else {
102            self.prev_price = Some(input);
103            return None;
104        };
105        self.prev_price = Some(input);
106        // `prev` came from `self.prev_price`, gated by the guard above, so it is
107        // finite and positive — the log return is always well-defined.
108        let r = (input / prev).ln();
109        let var = match self.variance {
110            // Seed the recursion with the first squared return.
111            None => r * r,
112            Some(prev_var) => self.lambda * prev_var + (1.0 - self.lambda) * r * r,
113        };
114        self.variance = Some(var);
115        // `var` is a convex combination of non-negative terms, but rounding can
116        // leave a tiny negative residual when every return is ~0; clamp first.
117        let vol = var.max(0.0).sqrt();
118        self.last = Some(vol);
119        Some(vol)
120    }
121
122    fn reset(&mut self) {
123        self.prev_price = None;
124        self.variance = None;
125        self.last = None;
126    }
127
128    #[inline]
129    fn warmup_period(&self) -> usize {
130        // The first log return needs a previous price; the estimate is seeded
131        // and emitted on that first return.
132        2
133    }
134
135    #[inline]
136    fn is_ready(&self) -> bool {
137        self.last.is_some()
138    }
139
140    #[inline]
141    fn name(&self) -> &'static str {
142        "EwmaVolatility"
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::traits::BatchExt;
150    use approx::assert_relative_eq;
151
152    #[test]
153    fn rejects_invalid_lambda() {
154        for bad in [0.0, 1.0, -0.5, 1.5, f64::NAN, f64::INFINITY] {
155            assert!(matches!(
156                EwmaVolatility::new(bad),
157                Err(Error::InvalidParameter { .. })
158            ));
159        }
160    }
161
162    #[test]
163    fn accessors_and_metadata() {
164        let ewma = EwmaVolatility::new(0.94).unwrap();
165        assert_relative_eq!(ewma.lambda(), 0.94);
166        assert_eq!(ewma.warmup_period(), 2);
167        assert_eq!(ewma.name(), "EwmaVolatility");
168        assert!(!ewma.is_ready());
169        assert_eq!(ewma.value(), None);
170    }
171
172    #[test]
173    fn first_emission_at_warmup_period() {
174        let mut ewma = EwmaVolatility::new(0.94).unwrap();
175        assert_eq!(ewma.update(100.0), None);
176        let out = ewma.update(110.0);
177        assert!(out.is_some());
178        assert!(ewma.is_ready());
179    }
180
181    #[test]
182    fn known_value() {
183        // r1 = ln(110/100), r2 = ln(99/110). Seed σ²₁ = r1²; then
184        // σ²₂ = λ·r1² + (1−λ)·r2².
185        let lambda = 0.94;
186        let mut ewma = EwmaVolatility::new(lambda).unwrap();
187        let out = ewma.batch(&[100.0, 110.0, 99.0]);
188        let r1 = (110.0_f64 / 100.0).ln();
189        let r2 = (99.0_f64 / 110.0).ln();
190        assert_relative_eq!(out[1].unwrap(), r1.abs(), epsilon = 1e-12);
191        let var2 = lambda * r1 * r1 + (1.0 - lambda) * r2 * r2;
192        assert_relative_eq!(out[2].unwrap(), var2.sqrt(), epsilon = 1e-12);
193    }
194
195    #[test]
196    fn constant_series_yields_zero() {
197        let mut ewma = EwmaVolatility::new(0.9).unwrap();
198        for v in ewma.batch(&[100.0; 40]).into_iter().flatten() {
199            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
200        }
201    }
202
203    #[test]
204    fn output_is_non_negative() {
205        let mut ewma = EwmaVolatility::new(0.94).unwrap();
206        let prices: Vec<f64> = (1..=200)
207            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
208            .collect();
209        for v in ewma.batch(&prices).into_iter().flatten() {
210            assert!(v >= 0.0, "EWMA volatility must be non-negative, got {v}");
211        }
212    }
213
214    #[test]
215    fn ignores_non_finite_input() {
216        let mut ewma = EwmaVolatility::new(0.94).unwrap();
217        let out = ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
218        let last = *out.last().unwrap();
219        assert!(last.is_some());
220        assert_eq!(ewma.update(f64::NAN), None);
221        assert_eq!(ewma.update(f64::INFINITY), None);
222    }
223
224    #[test]
225    fn skips_non_positive_prices() {
226        let mut ewma = EwmaVolatility::new(0.94).unwrap();
227        let warmup = ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
228        warmup.last().copied().flatten().expect("warmed up");
229        assert_eq!(ewma.update(-5.0), None);
230        assert_eq!(ewma.update(0.0), None);
231        // State untouched: a clone advanced by the same real tick agrees.
232        let mut control = ewma.clone();
233        let after = ewma.update(21.0).expect("ready");
234        assert_eq!(control.update(21.0).expect("ready"), after);
235    }
236
237    #[test]
238    fn skips_non_positive_before_first_price() {
239        // The skip guard fires before any previous price exists.
240        let mut ewma = EwmaVolatility::new(0.94).unwrap();
241        assert_eq!(ewma.update(0.0), None);
242        assert_eq!(ewma.update(f64::NAN), None);
243        assert_eq!(ewma.update(100.0), None);
244        assert!(ewma.update(110.0).is_some());
245    }
246
247    #[test]
248    fn reset_clears_state() {
249        let mut ewma = EwmaVolatility::new(0.94).unwrap();
250        ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
251        assert!(ewma.is_ready());
252        ewma.reset();
253        assert!(!ewma.is_ready());
254        assert_eq!(ewma.value(), None);
255        assert_eq!(ewma.update(1.0), None);
256    }
257
258    #[test]
259    fn batch_equals_streaming() {
260        let prices: Vec<f64> = (1..=120)
261            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
262            .collect();
263        let batch = EwmaVolatility::new(0.94).unwrap().batch(&prices);
264        let mut b = EwmaVolatility::new(0.94).unwrap();
265        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
266        assert_eq!(batch, streamed);
267    }
268}