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        let arg = 0.707 * 2.0 * PI / period as f64;
63        let c = arg.cos();
64        let alpha = (c + arg.sin() - 1.0) / c;
65        Ok(Self {
66            period,
67            alpha,
68            prev_in_1: None,
69            prev_in_2: None,
70            prev_hp_1: 0.0,
71            prev_hp_2: 0.0,
72            last_value: None,
73        })
74    }
75
76    /// Configured period.
77    pub const fn period(&self) -> usize {
78        self.period
79    }
80
81    /// High-pass `alpha` coefficient derived from the period.
82    pub const fn alpha(&self) -> f64 {
83        self.alpha
84    }
85
86    /// Current decycler value if available.
87    pub const fn value(&self) -> Option<f64> {
88        self.last_value
89    }
90
91    /// Compute and store the high-pass output for the latest input.
92    fn step_hp(&mut self, input: f64) -> f64 {
93        let (Some(x1), Some(x2)) = (self.prev_in_1, self.prev_in_2) else {
94            self.prev_hp_2 = self.prev_hp_1;
95            self.prev_hp_1 = 0.0;
96            return 0.0;
97        };
98        let one_minus_half_alpha = 1.0 - self.alpha / 2.0;
99        let one_minus_alpha = 1.0 - self.alpha;
100        let drv = one_minus_half_alpha * one_minus_half_alpha;
101        let term1 = drv * (input - 2.0 * x1 + x2);
102        let term2 = 2.0 * one_minus_alpha * self.prev_hp_1;
103        let term3 = one_minus_alpha * one_minus_alpha * self.prev_hp_2;
104        let hp = term1 + term2 - term3;
105        self.prev_hp_2 = self.prev_hp_1;
106        self.prev_hp_1 = hp;
107        hp
108    }
109}
110
111impl Indicator for Decycler {
112    type Input = f64;
113    type Output = f64;
114
115    fn update(&mut self, input: f64) -> Option<f64> {
116        if !input.is_finite() {
117            return self.last_value;
118        }
119        let hp = self.step_hp(input);
120        let v = input - hp;
121        self.prev_in_2 = self.prev_in_1;
122        self.prev_in_1 = Some(input);
123        self.last_value = Some(v);
124        Some(v)
125    }
126
127    fn reset(&mut self) {
128        self.prev_in_1 = None;
129        self.prev_in_2 = None;
130        self.prev_hp_1 = 0.0;
131        self.prev_hp_2 = 0.0;
132        self.last_value = None;
133    }
134
135    fn warmup_period(&self) -> usize {
136        1
137    }
138
139    fn is_ready(&self) -> bool {
140        self.last_value.is_some()
141    }
142
143    fn name(&self) -> &'static str {
144        "Decycler"
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::traits::BatchExt;
152    use approx::assert_relative_eq;
153
154    #[test]
155    fn new_rejects_zero_period() {
156        assert!(matches!(Decycler::new(0), Err(Error::PeriodZero)));
157    }
158
159    #[test]
160    fn accessors_and_metadata() {
161        let mut dc = Decycler::new(20).unwrap();
162        assert_eq!(dc.period(), 20);
163        assert_eq!(dc.warmup_period(), 1);
164        assert_eq!(dc.name(), "Decycler");
165        assert!(dc.alpha() > 0.0 && dc.alpha() < 1.0);
166        assert!(!dc.is_ready());
167        dc.update(100.0);
168        assert!(dc.is_ready());
169        assert!(dc.value().is_some());
170    }
171
172    #[test]
173    fn constant_series_passes_through() {
174        // For a flat input, the high-pass output is zero, so the decycler
175        // equals the input.
176        let mut dc = Decycler::new(20).unwrap();
177        let out = dc.batch(&[42.0_f64; 80]);
178        for x in out.iter().flatten() {
179            assert_relative_eq!(*x, 42.0, epsilon = 1e-9);
180        }
181    }
182
183    #[test]
184    fn batch_equals_streaming() {
185        let prices: Vec<f64> = (0..100)
186            .map(|i| 100.0 + (f64::from(i) * 0.15).sin() * 5.0)
187            .collect();
188        let mut a = Decycler::new(20).unwrap();
189        let mut b = Decycler::new(20).unwrap();
190        let batch = a.batch(&prices);
191        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
192        assert_eq!(batch, streamed);
193    }
194
195    #[test]
196    fn ignores_non_finite_input() {
197        let mut dc = Decycler::new(20).unwrap();
198        dc.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
199        let before = dc.value();
200        assert!(before.is_some());
201        assert_eq!(dc.update(f64::NAN), before);
202        assert_eq!(dc.update(f64::INFINITY), before);
203    }
204
205    #[test]
206    fn reset_clears_state() {
207        let mut dc = Decycler::new(20).unwrap();
208        dc.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
209        assert!(dc.is_ready());
210        dc.reset();
211        assert!(!dc.is_ready());
212    }
213}