Skip to main content

wickra_core/indicators/
reflex.rs

1//! Ehlers Reflex — a zero-lag cycle oscillator built on a SuperSmoother prefilter.
2#![allow(clippy::doc_markdown)]
3
4use std::collections::VecDeque;
5
6use crate::error::{Error, Result};
7use crate::indicators::super_smoother::SuperSmoother;
8use crate::traits::Indicator;
9
10/// Ehlers' **Reflex** — a near-zero-lag oscillator that measures how far the
11/// smoothed price has deviated from the straight line connecting its endpoints
12/// over the lookback.
13///
14/// From John Ehlers, "Reflex: A New Zero-Lag Indicator" (*Stocks & Commodities*,
15/// Feb 2020):
16///
17/// ```text
18/// Filt   = SuperSmoother(price, 0.5 · period)
19/// slope  = (Filt[period] − Filt[0]) / period          (line over the window)
20/// sum    = mean over i=1..period of ( Filt[0] + i·slope − Filt[i] )
21/// ms     = 0.04·sum² + 0.96·ms[−1]                     (adaptive normaliser)
22/// Reflex = sum / sqrt(ms)                              (0 if ms == 0)
23/// ```
24///
25/// Reflex fits a straight line across the SuperSmoothed price over `period` bars
26/// and averages the deviation of the curve from that line. Because the line uses
27/// both endpoints, the measure has almost no lag — it crosses zero essentially at
28/// the cycle turns. The adaptive mean-square normaliser rescales the output to a
29/// roughly `±3` range regardless of price, so the same thresholds work on any
30/// instrument. Its sibling [`Trendflex`](crate::Trendflex) uses the deviation from
31/// the *current* value instead of the line, making it trend- rather than
32/// cycle-sensitive.
33///
34/// The first value lands after `period + 1` SuperSmoothed samples. Each `update`
35/// is O(`period`).
36///
37/// # Example
38///
39/// ```
40/// use wickra_core::{Indicator, Reflex};
41///
42/// let mut indicator = Reflex::new(20).unwrap();
43/// let mut last = None;
44/// for i in 0..120 {
45///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
46/// }
47/// assert!(last.is_some());
48/// ```
49#[derive(Debug, Clone)]
50pub struct Reflex {
51    period: usize,
52    smoother: SuperSmoother,
53    filt: VecDeque<f64>,
54    ms: f64,
55    last: Option<f64>,
56}
57
58impl Reflex {
59    /// Construct a Reflex with the given lookback `period`.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`Error::PeriodZero`] if `period == 0`.
64    pub fn new(period: usize) -> Result<Self> {
65        if period == 0 {
66            return Err(Error::PeriodZero);
67        }
68        if period > crate::error::MAX_PERIOD {
69            return Err(Error::InvalidPeriod {
70                message: crate::error::PERIOD_ABOVE_MAX,
71            });
72        }
73        Ok(Self {
74            period,
75            // Ehlers smooths with half the cycle length (`a1 = exp(-1.414·π / (0.5·Length))`).
76            smoother: SuperSmoother::with_critical_period(period, 0.5 * period as f64),
77            filt: VecDeque::with_capacity(period + 1),
78            ms: 0.0,
79            last: None,
80        })
81    }
82
83    /// Configured lookback period.
84    pub const fn period(&self) -> usize {
85        self.period
86    }
87
88    /// Current value if available.
89    pub const fn value(&self) -> Option<f64> {
90        self.last
91    }
92}
93
94impl Indicator for Reflex {
95    type Input = f64;
96    type Output = f64;
97
98    #[inline]
99    fn update(&mut self, price: f64) -> Option<f64> {
100        if !price.is_finite() {
101            return None;
102        }
103        let filt = self.smoother.update(price)?;
104        if self.filt.len() == self.period + 1 {
105            self.filt.pop_front();
106        }
107        self.filt.push_back(filt);
108        if self.filt.len() < self.period + 1 {
109            return None;
110        }
111        // Newest at index `period`, oldest (period bars ago) at index 0.
112        let newest = self.filt[self.period];
113        let oldest = self.filt[0];
114        let slope = (oldest - newest) / self.period as f64;
115        let mut sum = 0.0;
116        for i in 1..=self.period {
117            sum += (newest + i as f64 * slope) - self.filt[self.period - i];
118        }
119        sum /= self.period as f64;
120        self.ms = 0.04 * sum * sum + 0.96 * self.ms;
121        let reflex = if self.ms > 0.0 {
122            sum / self.ms.sqrt()
123        } else {
124            0.0
125        };
126        self.last = Some(reflex);
127        Some(reflex)
128    }
129
130    fn reset(&mut self) {
131        self.smoother.reset();
132        self.filt.clear();
133        self.ms = 0.0;
134        self.last = None;
135    }
136
137    #[inline]
138    fn warmup_period(&self) -> usize {
139        self.period + 1
140    }
141
142    #[inline]
143    fn is_ready(&self) -> bool {
144        self.last.is_some()
145    }
146
147    #[inline]
148    fn name(&self) -> &'static str {
149        "Reflex"
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::traits::BatchExt;
157    use approx::assert_relative_eq;
158
159    #[test]
160    fn rejects_zero_period() {
161        assert!(matches!(Reflex::new(0), Err(Error::PeriodZero)));
162    }
163
164    #[test]
165    fn accessors_and_metadata() {
166        let r = Reflex::new(20).unwrap();
167        assert_eq!(r.period(), 20);
168        assert_eq!(r.warmup_period(), 21);
169        assert_eq!(r.name(), "Reflex");
170        assert!(!r.is_ready());
171        assert_eq!(r.value(), None);
172    }
173
174    #[test]
175    fn first_emission_at_warmup_period() {
176        let mut r = Reflex::new(5).unwrap();
177        let xs: Vec<f64> = (0..12)
178            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 3.0)
179            .collect();
180        let out = r.batch(&xs);
181        for v in out.iter().take(5) {
182            assert!(v.is_none());
183        }
184        assert!(out[5].is_some());
185    }
186
187    #[test]
188    fn constant_input_is_zero() {
189        // A flat price is exactly its own straight line -> zero deviation -> 0.
190        let mut r = Reflex::new(10).unwrap();
191        for v in r.batch(&[50.0; 100]).into_iter().flatten() {
192            assert_relative_eq!(v, 0.0, epsilon = 1e-9);
193        }
194    }
195
196    #[test]
197    fn cyclic_input_oscillates_around_zero() {
198        let mut r = Reflex::new(20).unwrap();
199        let xs: Vec<f64> = (0..400)
200            .map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0)
201            .collect();
202        let out: Vec<f64> = r.batch(&xs).into_iter().flatten().skip(100).collect();
203        assert!(out.iter().any(|&v| v > 0.5));
204        assert!(out.iter().any(|&v| v < -0.5));
205    }
206
207    #[test]
208    fn ignores_non_finite() {
209        let mut r = Reflex::new(10).unwrap();
210        r.batch(
211            &(0..40)
212                .map(|i| 100.0 + (f64::from(i) * 0.3).sin())
213                .collect::<Vec<_>>(),
214        );
215        let before = r.value();
216        assert_eq!(r.update(f64::NAN), None);
217        // The rejected input must not have disturbed the state.
218        assert_eq!(r.value(), before);
219    }
220
221    #[test]
222    fn reset_clears_state() {
223        let mut r = Reflex::new(10).unwrap();
224        r.batch(
225            &(0..40)
226                .map(|i| 100.0 + (f64::from(i) * 0.3).sin())
227                .collect::<Vec<_>>(),
228        );
229        assert!(r.is_ready());
230        r.reset();
231        assert!(!r.is_ready());
232        assert_eq!(r.value(), None);
233    }
234
235    #[test]
236    fn batch_equals_streaming() {
237        let xs: Vec<f64> = (0..120)
238            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
239            .collect();
240        let batch = Reflex::new(20).unwrap().batch(&xs);
241        let mut b = Reflex::new(20).unwrap();
242        let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
243        assert_eq!(batch, streamed);
244    }
245
246    use crate::traits::BatchNanExt;
247
248    #[test]
249    fn rejects_period_above_max() {
250        assert!(matches!(
251            Reflex::new(crate::error::MAX_PERIOD + 1),
252            Err(Error::InvalidPeriod { .. })
253        ));
254    }
255
256    #[test]
257    fn first_value_lands_exactly_at_warmup_for_several_periods() {
258        for period in [1_usize, 2, 7] {
259            let mut r = Reflex::new(period).unwrap();
260            let xs: Vec<f64> = (0..20)
261                .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 3.0)
262                .collect();
263            let out = r.batch(&xs);
264            let warmup = r.warmup_period();
265            assert!(out[..warmup - 1].iter().all(Option::is_none));
266            assert!(out[warmup - 1].is_some());
267        }
268    }
269
270    #[test]
271    fn reset_replays_identically() {
272        let xs: Vec<f64> = (0..120)
273            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
274            .collect();
275        let fresh = Reflex::new(13).unwrap().batch(&xs);
276        let mut r = Reflex::new(13).unwrap();
277        let first = r.batch(&xs);
278        r.reset();
279        let second = r.batch(&xs);
280        assert_eq!(first, fresh);
281        assert_eq!(second, fresh);
282    }
283
284    #[test]
285    fn batch_nan_paths_match_streaming_bitwise() {
286        let xs: Vec<f64> = (0..120)
287            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
288            .collect();
289        let mut out = vec![0.0; xs.len()];
290        Reflex::new(13).unwrap().batch_nan_into(&xs, &mut out);
291        let nan = Reflex::new(13).unwrap().batch_nan(&xs);
292        let fast = Reflex::new(13).unwrap().batch_fast(&xs);
293        let mut stream = Reflex::new(13).unwrap();
294        let expected: Vec<u64> = xs
295            .iter()
296            .map(|&p| stream.update(p).unwrap_or(f64::NAN).to_bits())
297            .collect();
298        assert!(out.iter().zip(&expected).all(|(v, e)| v.to_bits() == *e));
299        assert!(nan.iter().zip(&expected).all(|(v, e)| v.to_bits() == *e));
300        assert!(fast.iter().zip(&expected).all(|(v, e)| v.to_bits() == *e));
301    }
302
303    #[test]
304    fn smoother_uses_half_period_critical() {
305        // Ehlers: a1 = exp(-1.414 * pi / (0.5 * Length)); for an odd length the
306        // critical period is fractional (7 -> 3.5).
307        let r = Reflex::new(7).unwrap();
308        let got = r.smoother.coefficients();
309        let want = SuperSmoother::with_critical_period(7, 3.5).coefficients();
310        assert_eq!(
311            (got.0.to_bits(), got.1.to_bits(), got.2.to_bits()),
312            (want.0.to_bits(), want.1.to_bits(), want.2.to_bits())
313        );
314        assert_eq!(r.smoother.period(), 7);
315        let full = SuperSmoother::new(7).unwrap().coefficients();
316        assert!((got.0 - full.0).abs() > 1e-3);
317    }
318
319    #[test]
320    fn first_value_hand_computed() {
321        // period = 2, inputs 0, 0, 6. SuperSmoother(critical 1.0) outputs
322        // 0, 0 (seed), then c1 * (6 + 0)/2 = 3*c1. Window filt = [0, 0, 3c1].
323        // slope = (oldest - newest)/2 = -1.5*c1.
324        // i=1: 3c1 + 1*(-1.5c1) - filt[1] = 1.5c1 ; i=2: 3c1 - 3c1 - filt[0] = 0.
325        // sum = 1.5c1 / 2 = 0.75c1; ms = 0.04 * sum^2; reflex = sum / (0.2*|sum|) = 5.
326        let mut r = Reflex::new(2).unwrap();
327        let (c1, _, _) = r.smoother.coefficients();
328        assert!(c1 > 0.0);
329        let out = r.batch(&[0.0, 0.0, 6.0]);
330        assert_eq!(out[1], None);
331        assert_relative_eq!(out[2].unwrap(), 5.0, epsilon = 1e-12);
332        assert_relative_eq!(r.ms, 0.04 * (0.75 * c1) * (0.75 * c1), epsilon = 1e-12);
333        // The mirrored step gives -5.
334        let mut r = Reflex::new(2).unwrap();
335        let out = r.batch(&[0.0, 0.0, -6.0]);
336        assert_relative_eq!(out[2].unwrap(), -5.0, epsilon = 1e-12);
337    }
338
339    #[test]
340    fn zero_series_takes_zero_normaliser_branch() {
341        // All-zero input keeps every filt value at exactly 0, so ms stays 0 and
342        // the guarded division returns 0.
343        let mut r = Reflex::new(4).unwrap();
344        let out = r.batch(&[0.0; 30]);
345        assert!(out
346            .iter()
347            .flatten()
348            .all(|v| v.to_bits() == 0.0_f64.to_bits()));
349        assert_eq!(out.iter().flatten().count(), 30 - 4);
350    }
351}