Skip to main content

wickra_core/indicators/
ema.rs

1//! Exponential Moving Average.
2
3use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6/// Exponential Moving Average with smoothing factor `alpha = 2 / (period + 1)`.
7///
8/// The first value is seeded with the simple mean of the first `period` inputs
9/// (the classical TA-Lib convention). From then on each new input contributes
10/// `alpha * input + (1 - alpha) * previous`.
11///
12/// # Example
13///
14/// ```
15/// use wickra_core::{Indicator, Ema};
16///
17/// let mut indicator = Ema::new(3).unwrap();
18/// let mut last = None;
19/// for i in 0..80 {
20///     last = indicator.update(100.0 + f64::from(i));
21/// }
22/// assert!(last.is_some());
23/// ```
24#[derive(Debug, Clone)]
25pub struct Ema {
26    period: usize,
27    alpha: f64,
28    /// `1 - alpha`, precomputed so the recurrence avoids a subtraction per tick.
29    /// Cached value, so the steady-state output is bit-for-bit unchanged.
30    one_minus_alpha: f64,
31    /// Latest EMA value, valid only once `seeded` is true. Stored as a bare `f64`
32    /// (plus the `seeded` flag) rather than `Option<f64>` so the steady-state
33    /// recurrence reads and writes 8 bytes with no enum-tag handling per tick.
34    current: f64,
35    /// Whether `current` holds a real value yet (warmup complete).
36    seeded: bool,
37    warmup_buf: Vec<f64>,
38}
39
40impl Ema {
41    /// Construct an EMA with the given period.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`Error::PeriodZero`] if `period == 0`.
46    pub fn new(period: usize) -> Result<Self> {
47        if period == 0 {
48            return Err(Error::PeriodZero);
49        }
50        if period > crate::error::MAX_PERIOD {
51            return Err(Error::InvalidPeriod {
52                message: crate::error::PERIOD_ABOVE_MAX,
53            });
54        }
55        let alpha = 2.0 / (period as f64 + 1.0);
56        Ok(Self {
57            period,
58            alpha,
59            one_minus_alpha: 1.0 - alpha,
60            current: 0.0,
61            seeded: false,
62            warmup_buf: Vec::with_capacity(period),
63        })
64    }
65
66    /// Construct an EMA with a custom smoothing factor `alpha in (0, 1]`.
67    ///
68    /// The reported `period` is derived from `alpha` via `2/alpha - 1` and rounded;
69    /// `warmup_period()` falls back to `1` because the implementation seeds from the
70    /// very first input.
71    ///
72    /// # Errors
73    ///
74    /// Returns [`Error::InvalidPeriod`] if `alpha` is not in `(0.0, 1.0]` or non-finite.
75    pub fn with_alpha(alpha: f64) -> Result<Self> {
76        if !alpha.is_finite() || alpha <= 0.0 || alpha > 1.0 {
77            return Err(Error::InvalidPeriod {
78                message: "alpha must be in (0.0, 1.0]",
79            });
80        }
81        Ok(Self {
82            period: 1,
83            alpha,
84            one_minus_alpha: 1.0 - alpha,
85            current: 0.0,
86            seeded: false,
87            warmup_buf: Vec::with_capacity(1),
88        })
89    }
90
91    /// Configured period.
92    pub const fn period(&self) -> usize {
93        self.period
94    }
95
96    /// Smoothing factor.
97    pub const fn alpha(&self) -> f64 {
98        self.alpha
99    }
100
101    /// Current value if available.
102    pub const fn value(&self) -> Option<f64> {
103        if self.seeded {
104            Some(self.current)
105        } else {
106            None
107        }
108    }
109
110    /// Whether the EMA has seen no input yet (neither seeded nor mid-warmup).
111    /// Lets composite indicators (e.g. MACD) decide if a fast batch path is safe.
112    pub(crate) fn is_fresh(&self) -> bool {
113        !self.seeded && self.warmup_buf.is_empty()
114    }
115
116    /// Force the EMA into its seeded steady state with `current` as the latest
117    /// value. Used by composite fused batch paths (MACD) to leave each sub-EMA
118    /// where a per-tick `update` replay would, so a later `update` continues
119    /// correctly. The post-seed recurrence never re-reads `warmup_buf`, so it is
120    /// left as-is.
121    pub(crate) fn seed_to(&mut self, current: f64) {
122        self.current = current;
123        self.seeded = true;
124    }
125
126    /// Vectorized batch returning one `f64` per input (`NaN` during warmup).
127    ///
128    /// Shadows the generic [`BatchNanExt::batch_nan`](crate::BatchNanExt) blanket
129    /// default via inherent-method resolution. For a fresh indicator over an
130    /// all-finite slice it runs the seed (mean of the first `period`) once and
131    /// then the bare `alpha * x + (1 - alpha) * prev` recurrence in a tight loop
132    /// with no per-element `is_finite`/`seeded` branch and no `Option` — yet uses
133    /// the identical `mul_add`, so the result is *bit-for-bit* equal to replaying
134    /// `update`. Any other state, or a non-finite element, defers to the exact
135    /// `update` replay.
136    pub fn batch_nan(&mut self, inputs: &[f64]) -> Vec<f64> {
137        let p = self.period;
138        if self.seeded || !self.warmup_buf.is_empty() || !inputs.iter().all(|x| x.is_finite()) {
139            return inputs
140                .iter()
141                .map(|&x| self.update(x).unwrap_or(f64::NAN))
142                .collect();
143        }
144
145        let n = inputs.len();
146        if n < p {
147            // Not enough to seed; mirror `update` stashing inputs for warmup.
148            self.warmup_buf.extend_from_slice(inputs);
149            return vec![f64::NAN; n];
150        }
151
152        // Warmup `[0, p-1)` is `NaN`; values from the seed on are pushed once each.
153        let mut out = vec![f64::NAN; p - 1];
154        out.reserve(n - (p - 1));
155        let seed = inputs[..p].iter().copied().sum::<f64>() / p as f64;
156        let mut cur = seed;
157        out.push(seed);
158        let (alpha, oma) = (self.alpha, self.one_minus_alpha);
159        for &x in &inputs[p..] {
160            cur = alpha.mul_add(x, oma * cur);
161            out.push(cur);
162        }
163
164        // Leave state exactly where `update` would: seeded on `current`, with the
165        // first `period` inputs retained in `warmup_buf` (never cleared post-seed).
166        self.current = cur;
167        self.seeded = true;
168        self.warmup_buf.extend_from_slice(&inputs[..p]);
169        out
170    }
171
172    /// Internal helper that feeds a value without finiteness validation. The caller
173    /// guarantees `input.is_finite()`. Used by MACD which has already validated.
174    pub(crate) fn step_unchecked(&mut self, input: f64) -> Option<f64> {
175        if self.seeded {
176            let new = self
177                .alpha
178                .mul_add(input, self.one_minus_alpha * self.current);
179            self.current = new;
180            return Some(new);
181        }
182        self.warmup_buf.push(input);
183        if self.warmup_buf.len() == self.period {
184            let seed = self.warmup_buf.iter().copied().sum::<f64>() / self.period as f64;
185            self.current = seed;
186            self.seeded = true;
187            return Some(seed);
188        }
189        None
190    }
191}
192
193impl Indicator for Ema {
194    type Input = f64;
195    type Output = f64;
196
197    #[inline]
198    fn update(&mut self, input: f64) -> Option<f64> {
199        if !input.is_finite() {
200            return None;
201        }
202        self.step_unchecked(input)
203    }
204
205    fn reset(&mut self) {
206        self.current = 0.0;
207        self.seeded = false;
208        self.warmup_buf.clear();
209    }
210
211    #[inline]
212    fn warmup_period(&self) -> usize {
213        self.period
214    }
215
216    #[inline]
217    fn is_ready(&self) -> bool {
218        self.seeded
219    }
220
221    #[inline]
222    fn name(&self) -> &'static str {
223        "EMA"
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    /// Constructors size their buffers from the period, so an absurd value used
232    /// to abort the process before the caller saw anything: `usize::MAX` hit a
233    /// capacity overflow inside `Vec`, and a billion quietly reserved eight
234    /// gigabytes. Both are now rejected as an ordinary error.
235    #[test]
236    fn rejects_a_period_above_the_maximum() {
237        assert!(matches!(
238            Ema::new(usize::MAX),
239            Err(Error::InvalidPeriod { .. })
240        ));
241        assert!(matches!(
242            Ema::new(crate::error::MAX_PERIOD + 1),
243            Err(Error::InvalidPeriod { .. })
244        ));
245        assert!(matches!(
246            Ema::new(1_000_000_000),
247            Err(Error::InvalidPeriod { .. })
248        ));
249        // A sane period is untouched.
250        assert!(Ema::new(14).is_ok());
251    }
252    use crate::traits::BatchExt;
253    use approx::assert_relative_eq;
254
255    /// Independent reference: SMA-seeded EMA computed straight from the definition.
256    fn ema_naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
257        let alpha = 2.0 / (period as f64 + 1.0);
258        let mut out = Vec::with_capacity(prices.len());
259        let mut state: Option<f64> = None;
260        for (i, &p) in prices.iter().enumerate() {
261            if let Some(prev) = state {
262                let v = alpha * p + (1.0 - alpha) * prev;
263                state = Some(v);
264                out.push(Some(v));
265            } else if i + 1 == period {
266                let seed = prices[..period].iter().sum::<f64>() / period as f64;
267                state = Some(seed);
268                out.push(Some(seed));
269            } else {
270                out.push(None);
271            }
272        }
273        out
274    }
275
276    #[test]
277    fn new_rejects_zero_period() {
278        assert!(matches!(Ema::new(0), Err(Error::PeriodZero)));
279    }
280
281    /// Cover the const accessor `period` (74-77) and the Indicator-impl
282    /// `warmup_period` (123-125) + `name` (131-133). `alpha` and `value`
283    /// are exercised by other tests and downstream consumers; only the
284    /// three metadata methods were dead.
285    #[test]
286    fn accessors_and_metadata() {
287        let ema = Ema::new(14).unwrap();
288        assert_eq!(ema.period(), 14);
289        assert_eq!(ema.warmup_period(), 14);
290        assert_eq!(ema.name(), "EMA");
291    }
292
293    #[test]
294    fn warmup_returns_none_until_seed() {
295        let mut ema = Ema::new(3).unwrap();
296        assert_eq!(ema.update(1.0), None);
297        assert_eq!(ema.update(2.0), None);
298        assert_eq!(ema.update(3.0), Some(2.0)); // seed = SMA([1,2,3]) = 2
299    }
300
301    #[test]
302    fn first_value_equals_sma_seed() {
303        let mut ema = Ema::new(5).unwrap();
304        let inputs = [10.0, 20.0, 30.0, 40.0, 50.0];
305        let mut last = None;
306        for v in inputs {
307            last = ema.update(v);
308        }
309        assert_relative_eq!(last.unwrap(), 30.0, epsilon = 1e-12);
310    }
311
312    #[test]
313    fn alpha_matches_period_formula() {
314        let ema = Ema::new(10).unwrap();
315        assert_relative_eq!(ema.alpha(), 2.0 / 11.0, epsilon = 1e-15);
316    }
317
318    #[test]
319    fn step_after_seed_uses_alpha_formula() {
320        // period=3 => alpha = 0.5; seed = mean([1,2,3]) = 2; next input 10
321        // expected = 0.5*10 + 0.5*2 = 6
322        let mut ema = Ema::new(3).unwrap();
323        ema.batch(&[1.0, 2.0, 3.0]);
324        assert_relative_eq!(ema.update(10.0).unwrap(), 6.0, epsilon = 1e-12);
325    }
326
327    #[test]
328    fn constant_series_converges_to_constant() {
329        let mut ema = Ema::new(10).unwrap();
330        let out = ema.batch(&[42.0_f64; 100]);
331        for x in out.iter().skip(9) {
332            assert_relative_eq!(x.unwrap(), 42.0, epsilon = 1e-9);
333        }
334    }
335
336    #[test]
337    fn with_alpha_validates_range() {
338        assert!(Ema::with_alpha(0.5).is_ok());
339        assert!(Ema::with_alpha(1.0).is_ok());
340        assert!(matches!(
341            Ema::with_alpha(0.0),
342            Err(Error::InvalidPeriod { .. })
343        ));
344        assert!(matches!(
345            Ema::with_alpha(1.5),
346            Err(Error::InvalidPeriod { .. })
347        ));
348        assert!(matches!(
349            Ema::with_alpha(f64::NAN),
350            Err(Error::InvalidPeriod { .. })
351        ));
352    }
353
354    #[test]
355    fn reset_clears_state() {
356        let mut ema = Ema::new(3).unwrap();
357        ema.batch(&[1.0, 2.0, 3.0]);
358        assert!(ema.is_ready());
359        ema.reset();
360        assert!(!ema.is_ready());
361        assert_eq!(ema.update(1.0), None);
362    }
363
364    #[test]
365    fn batch_equals_streaming() {
366        let prices: Vec<f64> = (1..=30).map(f64::from).collect();
367        let mut a = Ema::new(5).unwrap();
368        let mut b = Ema::new(5).unwrap();
369        assert_eq!(
370            a.batch(&prices),
371            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
372        );
373    }
374
375    #[test]
376    fn ignores_non_finite_input() {
377        let mut ema = Ema::new(3).unwrap();
378        ema.batch(&[1.0, 2.0, 3.0]);
379        let before = ema.value();
380        assert_eq!(ema.update(f64::NAN), None);
381        assert_eq!(ema.update(f64::INFINITY), None);
382        // The rejected input must not have disturbed the state.
383        assert_eq!(ema.value(), before);
384    }
385
386    fn bits_eq(a: &[f64], b: &[f64]) -> bool {
387        a.len() == b.len()
388            && a.iter()
389                .zip(b)
390                .all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
391    }
392
393    fn ema_replay(period: usize, series: &[f64]) -> Vec<f64> {
394        let mut e = Ema::new(period).unwrap();
395        series
396            .iter()
397            .map(|&x| e.update(x).unwrap_or(f64::NAN))
398            .collect()
399    }
400
401    #[test]
402    fn batch_nan_fast_path_is_bit_identical() {
403        let series: Vec<f64> = (0..300)
404            .map(|i| (f64::from(i) * 0.25).cos() * 8.0 + 40.0)
405            .collect();
406        let mut ema = Ema::new(14).unwrap();
407        let got = ema.batch_nan(&series);
408        assert!(bits_eq(&got, &ema_replay(14, &series)));
409        let mut ref_ema = Ema::new(14).unwrap();
410        for &x in &series {
411            ref_ema.update(x);
412        }
413        assert_eq!(ema.update(7.5), ref_ema.update(7.5));
414    }
415
416    #[test]
417    fn batch_nan_falls_back_on_non_finite() {
418        let series = [1.0, 2.0, 3.0, f64::INFINITY, 5.0, 6.0, 7.0];
419        let mut ema = Ema::new(3).unwrap();
420        assert!(bits_eq(&ema.batch_nan(&series), &ema_replay(3, &series)));
421    }
422
423    #[test]
424    fn batch_nan_falls_back_when_warming() {
425        let mut ema = Ema::new(3).unwrap();
426        ema.update(10.0); // mid-warmup: warmup_buf non-empty, not seeded
427        let series = [1.0, 2.0, 3.0, 4.0];
428        let mut ref_ema = Ema::new(3).unwrap();
429        ref_ema.update(10.0);
430        let want: Vec<f64> = series
431            .iter()
432            .map(|&x| ref_ema.update(x).unwrap_or(f64::NAN))
433            .collect();
434        assert!(bits_eq(&ema.batch_nan(&series), &want));
435    }
436
437    #[test]
438    fn batch_nan_sub_period_slice_stays_unseeded() {
439        let series = [1.0, 2.0];
440        let mut ema = Ema::new(5).unwrap();
441        let got = ema.batch_nan(&series);
442        assert!(got.iter().all(|x| x.is_nan()) && got.len() == 2);
443        assert!(!ema.is_ready());
444        // Warmup state was stashed: feeding the rest seeds exactly as a full stream.
445        assert!(bits_eq(
446            &[ema.update(3.0).unwrap_or(f64::NAN)],
447            &[ema_replay(5, &[1.0, 2.0, 3.0])[2]]
448        ));
449    }
450
451    proptest::proptest! {
452        #![proptest_config(proptest::test_runner::Config::with_cases(48))]
453        #[test]
454        fn ema_matches_naive(
455            period in 1usize..20,
456            prices in proptest::collection::vec(-1000.0_f64..1000.0, 0..150),
457        ) {
458            let mut ema = Ema::new(period).unwrap();
459            let got = ema.batch(&prices);
460            let want = ema_naive(&prices, period);
461            proptest::prop_assert_eq!(got.len(), want.len());
462            for (g, w) in got.iter().zip(want.iter()) {
463                match (g, w) {
464                    (None, None) => {}
465                    (Some(a), Some(b)) => proptest::prop_assert!(
466                        (a - b).abs() <= 1e-9 * a.abs().max(1.0),
467                        "got={a} want={b}"
468                    ),
469                    _ => proptest::prop_assert!(false, "warmup mismatch"),
470                }
471            }
472        }
473    }
474}