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 period > crate::error::MAX_PERIOD {
72            return Err(Error::InvalidPeriod {
73                message: crate::error::PERIOD_ABOVE_MAX,
74            });
75        }
76        if !fraction.is_finite() || fraction <= 0.0 || fraction > 1.0 {
77            return Err(Error::InvalidPeriod {
78                message: "fraction must be in (0, 1]",
79            });
80        }
81        let beta = (2.0 * PI / period as f64).cos();
82        let gamma = 1.0 / (2.0 * PI * 0.25 / period as f64).cos();
83        let alpha = gamma - (gamma * gamma - 1.0).sqrt();
84        let history = (period as f64 * fraction).round().max(1.0) as usize;
85        Ok(Self {
86            period,
87            fraction,
88            bandpass: 0.0,
89            prev_bp_1: 0.0,
90            prev_bp_2: 0.0,
91            prev_in_1: None,
92            prev_in_2: None,
93            beta,
94            alpha,
95            smoother: SuperSmoother::new(period.max(2))?,
96            peak_smoother: SuperSmoother::new(period.max(2))?,
97            valley_smoother: SuperSmoother::new(period.max(2))?,
98            bp_buf: VecDeque::with_capacity(history),
99            bp_history_len: history,
100            last_value: None,
101        })
102    }
103
104    /// Configured period.
105    pub const fn period(&self) -> usize {
106        self.period
107    }
108
109    /// Configured fraction.
110    pub const fn fraction(&self) -> f64 {
111        self.fraction
112    }
113
114    /// Current value if available.
115    pub const fn value(&self) -> Option<f64> {
116        self.last_value
117    }
118}
119
120impl Indicator for EmpiricalModeDecomposition {
121    type Input = f64;
122    type Output = f64;
123
124    fn update(&mut self, input: f64) -> Option<f64> {
125        if !input.is_finite() {
126            return None;
127        }
128        // 2nd-order resonant bandpass per Ehlers ch. 6.
129        let bp = if let (Some(_x1), Some(x2)) = (self.prev_in_1, self.prev_in_2) {
130            0.5 * (1.0 - self.alpha) * (input - x2)
131                + self.beta * (1.0 + self.alpha) * self.prev_bp_1
132                - self.alpha * self.prev_bp_2
133        } else {
134            0.0
135        };
136        self.prev_bp_2 = self.prev_bp_1;
137        self.prev_bp_1 = bp;
138        self.bandpass = bp;
139        self.prev_in_2 = self.prev_in_1;
140        self.prev_in_1 = Some(input);
141
142        if self.bp_buf.len() == self.bp_history_len {
143            self.bp_buf.pop_front();
144        }
145        self.bp_buf.push_back(bp);
146        if self.bp_buf.len() < self.bp_history_len {
147            return None;
148        }
149
150        // Identify the current peak (largest), valley (smallest) within the window.
151        let peak = self
152            .bp_buf
153            .iter()
154            .copied()
155            .fold(f64::NEG_INFINITY, f64::max);
156        let valley = self.bp_buf.iter().copied().fold(f64::INFINITY, f64::min);
157
158        let avg_peak = self.peak_smoother.update(peak)?;
159        let avg_valley = self.valley_smoother.update(valley)?;
160
161        // The EMD line is the bandpass minus the smoothed mean envelope.
162        let mean = f64::midpoint(avg_peak, avg_valley);
163        let raw = bp - mean;
164        let v = self.smoother.update(raw)?;
165        self.last_value = Some(v);
166        Some(v)
167    }
168
169    fn reset(&mut self) {
170        self.bandpass = 0.0;
171        self.prev_bp_1 = 0.0;
172        self.prev_bp_2 = 0.0;
173        self.prev_in_1 = None;
174        self.prev_in_2 = None;
175        self.smoother.reset();
176        self.peak_smoother.reset();
177        self.valley_smoother.reset();
178        self.bp_buf.clear();
179        self.last_value = None;
180    }
181
182    #[inline]
183    fn warmup_period(&self) -> usize {
184        self.bp_history_len
185    }
186
187    #[inline]
188    fn is_ready(&self) -> bool {
189        self.last_value.is_some()
190    }
191
192    #[inline]
193    fn name(&self) -> &'static str {
194        "EmpiricalModeDecomposition"
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::traits::BatchExt;
202
203    #[test]
204    fn new_rejects_invalid_params() {
205        assert!(matches!(
206            EmpiricalModeDecomposition::new(0, 0.5),
207            Err(Error::PeriodZero)
208        ));
209        assert!(matches!(
210            EmpiricalModeDecomposition::new(20, 0.0),
211            Err(Error::InvalidPeriod { .. })
212        ));
213        assert!(matches!(
214            EmpiricalModeDecomposition::new(20, 1.5),
215            Err(Error::InvalidPeriod { .. })
216        ));
217        assert!(matches!(
218            EmpiricalModeDecomposition::new(20, f64::NAN),
219            Err(Error::InvalidPeriod { .. })
220        ));
221    }
222
223    #[test]
224    fn accessors_and_metadata() {
225        let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
226        assert_eq!(emd.period(), 20);
227        assert!((emd.fraction() - 0.5).abs() < 1e-15);
228        assert_eq!(emd.name(), "EmpiricalModeDecomposition");
229        assert!(emd.warmup_period() >= 1);
230        assert!(!emd.is_ready());
231        let prices: Vec<f64> = (0..200)
232            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
233            .collect();
234        emd.batch(&prices);
235        assert!(emd.is_ready());
236        assert!(emd.value().is_some());
237    }
238
239    #[test]
240    fn batch_equals_streaming() {
241        let prices: Vec<f64> = (0..200)
242            .map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 5.0)
243            .collect();
244        let mut a = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
245        let mut b = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
246        let batch = a.batch(&prices);
247        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
248        assert_eq!(batch, streamed);
249    }
250
251    #[test]
252    fn ignores_non_finite_input() {
253        let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
254        let prices: Vec<f64> = (0..200)
255            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
256            .collect();
257        emd.batch(&prices);
258        let before = emd.value();
259        assert!(before.is_some());
260        assert_eq!(emd.update(f64::NAN), None);
261    }
262
263    #[test]
264    fn reset_clears_state() {
265        let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
266        let prices: Vec<f64> = (0..200)
267            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
268            .collect();
269        emd.batch(&prices);
270        assert!(emd.is_ready());
271        emd.reset();
272        assert!(!emd.is_ready());
273    }
274}