Skip to main content

wickra_core/indicators/
empirical_mode_decomposition.rs

1//! Ehlers Empirical Mode Decomposition (bandpass + envelope).
2
3use std::collections::VecDeque;
4use std::f64::consts::PI;
5
6use crate::error::{Error, Result};
7use crate::indicators::super_smoother::SuperSmoother;
8use crate::traits::Indicator;
9
10/// Ehlers' adaptation of Empirical Mode Decomposition (EMD).
11///
12/// Implementation per *Cycle Analytics for Traders* (Ehlers 2013, ch. 14).
13/// The procedure is:
14///
15/// 1. Apply a bandpass filter centred on `period` to the price.
16/// 2. Detect peaks and valleys of the bandpassed signal over a `fraction`
17///    of the period.
18/// 3. Average the peaks and valleys separately to form an upper / lower
19///    envelope, then return the centred bandpass minus the envelope mean
20///    (the "EMD" line).
21///
22/// The output crosses zero at trend changes and stays near zero in
23/// non-trending markets — the classic visual cue Ehlers documents.
24///
25/// # Example
26///
27/// ```
28/// use wickra_core::{Indicator, EmpiricalModeDecomposition};
29///
30/// let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
31/// let mut last = None;
32/// for i in 0..200 {
33///     last = emd.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
34/// }
35/// assert!(last.is_some());
36/// ```
37#[derive(Debug, Clone)]
38pub struct EmpiricalModeDecomposition {
39    period: usize,
40    fraction: f64,
41    bandpass: f64,
42    prev_bp_1: f64,
43    prev_bp_2: f64,
44    prev_in_1: Option<f64>,
45    prev_in_2: Option<f64>,
46    beta: f64,
47    alpha: f64,
48    smoother: SuperSmoother,
49    peak_smoother: SuperSmoother,
50    valley_smoother: SuperSmoother,
51    bp_buf: VecDeque<f64>,
52    bp_history_len: usize,
53    last_value: Option<f64>,
54}
55
56impl EmpiricalModeDecomposition {
57    /// Construct with the bandpass centre period and the peak-detection
58    /// window fraction.
59    ///
60    /// `fraction` is multiplied by `period` to size the rolling peak/valley
61    /// window; Ehlers recommends `0.5`. Both must be positive.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`Error::PeriodZero`] if `period == 0`, and
66    /// [`Error::InvalidPeriod`] if `fraction <= 0` or non-finite.
67    pub fn new(period: usize, fraction: f64) -> Result<Self> {
68        if period == 0 {
69            return Err(Error::PeriodZero);
70        }
71        if !fraction.is_finite() || fraction <= 0.0 || fraction > 1.0 {
72            return Err(Error::InvalidPeriod {
73                message: "fraction must be in (0, 1]",
74            });
75        }
76        let beta = (2.0 * PI / period as f64).cos();
77        let gamma = 1.0 / (2.0 * PI * 0.25 / period as f64).cos();
78        let alpha = gamma - (gamma * gamma - 1.0).sqrt();
79        let history = (period as f64 * fraction).round().max(1.0) as usize;
80        Ok(Self {
81            period,
82            fraction,
83            bandpass: 0.0,
84            prev_bp_1: 0.0,
85            prev_bp_2: 0.0,
86            prev_in_1: None,
87            prev_in_2: None,
88            beta,
89            alpha,
90            smoother: SuperSmoother::new(period.max(2))?,
91            peak_smoother: SuperSmoother::new(period.max(2))?,
92            valley_smoother: SuperSmoother::new(period.max(2))?,
93            bp_buf: VecDeque::with_capacity(history),
94            bp_history_len: history,
95            last_value: None,
96        })
97    }
98
99    /// Configured period.
100    pub const fn period(&self) -> usize {
101        self.period
102    }
103
104    /// Configured fraction.
105    pub const fn fraction(&self) -> f64 {
106        self.fraction
107    }
108
109    /// Current value if available.
110    pub const fn value(&self) -> Option<f64> {
111        self.last_value
112    }
113}
114
115impl Indicator for EmpiricalModeDecomposition {
116    type Input = f64;
117    type Output = f64;
118
119    fn update(&mut self, input: f64) -> Option<f64> {
120        if !input.is_finite() {
121            return self.last_value;
122        }
123        // 2nd-order resonant bandpass per Ehlers ch. 6.
124        let bp = if let (Some(_x1), Some(x2)) = (self.prev_in_1, self.prev_in_2) {
125            0.5 * (1.0 - self.alpha) * (input - x2)
126                + self.beta * (1.0 + self.alpha) * self.prev_bp_1
127                - self.alpha * self.prev_bp_2
128        } else {
129            0.0
130        };
131        self.prev_bp_2 = self.prev_bp_1;
132        self.prev_bp_1 = bp;
133        self.bandpass = bp;
134        self.prev_in_2 = self.prev_in_1;
135        self.prev_in_1 = Some(input);
136
137        if self.bp_buf.len() == self.bp_history_len {
138            self.bp_buf.pop_front();
139        }
140        self.bp_buf.push_back(bp);
141        if self.bp_buf.len() < self.bp_history_len {
142            return None;
143        }
144
145        // Identify the current peak (largest), valley (smallest) within the window.
146        let peak = self
147            .bp_buf
148            .iter()
149            .copied()
150            .fold(f64::NEG_INFINITY, f64::max);
151        let valley = self.bp_buf.iter().copied().fold(f64::INFINITY, f64::min);
152
153        let avg_peak = self.peak_smoother.update(peak)?;
154        let avg_valley = self.valley_smoother.update(valley)?;
155
156        // The EMD line is the bandpass minus the smoothed mean envelope.
157        let mean = 0.5 * (avg_peak + avg_valley);
158        let raw = bp - mean;
159        let v = self.smoother.update(raw)?;
160        self.last_value = Some(v);
161        Some(v)
162    }
163
164    fn reset(&mut self) {
165        self.bandpass = 0.0;
166        self.prev_bp_1 = 0.0;
167        self.prev_bp_2 = 0.0;
168        self.prev_in_1 = None;
169        self.prev_in_2 = None;
170        self.smoother.reset();
171        self.peak_smoother.reset();
172        self.valley_smoother.reset();
173        self.bp_buf.clear();
174        self.last_value = None;
175    }
176
177    fn warmup_period(&self) -> usize {
178        self.bp_history_len
179    }
180
181    fn is_ready(&self) -> bool {
182        self.last_value.is_some()
183    }
184
185    fn name(&self) -> &'static str {
186        "EmpiricalModeDecomposition"
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::traits::BatchExt;
194
195    #[test]
196    fn new_rejects_invalid_params() {
197        assert!(matches!(
198            EmpiricalModeDecomposition::new(0, 0.5),
199            Err(Error::PeriodZero)
200        ));
201        assert!(matches!(
202            EmpiricalModeDecomposition::new(20, 0.0),
203            Err(Error::InvalidPeriod { .. })
204        ));
205        assert!(matches!(
206            EmpiricalModeDecomposition::new(20, 1.5),
207            Err(Error::InvalidPeriod { .. })
208        ));
209        assert!(matches!(
210            EmpiricalModeDecomposition::new(20, f64::NAN),
211            Err(Error::InvalidPeriod { .. })
212        ));
213    }
214
215    #[test]
216    fn accessors_and_metadata() {
217        let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
218        assert_eq!(emd.period(), 20);
219        assert!((emd.fraction() - 0.5).abs() < 1e-15);
220        assert_eq!(emd.name(), "EmpiricalModeDecomposition");
221        assert!(emd.warmup_period() >= 1);
222        assert!(!emd.is_ready());
223        let prices: Vec<f64> = (0..200)
224            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
225            .collect();
226        emd.batch(&prices);
227        assert!(emd.is_ready());
228        assert!(emd.value().is_some());
229    }
230
231    #[test]
232    fn batch_equals_streaming() {
233        let prices: Vec<f64> = (0..200)
234            .map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 5.0)
235            .collect();
236        let mut a = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
237        let mut b = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
238        let batch = a.batch(&prices);
239        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
240        assert_eq!(batch, streamed);
241    }
242
243    #[test]
244    fn ignores_non_finite_input() {
245        let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
246        let prices: Vec<f64> = (0..200)
247            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
248            .collect();
249        emd.batch(&prices);
250        let before = emd.value();
251        assert!(before.is_some());
252        assert_eq!(emd.update(f64::NAN), before);
253    }
254
255    #[test]
256    fn reset_clears_state() {
257        let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
258        let prices: Vec<f64> = (0..200)
259            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
260            .collect();
261        emd.batch(&prices);
262        assert!(emd.is_ready());
263        emd.reset();
264        assert!(!emd.is_ready());
265    }
266}