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, 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            smoother: SuperSmoother::new(period)?,
76            filt: VecDeque::with_capacity(period + 1),
77            ms: 0.0,
78            last: None,
79        })
80    }
81
82    /// Configured lookback period.
83    pub const fn period(&self) -> usize {
84        self.period
85    }
86
87    /// Current value if available.
88    pub const fn value(&self) -> Option<f64> {
89        self.last
90    }
91}
92
93impl Indicator for Reflex {
94    type Input = f64;
95    type Output = f64;
96
97    #[inline]
98    fn update(&mut self, price: f64) -> Option<f64> {
99        if !price.is_finite() {
100            return None;
101        }
102        let filt = self.smoother.update(price)?;
103        if self.filt.len() == self.period + 1 {
104            self.filt.pop_front();
105        }
106        self.filt.push_back(filt);
107        if self.filt.len() < self.period + 1 {
108            return None;
109        }
110        // Newest at index `period`, oldest (period bars ago) at index 0.
111        let newest = self.filt[self.period];
112        let oldest = self.filt[0];
113        let slope = (oldest - newest) / self.period as f64;
114        let mut sum = 0.0;
115        for i in 1..=self.period {
116            sum += (newest + i as f64 * slope) - self.filt[self.period - i];
117        }
118        sum /= self.period as f64;
119        self.ms = 0.04 * sum * sum + 0.96 * self.ms;
120        let reflex = if self.ms > 0.0 {
121            sum / self.ms.sqrt()
122        } else {
123            0.0
124        };
125        self.last = Some(reflex);
126        Some(reflex)
127    }
128
129    fn reset(&mut self) {
130        self.smoother.reset();
131        self.filt.clear();
132        self.ms = 0.0;
133        self.last = None;
134    }
135
136    #[inline]
137    fn warmup_period(&self) -> usize {
138        self.period + 1
139    }
140
141    #[inline]
142    fn is_ready(&self) -> bool {
143        self.last.is_some()
144    }
145
146    #[inline]
147    fn name(&self) -> &'static str {
148        "Reflex"
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::traits::BatchExt;
156    use approx::assert_relative_eq;
157
158    #[test]
159    fn rejects_zero_period() {
160        assert!(matches!(Reflex::new(0), Err(Error::PeriodZero)));
161    }
162
163    #[test]
164    fn accessors_and_metadata() {
165        let r = Reflex::new(20).unwrap();
166        assert_eq!(r.period(), 20);
167        assert_eq!(r.warmup_period(), 21);
168        assert_eq!(r.name(), "Reflex");
169        assert!(!r.is_ready());
170        assert_eq!(r.value(), None);
171    }
172
173    #[test]
174    fn first_emission_at_warmup_period() {
175        let mut r = Reflex::new(5).unwrap();
176        let xs: Vec<f64> = (0..12)
177            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 3.0)
178            .collect();
179        let out = r.batch(&xs);
180        for v in out.iter().take(5) {
181            assert!(v.is_none());
182        }
183        assert!(out[5].is_some());
184    }
185
186    #[test]
187    fn constant_input_is_zero() {
188        // A flat price is exactly its own straight line -> zero deviation -> 0.
189        let mut r = Reflex::new(10).unwrap();
190        for v in r.batch(&[50.0; 100]).into_iter().flatten() {
191            assert_relative_eq!(v, 0.0, epsilon = 1e-9);
192        }
193    }
194
195    #[test]
196    fn cyclic_input_oscillates_around_zero() {
197        let mut r = Reflex::new(20).unwrap();
198        let xs: Vec<f64> = (0..400)
199            .map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0)
200            .collect();
201        let out: Vec<f64> = r.batch(&xs).into_iter().flatten().skip(100).collect();
202        assert!(out.iter().any(|&v| v > 0.5));
203        assert!(out.iter().any(|&v| v < -0.5));
204    }
205
206    #[test]
207    fn ignores_non_finite() {
208        let mut r = Reflex::new(10).unwrap();
209        r.batch(
210            &(0..40)
211                .map(|i| 100.0 + (f64::from(i) * 0.3).sin())
212                .collect::<Vec<_>>(),
213        );
214        let before = r.value();
215        assert_eq!(r.update(f64::NAN), None);
216        // The rejected input must not have disturbed the state.
217        assert_eq!(r.value(), before);
218    }
219
220    #[test]
221    fn reset_clears_state() {
222        let mut r = Reflex::new(10).unwrap();
223        r.batch(
224            &(0..40)
225                .map(|i| 100.0 + (f64::from(i) * 0.3).sin())
226                .collect::<Vec<_>>(),
227        );
228        assert!(r.is_ready());
229        r.reset();
230        assert!(!r.is_ready());
231        assert_eq!(r.value(), None);
232    }
233
234    #[test]
235    fn batch_equals_streaming() {
236        let xs: Vec<f64> = (0..120)
237            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
238            .collect();
239        let batch = Reflex::new(20).unwrap().batch(&xs);
240        let mut b = Reflex::new(20).unwrap();
241        let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
242        assert_eq!(batch, streamed);
243    }
244}