Skip to main content

wickra_core/indicators/
trendflex.rs

1//! Ehlers Trendflex — a trend-sensitive sibling of Reflex.
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' **Trendflex** — the trend-sensitive companion to
11/// [`Reflex`](crate::Reflex): it averages how far the SuperSmoothed price sits
12/// above or below its values over the lookback, then self-normalises.
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/// sum       = mean over i=1..period of ( Filt[0] − Filt[i] )
20/// ms        = 0.04·sum² + 0.96·ms[−1]                (adaptive normaliser)
21/// Trendflex = sum / sqrt(ms)                         (0 if ms == 0)
22/// ```
23///
24/// Where Reflex measures deviation from the straight *line* across the window
25/// (cycle sensitive, near zero lag), Trendflex measures deviation from the
26/// window's *values* (trend sensitive). It stays pinned to one side of zero
27/// during a trend and oscillates through zero in a range, so it doubles as a
28/// trend/range gauge. The adaptive mean-square normaliser keeps the output near a
29/// `±3` band on any instrument.
30///
31/// The first value lands after `period + 1` SuperSmoothed samples. Each `update`
32/// is O(`period`).
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{Indicator, Trendflex};
38///
39/// let mut indicator = Trendflex::new(20).unwrap();
40/// let mut last = None;
41/// for i in 0..120 {
42///     last = indicator.update(100.0 + f64::from(i));
43/// }
44/// assert!(last.is_some());
45/// ```
46#[derive(Debug, Clone)]
47pub struct Trendflex {
48    period: usize,
49    smoother: SuperSmoother,
50    filt: VecDeque<f64>,
51    ms: f64,
52    last: Option<f64>,
53}
54
55impl Trendflex {
56    /// Construct a Trendflex with the given lookback `period`.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`Error::PeriodZero`] if `period == 0`.
61    pub fn new(period: usize) -> Result<Self> {
62        if period == 0 {
63            return Err(Error::PeriodZero);
64        }
65        if period > crate::error::MAX_PERIOD {
66            return Err(Error::InvalidPeriod {
67                message: crate::error::PERIOD_ABOVE_MAX,
68            });
69        }
70        Ok(Self {
71            period,
72            smoother: SuperSmoother::new(period)?,
73            filt: VecDeque::with_capacity(period + 1),
74            ms: 0.0,
75            last: None,
76        })
77    }
78
79    /// Configured lookback period.
80    pub const fn period(&self) -> usize {
81        self.period
82    }
83
84    /// Current value if available.
85    pub const fn value(&self) -> Option<f64> {
86        self.last
87    }
88}
89
90impl Indicator for Trendflex {
91    type Input = f64;
92    type Output = f64;
93
94    #[inline]
95    fn update(&mut self, price: f64) -> Option<f64> {
96        if !price.is_finite() {
97            return None;
98        }
99        let filt = self.smoother.update(price)?;
100        if self.filt.len() == self.period + 1 {
101            self.filt.pop_front();
102        }
103        self.filt.push_back(filt);
104        if self.filt.len() < self.period + 1 {
105            return None;
106        }
107        let newest = self.filt[self.period];
108        let mut sum = 0.0;
109        for i in 1..=self.period {
110            sum += newest - self.filt[self.period - i];
111        }
112        sum /= self.period as f64;
113        self.ms = 0.04 * sum * sum + 0.96 * self.ms;
114        let trendflex = if self.ms > 0.0 {
115            sum / self.ms.sqrt()
116        } else {
117            0.0
118        };
119        self.last = Some(trendflex);
120        Some(trendflex)
121    }
122
123    fn reset(&mut self) {
124        self.smoother.reset();
125        self.filt.clear();
126        self.ms = 0.0;
127        self.last = None;
128    }
129
130    #[inline]
131    fn warmup_period(&self) -> usize {
132        self.period + 1
133    }
134
135    #[inline]
136    fn is_ready(&self) -> bool {
137        self.last.is_some()
138    }
139
140    #[inline]
141    fn name(&self) -> &'static str {
142        "Trendflex"
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 rejects_zero_period() {
154        assert!(matches!(Trendflex::new(0), Err(Error::PeriodZero)));
155    }
156
157    #[test]
158    fn accessors_and_metadata() {
159        let t = Trendflex::new(20).unwrap();
160        assert_eq!(t.period(), 20);
161        assert_eq!(t.warmup_period(), 21);
162        assert_eq!(t.name(), "Trendflex");
163        assert!(!t.is_ready());
164        assert_eq!(t.value(), None);
165    }
166
167    #[test]
168    fn first_emission_at_warmup_period() {
169        let mut t = Trendflex::new(5).unwrap();
170        let xs: Vec<f64> = (0..12).map(f64::from).collect();
171        let out = t.batch(&xs);
172        for v in out.iter().take(5) {
173            assert!(v.is_none());
174        }
175        assert!(out[5].is_some());
176    }
177
178    #[test]
179    fn constant_input_is_zero() {
180        let mut t = Trendflex::new(10).unwrap();
181        for v in t.batch(&[50.0; 100]).into_iter().flatten() {
182            assert_relative_eq!(v, 0.0, epsilon = 1e-9);
183        }
184    }
185
186    #[test]
187    fn uptrend_is_positive() {
188        // A steady rise keeps the current filtered value above its past values.
189        let mut t = Trendflex::new(10).unwrap();
190        let out: Vec<f64> = t
191            .batch(&(0..200).map(f64::from).collect::<Vec<_>>())
192            .into_iter()
193            .flatten()
194            .skip(100)
195            .collect();
196        for v in out {
197            assert!(v > 0.0, "uptrend should be positive, got {v}");
198        }
199    }
200
201    #[test]
202    fn downtrend_is_negative() {
203        let mut t = Trendflex::new(10).unwrap();
204        let out: Vec<f64> = t
205            .batch(&(0..200).map(|i| 200.0 - f64::from(i)).collect::<Vec<_>>())
206            .into_iter()
207            .flatten()
208            .skip(100)
209            .collect();
210        for v in out {
211            assert!(v < 0.0, "downtrend should be negative, got {v}");
212        }
213    }
214
215    #[test]
216    fn ignores_non_finite() {
217        let mut t = Trendflex::new(10).unwrap();
218        t.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
219        let before = t.value();
220        assert_eq!(t.update(f64::NAN), None);
221        // The rejected input must not have disturbed the state.
222        assert_eq!(t.value(), before);
223    }
224
225    #[test]
226    fn reset_clears_state() {
227        let mut t = Trendflex::new(10).unwrap();
228        t.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
229        assert!(t.is_ready());
230        t.reset();
231        assert!(!t.is_ready());
232        assert_eq!(t.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 = Trendflex::new(20).unwrap().batch(&xs);
241        let mut b = Trendflex::new(20).unwrap();
242        let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
243        assert_eq!(batch, streamed);
244    }
245}