Skip to main content

wickra_core/indicators/
super_smoother.rs

1//! Ehlers SuperSmoother filter.
2#![allow(clippy::doc_markdown)]
3
4use std::f64::consts::PI;
5
6use crate::error::{Error, Result};
7use crate::traits::Indicator;
8
9/// Ehlers' 2-pole Butterworth-style "SuperSmoother" lowpass filter.
10///
11/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 3). For a given
12/// critical period `period`, the filter coefficients are:
13///
14/// ```text
15/// a1 = exp(-sqrt(2) * pi / period)
16/// b1 = 2 * a1 * cos(sqrt(2) * pi / period)
17/// c2 = b1
18/// c3 = -a1 * a1
19/// c1 = 1 - c2 - c3
20/// y[t] = c1 * (x[t] + x[t-1]) / 2 + c2 * y[t-1] + c3 * y[t-2]
21/// ```
22///
23/// The implementation needs two prior inputs and two prior outputs to begin
24/// running; until then it returns the input itself (a common Ehlers initial
25/// condition), which lets downstream filters warm up without long delays.
26///
27/// # Example
28///
29/// ```
30/// use wickra_core::{Indicator, SuperSmoother};
31///
32/// let mut ss = SuperSmoother::new(10).unwrap();
33/// let mut last = None;
34/// for i in 0..40 {
35///     last = ss.update(100.0 + f64::from(i));
36/// }
37/// assert!(last.is_some());
38/// ```
39#[derive(Debug, Clone)]
40pub struct SuperSmoother {
41    period: usize,
42    c1: f64,
43    c2: f64,
44    c3: f64,
45    prev_input: Option<f64>,
46    prev_output_1: Option<f64>,
47    prev_output_2: Option<f64>,
48    count: usize,
49}
50
51impl SuperSmoother {
52    /// Construct a new SuperSmoother with the given critical period.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`Error::PeriodZero`] if `period == 0`.
57    pub fn new(period: usize) -> Result<Self> {
58        if period == 0 {
59            return Err(Error::PeriodZero);
60        }
61        if period > crate::error::MAX_PERIOD {
62            return Err(Error::InvalidPeriod {
63                message: crate::error::PERIOD_ABOVE_MAX,
64            });
65        }
66        let arg = std::f64::consts::SQRT_2 * PI / period as f64;
67        let a1 = (-arg).exp();
68        let b1 = 2.0 * a1 * arg.cos();
69        let c2 = b1;
70        let c3 = -a1 * a1;
71        let c1 = 1.0 - c2 - c3;
72        Ok(Self {
73            period,
74            c1,
75            c2,
76            c3,
77            prev_input: None,
78            prev_output_1: None,
79            prev_output_2: None,
80            count: 0,
81        })
82    }
83
84    /// Configured period.
85    pub const fn period(&self) -> usize {
86        self.period
87    }
88
89    /// Filter coefficients `(c1, c2, c3)`.
90    pub const fn coefficients(&self) -> (f64, f64, f64) {
91        (self.c1, self.c2, self.c3)
92    }
93
94    /// Current value if available.
95    pub const fn value(&self) -> Option<f64> {
96        self.prev_output_1
97    }
98}
99
100impl Indicator for SuperSmoother {
101    type Input = f64;
102    type Output = f64;
103
104    #[inline]
105    fn update(&mut self, input: f64) -> Option<f64> {
106        if !input.is_finite() {
107            return None;
108        }
109        self.count += 1;
110        let output = match (self.prev_input, self.prev_output_1, self.prev_output_2) {
111            (Some(p_in), Some(y1), Some(y2)) => {
112                let avg = f64::midpoint(input, p_in);
113                self.c1 * avg + self.c2 * y1 + self.c3 * y2
114            }
115            _ => input,
116        };
117        self.prev_output_2 = self.prev_output_1;
118        self.prev_output_1 = Some(output);
119        self.prev_input = Some(input);
120        Some(output)
121    }
122
123    fn reset(&mut self) {
124        self.prev_input = None;
125        self.prev_output_1 = None;
126        self.prev_output_2 = None;
127        self.count = 0;
128    }
129
130    #[inline]
131    fn warmup_period(&self) -> usize {
132        1
133    }
134
135    #[inline]
136    fn is_ready(&self) -> bool {
137        self.prev_output_1.is_some()
138    }
139
140    #[inline]
141    fn name(&self) -> &'static str {
142        "SuperSmoother"
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::traits::BatchExt;
150    use approx::assert_relative_eq;
151
152    #[test]
153    fn new_rejects_zero_period() {
154        assert!(matches!(SuperSmoother::new(0), Err(Error::PeriodZero)));
155    }
156
157    #[test]
158    fn accessors_and_metadata() {
159        let mut ss = SuperSmoother::new(10).unwrap();
160        assert_eq!(ss.period(), 10);
161        assert_eq!(ss.name(), "SuperSmoother");
162        assert_eq!(ss.warmup_period(), 1);
163        let (c1, c2, c3) = ss.coefficients();
164        // Coefficients sum to 1 by construction (steady-state gain == 1).
165        assert_relative_eq!(c1 + c2 + c3, 1.0, epsilon = 1e-12);
166        assert!(ss.value().is_none());
167        ss.update(42.0);
168        assert!(ss.value().is_some());
169        assert!(ss.is_ready());
170    }
171
172    #[test]
173    fn first_output_equals_input_then_filters() {
174        let mut ss = SuperSmoother::new(10).unwrap();
175        // Initial condition: first two outputs equal their inputs.
176        assert_eq!(ss.update(100.0), Some(100.0));
177        assert_eq!(ss.update(101.0), Some(101.0));
178        let third = ss.update(102.0).unwrap();
179        // From step 3 onward, the recursive filter activates and the result
180        // is no longer the raw input.
181        assert!((third - 102.0).abs() < 5.0);
182    }
183
184    #[test]
185    fn constant_series_converges_to_constant() {
186        // Steady-state gain is 1 (c1 + c2 + c3 = 1), so a flat input yields a
187        // flat output after warmup.
188        let mut ss = SuperSmoother::new(20).unwrap();
189        let out = ss.batch(&[50.0_f64; 200]);
190        for x in out.iter().skip(50).flatten() {
191            assert_relative_eq!(*x, 50.0, epsilon = 1e-9);
192        }
193    }
194
195    #[test]
196    fn batch_equals_streaming() {
197        let prices: Vec<f64> = (0..120)
198            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
199            .collect();
200        let mut a = SuperSmoother::new(15).unwrap();
201        let mut b = SuperSmoother::new(15).unwrap();
202        let batch = a.batch(&prices);
203        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
204        assert_eq!(batch, streamed);
205    }
206
207    #[test]
208    fn ignores_non_finite_input() {
209        let mut ss = SuperSmoother::new(10).unwrap();
210        ss.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
211        let before = ss.value();
212        assert!(before.is_some());
213        assert_eq!(ss.update(f64::NAN), None);
214        assert_eq!(ss.update(f64::INFINITY), None);
215    }
216
217    #[test]
218    fn reset_clears_state() {
219        let mut ss = SuperSmoother::new(10).unwrap();
220        ss.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
221        assert!(ss.is_ready());
222        ss.reset();
223        assert!(!ss.is_ready());
224        assert_eq!(ss.update(50.0), Some(50.0));
225    }
226}