Skip to main content

wickra_core/indicators/
decycler.rs

1//! Ehlers Decycler (single-pole high-pass complement).
2
3use std::f64::consts::PI;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Ehlers' Decycler: price minus the dominant cycle component.
9///
10/// Implemented as `decycler = input - HP(input)`, where `HP` is a 2-pole
11/// high-pass filter with critical period `period`. Subtracting the high-pass
12/// from the raw price leaves the slow component — equivalent to a smoothed
13/// trend line with no group delay at low frequencies. From *Cycle Analytics
14/// for Traders* (Ehlers 2013, ch. 4).
15///
16/// The high-pass uses the standard 2-pole formulation:
17///
18/// ```text
19/// alpha = (cos(.707*2*pi/period) + sin(.707*2*pi/period) - 1) / cos(.707*2*pi/period)
20/// HP[t] = (1 - alpha/2)^2 * (x[t] - 2*x[t-1] + x[t-2])
21///       + 2*(1 - alpha) * HP[t-1]
22///       - (1 - alpha)^2 * HP[t-2]
23/// ```
24///
25/// The first two outputs simply equal the input (warmup buffering), which is
26/// the conventional Ehlers initialisation and keeps downstream consumers
27/// reactive while the recursion fills.
28///
29/// # Example
30///
31/// ```
32/// use wickra_core::{Indicator, Decycler};
33///
34/// let mut dc = Decycler::new(20).unwrap();
35/// let mut last = None;
36/// for i in 0..50 {
37///     last = dc.update(100.0 + f64::from(i) * 0.5);
38/// }
39/// assert!(last.is_some());
40/// ```
41#[derive(Debug, Clone)]
42pub struct Decycler {
43    period: usize,
44    alpha: f64,
45    prev_in_1: Option<f64>,
46    prev_in_2: Option<f64>,
47    prev_hp_1: f64,
48    prev_hp_2: f64,
49    last_value: Option<f64>,
50}
51
52impl Decycler {
53    /// Construct a Decycler with the given critical period for the high-pass filter.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`Error::PeriodZero`] if `period == 0`.
58    pub fn new(period: usize) -> Result<Self> {
59        if period == 0 {
60            return Err(Error::PeriodZero);
61        }
62        if period > crate::error::MAX_PERIOD {
63            return Err(Error::InvalidPeriod {
64                message: crate::error::PERIOD_ABOVE_MAX,
65            });
66        }
67        let arg = 0.707 * 2.0 * PI / period as f64;
68        let c = arg.cos();
69        let alpha = (c + arg.sin() - 1.0) / c;
70        Ok(Self {
71            period,
72            alpha,
73            prev_in_1: None,
74            prev_in_2: None,
75            prev_hp_1: 0.0,
76            prev_hp_2: 0.0,
77            last_value: None,
78        })
79    }
80
81    /// Configured period.
82    pub const fn period(&self) -> usize {
83        self.period
84    }
85
86    /// High-pass `alpha` coefficient derived from the period.
87    pub const fn alpha(&self) -> f64 {
88        self.alpha
89    }
90
91    /// Current decycler value if available.
92    pub const fn value(&self) -> Option<f64> {
93        self.last_value
94    }
95
96    /// Compute and store the high-pass output for the latest input.
97    fn step_hp(&mut self, input: f64) -> f64 {
98        let (Some(x1), Some(x2)) = (self.prev_in_1, self.prev_in_2) else {
99            self.prev_hp_2 = self.prev_hp_1;
100            self.prev_hp_1 = 0.0;
101            return 0.0;
102        };
103        let one_minus_half_alpha = 1.0 - self.alpha / 2.0;
104        let one_minus_alpha = 1.0 - self.alpha;
105        let drv = one_minus_half_alpha * one_minus_half_alpha;
106        let term1 = drv * (input - 2.0 * x1 + x2);
107        let term2 = 2.0 * one_minus_alpha * self.prev_hp_1;
108        let term3 = one_minus_alpha * one_minus_alpha * self.prev_hp_2;
109        let hp = term1 + term2 - term3;
110        self.prev_hp_2 = self.prev_hp_1;
111        self.prev_hp_1 = hp;
112        hp
113    }
114}
115
116impl Indicator for Decycler {
117    type Input = f64;
118    type Output = f64;
119
120    #[inline]
121    fn update(&mut self, input: f64) -> Option<f64> {
122        if !input.is_finite() {
123            return None;
124        }
125        let hp = self.step_hp(input);
126        let v = input - hp;
127        self.prev_in_2 = self.prev_in_1;
128        self.prev_in_1 = Some(input);
129        self.last_value = Some(v);
130        Some(v)
131    }
132
133    fn reset(&mut self) {
134        self.prev_in_1 = None;
135        self.prev_in_2 = None;
136        self.prev_hp_1 = 0.0;
137        self.prev_hp_2 = 0.0;
138        self.last_value = None;
139    }
140
141    #[inline]
142    fn warmup_period(&self) -> usize {
143        1
144    }
145
146    #[inline]
147    fn is_ready(&self) -> bool {
148        self.last_value.is_some()
149    }
150
151    #[inline]
152    fn name(&self) -> &'static str {
153        "Decycler"
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::traits::BatchExt;
161    use approx::assert_relative_eq;
162
163    #[test]
164    fn new_rejects_zero_period() {
165        assert!(matches!(Decycler::new(0), Err(Error::PeriodZero)));
166    }
167
168    #[test]
169    fn accessors_and_metadata() {
170        let mut dc = Decycler::new(20).unwrap();
171        assert_eq!(dc.period(), 20);
172        assert_eq!(dc.warmup_period(), 1);
173        assert_eq!(dc.name(), "Decycler");
174        assert!(dc.alpha() > 0.0 && dc.alpha() < 1.0);
175        assert!(!dc.is_ready());
176        dc.update(100.0);
177        assert!(dc.is_ready());
178        assert!(dc.value().is_some());
179    }
180
181    #[test]
182    fn constant_series_passes_through() {
183        // For a flat input, the high-pass output is zero, so the decycler
184        // equals the input.
185        let mut dc = Decycler::new(20).unwrap();
186        let out = dc.batch(&[42.0_f64; 80]);
187        for x in out.iter().flatten() {
188            assert_relative_eq!(*x, 42.0, epsilon = 1e-9);
189        }
190    }
191
192    #[test]
193    fn batch_equals_streaming() {
194        let prices: Vec<f64> = (0..100)
195            .map(|i| 100.0 + (f64::from(i) * 0.15).sin() * 5.0)
196            .collect();
197        let mut a = Decycler::new(20).unwrap();
198        let mut b = Decycler::new(20).unwrap();
199        let batch = a.batch(&prices);
200        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
201        assert_eq!(batch, streamed);
202    }
203
204    #[test]
205    fn ignores_non_finite_input() {
206        let mut dc = Decycler::new(20).unwrap();
207        dc.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
208        let before = dc.value();
209        assert!(before.is_some());
210        assert_eq!(dc.update(f64::NAN), None);
211        assert_eq!(dc.update(f64::INFINITY), None);
212    }
213
214    #[test]
215    fn reset_clears_state() {
216        let mut dc = Decycler::new(20).unwrap();
217        dc.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
218        assert!(dc.is_ready());
219        dc.reset();
220        assert!(!dc.is_ready());
221    }
222}