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    fn update(&mut self, input: f64) -> Option<f64> {
95        // Non-finite / non-positive prices are skipped: `ln(input / prev)` is
96        // undefined, so the tick must not enter the variance recursion.
97        if !input.is_finite() || input <= 0.0 {
98            return self.last;
99        }
100        let Some(prev) = self.prev_price else {
101            self.prev_price = Some(input);
102            return None;
103        };
104        self.prev_price = Some(input);
105        // `prev` came from `self.prev_price`, gated by the guard above, so it is
106        // finite and positive — the log return is always well-defined.
107        let r = (input / prev).ln();
108        let var = match self.variance {
109            // Seed the recursion with the first squared return.
110            None => r * r,
111            Some(prev_var) => self.lambda * prev_var + (1.0 - self.lambda) * r * r,
112        };
113        self.variance = Some(var);
114        // `var` is a convex combination of non-negative terms, but rounding can
115        // leave a tiny negative residual when every return is ~0; clamp first.
116        let vol = var.max(0.0).sqrt();
117        self.last = Some(vol);
118        Some(vol)
119    }
120
121    fn reset(&mut self) {
122        self.prev_price = None;
123        self.variance = None;
124        self.last = None;
125    }
126
127    fn warmup_period(&self) -> usize {
128        // The first log return needs a previous price; the estimate is seeded
129        // and emitted on that first return.
130        2
131    }
132
133    fn is_ready(&self) -> bool {
134        self.last.is_some()
135    }
136
137    fn name(&self) -> &'static str {
138        "EwmaVolatility"
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::traits::BatchExt;
146    use approx::assert_relative_eq;
147
148    #[test]
149    fn rejects_invalid_lambda() {
150        for bad in [0.0, 1.0, -0.5, 1.5, f64::NAN, f64::INFINITY] {
151            assert!(matches!(
152                EwmaVolatility::new(bad),
153                Err(Error::InvalidParameter { .. })
154            ));
155        }
156    }
157
158    #[test]
159    fn accessors_and_metadata() {
160        let ewma = EwmaVolatility::new(0.94).unwrap();
161        assert_relative_eq!(ewma.lambda(), 0.94);
162        assert_eq!(ewma.warmup_period(), 2);
163        assert_eq!(ewma.name(), "EwmaVolatility");
164        assert!(!ewma.is_ready());
165        assert_eq!(ewma.value(), None);
166    }
167
168    #[test]
169    fn first_emission_at_warmup_period() {
170        let mut ewma = EwmaVolatility::new(0.94).unwrap();
171        assert_eq!(ewma.update(100.0), None);
172        let out = ewma.update(110.0);
173        assert!(out.is_some());
174        assert!(ewma.is_ready());
175    }
176
177    #[test]
178    fn known_value() {
179        // r1 = ln(110/100), r2 = ln(99/110). Seed σ²₁ = r1²; then
180        // σ²₂ = λ·r1² + (1−λ)·r2².
181        let lambda = 0.94;
182        let mut ewma = EwmaVolatility::new(lambda).unwrap();
183        let out = ewma.batch(&[100.0, 110.0, 99.0]);
184        let r1 = (110.0_f64 / 100.0).ln();
185        let r2 = (99.0_f64 / 110.0).ln();
186        assert_relative_eq!(out[1].unwrap(), r1.abs(), epsilon = 1e-12);
187        let var2 = lambda * r1 * r1 + (1.0 - lambda) * r2 * r2;
188        assert_relative_eq!(out[2].unwrap(), var2.sqrt(), epsilon = 1e-12);
189    }
190
191    #[test]
192    fn constant_series_yields_zero() {
193        let mut ewma = EwmaVolatility::new(0.9).unwrap();
194        for v in ewma.batch(&[100.0; 40]).into_iter().flatten() {
195            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
196        }
197    }
198
199    #[test]
200    fn output_is_non_negative() {
201        let mut ewma = EwmaVolatility::new(0.94).unwrap();
202        let prices: Vec<f64> = (1..=200)
203            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
204            .collect();
205        for v in ewma.batch(&prices).into_iter().flatten() {
206            assert!(v >= 0.0, "EWMA volatility must be non-negative, got {v}");
207        }
208    }
209
210    #[test]
211    fn ignores_non_finite_input() {
212        let mut ewma = EwmaVolatility::new(0.94).unwrap();
213        let out = ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
214        let last = *out.last().unwrap();
215        assert!(last.is_some());
216        assert_eq!(ewma.update(f64::NAN), last);
217        assert_eq!(ewma.update(f64::INFINITY), last);
218    }
219
220    #[test]
221    fn skips_non_positive_prices() {
222        let mut ewma = EwmaVolatility::new(0.94).unwrap();
223        let warmup = ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
224        let baseline = warmup.last().copied().flatten().expect("warmed up");
225        assert_eq!(ewma.update(-5.0), Some(baseline));
226        assert_eq!(ewma.update(0.0), Some(baseline));
227        // State untouched: a clone advanced by the same real tick agrees.
228        let mut control = ewma.clone();
229        let after = ewma.update(21.0).expect("ready");
230        assert_eq!(control.update(21.0).expect("ready"), after);
231    }
232
233    #[test]
234    fn skips_non_positive_before_first_price() {
235        // The skip guard fires before any previous price exists.
236        let mut ewma = EwmaVolatility::new(0.94).unwrap();
237        assert_eq!(ewma.update(0.0), None);
238        assert_eq!(ewma.update(f64::NAN), None);
239        assert_eq!(ewma.update(100.0), None);
240        assert!(ewma.update(110.0).is_some());
241    }
242
243    #[test]
244    fn reset_clears_state() {
245        let mut ewma = EwmaVolatility::new(0.94).unwrap();
246        ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
247        assert!(ewma.is_ready());
248        ewma.reset();
249        assert!(!ewma.is_ready());
250        assert_eq!(ewma.value(), None);
251        assert_eq!(ewma.update(1.0), None);
252    }
253
254    #[test]
255    fn batch_equals_streaming() {
256        let prices: Vec<f64> = (1..=120)
257            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
258            .collect();
259        let batch = EwmaVolatility::new(0.94).unwrap().batch(&prices);
260        let mut b = EwmaVolatility::new(0.94).unwrap();
261        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
262        assert_eq!(batch, streamed);
263    }
264}