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        let arg = std::f64::consts::SQRT_2 * PI / period as f64;
62        let a1 = (-arg).exp();
63        let b1 = 2.0 * a1 * arg.cos();
64        let c2 = b1;
65        let c3 = -a1 * a1;
66        let c1 = 1.0 - c2 - c3;
67        Ok(Self {
68            period,
69            c1,
70            c2,
71            c3,
72            prev_input: None,
73            prev_output_1: None,
74            prev_output_2: None,
75            count: 0,
76        })
77    }
78
79    /// Configured period.
80    pub const fn period(&self) -> usize {
81        self.period
82    }
83
84    /// Filter coefficients `(c1, c2, c3)`.
85    pub const fn coefficients(&self) -> (f64, f64, f64) {
86        (self.c1, self.c2, self.c3)
87    }
88
89    /// Current value if available.
90    pub const fn value(&self) -> Option<f64> {
91        self.prev_output_1
92    }
93}
94
95impl Indicator for SuperSmoother {
96    type Input = f64;
97    type Output = f64;
98
99    fn update(&mut self, input: f64) -> Option<f64> {
100        if !input.is_finite() {
101            return self.prev_output_1;
102        }
103        self.count += 1;
104        let output = match (self.prev_input, self.prev_output_1, self.prev_output_2) {
105            (Some(p_in), Some(y1), Some(y2)) => {
106                let avg = 0.5 * (input + p_in);
107                self.c1 * avg + self.c2 * y1 + self.c3 * y2
108            }
109            _ => input,
110        };
111        self.prev_output_2 = self.prev_output_1;
112        self.prev_output_1 = Some(output);
113        self.prev_input = Some(input);
114        Some(output)
115    }
116
117    fn reset(&mut self) {
118        self.prev_input = None;
119        self.prev_output_1 = None;
120        self.prev_output_2 = None;
121        self.count = 0;
122    }
123
124    fn warmup_period(&self) -> usize {
125        1
126    }
127
128    fn is_ready(&self) -> bool {
129        self.prev_output_1.is_some()
130    }
131
132    fn name(&self) -> &'static str {
133        "SuperSmoother"
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::traits::BatchExt;
141    use approx::assert_relative_eq;
142
143    #[test]
144    fn new_rejects_zero_period() {
145        assert!(matches!(SuperSmoother::new(0), Err(Error::PeriodZero)));
146    }
147
148    #[test]
149    fn accessors_and_metadata() {
150        let mut ss = SuperSmoother::new(10).unwrap();
151        assert_eq!(ss.period(), 10);
152        assert_eq!(ss.name(), "SuperSmoother");
153        assert_eq!(ss.warmup_period(), 1);
154        let (c1, c2, c3) = ss.coefficients();
155        // Coefficients sum to 1 by construction (steady-state gain == 1).
156        assert_relative_eq!(c1 + c2 + c3, 1.0, epsilon = 1e-12);
157        assert!(ss.value().is_none());
158        ss.update(42.0);
159        assert!(ss.value().is_some());
160        assert!(ss.is_ready());
161    }
162
163    #[test]
164    fn first_output_equals_input_then_filters() {
165        let mut ss = SuperSmoother::new(10).unwrap();
166        // Initial condition: first two outputs equal their inputs.
167        assert_eq!(ss.update(100.0), Some(100.0));
168        assert_eq!(ss.update(101.0), Some(101.0));
169        let third = ss.update(102.0).unwrap();
170        // From step 3 onward, the recursive filter activates and the result
171        // is no longer the raw input.
172        assert!((third - 102.0).abs() < 5.0);
173    }
174
175    #[test]
176    fn constant_series_converges_to_constant() {
177        // Steady-state gain is 1 (c1 + c2 + c3 = 1), so a flat input yields a
178        // flat output after warmup.
179        let mut ss = SuperSmoother::new(20).unwrap();
180        let out = ss.batch(&[50.0_f64; 200]);
181        for x in out.iter().skip(50).flatten() {
182            assert_relative_eq!(*x, 50.0, epsilon = 1e-9);
183        }
184    }
185
186    #[test]
187    fn batch_equals_streaming() {
188        let prices: Vec<f64> = (0..120)
189            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
190            .collect();
191        let mut a = SuperSmoother::new(15).unwrap();
192        let mut b = SuperSmoother::new(15).unwrap();
193        let batch = a.batch(&prices);
194        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
195        assert_eq!(batch, streamed);
196    }
197
198    #[test]
199    fn ignores_non_finite_input() {
200        let mut ss = SuperSmoother::new(10).unwrap();
201        ss.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
202        let before = ss.value();
203        assert!(before.is_some());
204        assert_eq!(ss.update(f64::NAN), before);
205        assert_eq!(ss.update(f64::INFINITY), before);
206    }
207
208    #[test]
209    fn reset_clears_state() {
210        let mut ss = SuperSmoother::new(10).unwrap();
211        ss.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
212        assert!(ss.is_ready());
213        ss.reset();
214        assert!(!ss.is_ready());
215        assert_eq!(ss.update(50.0), Some(50.0));
216    }
217}