Skip to main content

wickra_core/indicators/
even_better_sinewave.rs

1//! Ehlers Even Better Sinewave (EBSW) — a normalised cycle oscillator in [-1, 1].
2#![allow(clippy::doc_markdown)]
3
4use std::f64::consts::PI;
5
6use crate::error::{Error, Result};
7use crate::indicators::super_smoother::SuperSmoother;
8use crate::traits::Indicator;
9
10/// Ehlers' **Even Better Sinewave** (EBSW) — a self-normalising cycle oscillator
11/// that swings cleanly in `[−1, +1]` regardless of price amplitude.
12///
13/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 12):
14///
15/// ```text
16/// alpha1 = (1 − sin(2π/hp_period)) / cos(2π/hp_period)
17/// HP_t   = 0.5·(1 + alpha1)·(price_t − price_{t−1}) + alpha1·HP_{t−1}   (one-pole highpass)
18/// Filt   = SuperSmoother(HP, ssf_length)
19/// Wave   = (Filt_t + Filt_{t−1} + Filt_{t−2}) / 3
20/// Pwr    = (Filt_t² + Filt_{t−1}² + Filt_{t−2}²) / 3
21/// EBSW   = Wave / sqrt(Pwr)
22/// ```
23///
24/// The price is first highpass-filtered to remove the trend, then SuperSmoothed to
25/// remove noise, leaving the dominant cycle. Dividing a 3-bar average of that
26/// cycle by its RMS power normalises the amplitude, so the output reads like a
27/// clean sine wave bounded in `[−1, +1]` whatever the instrument. Unlike the
28/// classic [`SineWave`](crate::SineWave) (which derives in-phase/quadrature
29/// components from the Hilbert transform and can whip in trends), the EBSW stays
30/// well-behaved and is read directly: crossing up through `0`/`−0.9` is a buy
31/// cue, crossing down through `0`/`+0.9` a sell cue.
32///
33/// The first value lands once three SuperSmoothed samples exist
34/// (`warmup_period == 3`). Each `update` is O(1).
35///
36/// # Example
37///
38/// ```
39/// use wickra_core::{Indicator, EvenBetterSinewave};
40///
41/// let mut indicator = EvenBetterSinewave::new(40, 10).unwrap();
42/// let mut last = None;
43/// for i in 0..120 {
44///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
45/// }
46/// assert!(last.is_some());
47/// ```
48#[derive(Debug, Clone)]
49pub struct EvenBetterSinewave {
50    hp_period: usize,
51    ssf_length: usize,
52    alpha1: f64,
53    smoother: SuperSmoother,
54    prev_price: Option<f64>,
55    hp: f64,
56    filt1: Option<f64>,
57    filt2: Option<f64>,
58    filt3: Option<f64>,
59    last: Option<f64>,
60}
61
62impl EvenBetterSinewave {
63    /// Construct an EBSW with the given highpass `hp_period` and SuperSmoother
64    /// `ssf_length`.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`Error::PeriodZero`] if either argument is `0`.
69    pub fn new(hp_period: usize, ssf_length: usize) -> Result<Self> {
70        if hp_period == 0 || ssf_length == 0 {
71            return Err(Error::PeriodZero);
72        }
73        let w = 2.0 * PI / hp_period as f64;
74        let alpha1 = (1.0 - w.sin()) / w.cos();
75        Ok(Self {
76            hp_period,
77            ssf_length,
78            alpha1,
79            smoother: SuperSmoother::new(ssf_length)?,
80            prev_price: None,
81            hp: 0.0,
82            filt1: None,
83            filt2: None,
84            filt3: None,
85            last: None,
86        })
87    }
88
89    /// Configured `(hp_period, ssf_length)`.
90    pub const fn params(&self) -> (usize, usize) {
91        (self.hp_period, self.ssf_length)
92    }
93
94    /// Current value if available.
95    pub const fn value(&self) -> Option<f64> {
96        self.last
97    }
98}
99
100impl Indicator for EvenBetterSinewave {
101    type Input = f64;
102    type Output = f64;
103
104    #[inline]
105    fn update(&mut self, price: f64) -> Option<f64> {
106        if !price.is_finite() {
107            return None;
108        }
109        // Ehlers' high-pass gain, (1 + alpha1) / 2.
110        let gain = f64::midpoint(1.0, self.alpha1);
111        let hp = match self.prev_price {
112            Some(prev) => gain * (price - prev) + self.alpha1 * self.hp,
113            None => 0.0,
114        };
115        self.prev_price = Some(price);
116        self.hp = hp;
117        let filt = self.smoother.update(hp)?;
118        // Shift the three-deep filter buffer.
119        self.filt3 = self.filt2;
120        self.filt2 = self.filt1;
121        self.filt1 = Some(filt);
122        let (Some(f1), Some(f2), Some(f3)) = (self.filt1, self.filt2, self.filt3) else {
123            return None;
124        };
125        let wave = (f1 + f2 + f3) / 3.0;
126        let pwr = (f1 * f1 + f2 * f2 + f3 * f3) / 3.0;
127        let ebsw = if pwr > 0.0 {
128            (wave / pwr.sqrt()).clamp(-1.0, 1.0)
129        } else {
130            0.0
131        };
132        self.last = Some(ebsw);
133        Some(ebsw)
134    }
135
136    fn reset(&mut self) {
137        self.smoother.reset();
138        self.prev_price = None;
139        self.hp = 0.0;
140        self.filt1 = None;
141        self.filt2 = None;
142        self.filt3 = None;
143        self.last = None;
144    }
145
146    #[inline]
147    fn warmup_period(&self) -> usize {
148        3
149    }
150
151    #[inline]
152    fn is_ready(&self) -> bool {
153        self.last.is_some()
154    }
155
156    #[inline]
157    fn name(&self) -> &'static str {
158        "EvenBetterSinewave"
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::traits::BatchExt;
166
167    #[test]
168    fn rejects_zero_params() {
169        assert!(matches!(
170            EvenBetterSinewave::new(0, 10),
171            Err(Error::PeriodZero)
172        ));
173        assert!(matches!(
174            EvenBetterSinewave::new(40, 0),
175            Err(Error::PeriodZero)
176        ));
177    }
178
179    #[test]
180    fn accessors_and_metadata() {
181        let e = EvenBetterSinewave::new(40, 10).unwrap();
182        assert_eq!(e.params(), (40, 10));
183        assert_eq!(e.warmup_period(), 3);
184        assert_eq!(e.name(), "EvenBetterSinewave");
185        assert!(!e.is_ready());
186        assert_eq!(e.value(), None);
187    }
188
189    #[test]
190    fn first_emission_at_warmup_period() {
191        let mut e = EvenBetterSinewave::new(40, 10).unwrap();
192        let xs: Vec<f64> = (0..12)
193            .map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 3.0)
194            .collect();
195        let out = e.batch(&xs);
196        for v in out.iter().take(2) {
197            assert!(v.is_none());
198        }
199        assert!(out[2].is_some());
200    }
201
202    #[test]
203    fn output_in_range() {
204        let mut e = EvenBetterSinewave::new(40, 10).unwrap();
205        let xs: Vec<f64> = (0..400)
206            .map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 30.0).sin() * 5.0)
207            .collect();
208        for v in e.batch(&xs).into_iter().flatten() {
209            assert!((-1.0..=1.0).contains(&v), "EBSW out of range: {v}");
210        }
211    }
212
213    #[test]
214    fn cyclic_input_swings_both_signs() {
215        let mut e = EvenBetterSinewave::new(30, 8).unwrap();
216        let xs: Vec<f64> = (0..400)
217            .map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 30.0).sin() * 5.0)
218            .collect();
219        let out: Vec<f64> = e.batch(&xs).into_iter().flatten().skip(100).collect();
220        assert!(out.iter().any(|&v| v > 0.5));
221        assert!(out.iter().any(|&v| v < -0.5));
222    }
223
224    #[test]
225    fn ignores_non_finite() {
226        let mut e = EvenBetterSinewave::new(40, 10).unwrap();
227        e.batch(
228            &(0..40)
229                .map(|i| 100.0 + (f64::from(i) * 0.3).sin())
230                .collect::<Vec<_>>(),
231        );
232        let before = e.value();
233        assert_eq!(e.update(f64::NAN), None);
234        // The rejected input must not have disturbed the state.
235        assert_eq!(e.value(), before);
236    }
237
238    #[test]
239    fn reset_clears_state() {
240        let mut e = EvenBetterSinewave::new(40, 10).unwrap();
241        e.batch(
242            &(0..40)
243                .map(|i| 100.0 + (f64::from(i) * 0.3).sin())
244                .collect::<Vec<_>>(),
245        );
246        assert!(e.is_ready());
247        e.reset();
248        assert!(!e.is_ready());
249        assert_eq!(e.value(), None);
250    }
251
252    #[test]
253    fn batch_equals_streaming() {
254        let xs: Vec<f64> = (0..120)
255            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
256            .collect();
257        let batch = EvenBetterSinewave::new(40, 10).unwrap().batch(&xs);
258        let mut b = EvenBetterSinewave::new(40, 10).unwrap();
259        let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
260        assert_eq!(batch, streamed);
261    }
262
263    #[test]
264    fn flat_input_yields_zero_power() {
265        // A constant series drives the highpass/smoother outputs to zero, so the
266        // signal power is zero and the oscillator reports 0.0 (the `pwr == 0` arm).
267        let flat = [100.0_f64; 200];
268        let last = EvenBetterSinewave::new(40, 10)
269            .unwrap()
270            .batch(&flat)
271            .into_iter()
272            .flatten()
273            .last()
274            .unwrap();
275        assert_eq!(last, 0.0);
276    }
277}