Skip to main content

wickra_core/indicators/
laguerre_rsi.rs

1//! Ehlers' Laguerre RSI.
2
3use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6/// John Ehlers' Laguerre RSI — a four-stage Laguerre polynomial filter wrapped
7/// in an `RSI`-style up/down accumulator. The single tuning parameter `gamma`
8/// in `[0, 1]` trades lag for smoothness: small `gamma` is fast and noisy,
9/// large `gamma` is slow and smooth (Ehlers recommends `0.5`).
10///
11/// ```text
12/// alpha = 1 − gamma
13/// L0_t  = alpha · price_t + gamma · L0_{t-1}
14/// L1_t  = −gamma · L0_t   + L0_{t-1} + gamma · L1_{t-1}
15/// L2_t  = −gamma · L1_t   + L1_{t-1} + gamma · L2_{t-1}
16/// L3_t  = −gamma · L2_t   + L2_{t-1} + gamma · L3_{t-1}
17///
18/// cu, cd = 0
19/// for each pair (L0, L1), (L1, L2), (L2, L3):
20///     if upper ≥ lower: cu += upper − lower
21///     else            : cd += lower − upper
22///
23/// LRSI = 100 · cu / (cu + cd)
24/// ```
25///
26/// The output is bounded in `[0, 100]`. State is seeded by setting all four
27/// `L_i` to the first input, so the first emission lands on input #1.
28///
29/// Reference: John F. Ehlers, *Time Warp — Without Space Travel*, 2002.
30///
31/// # Example
32///
33/// ```
34/// use wickra_core::{Indicator, LaguerreRsi};
35///
36/// let mut lrsi = LaguerreRsi::new(0.5).unwrap();
37/// let mut last = None;
38/// for i in 0..40 {
39///     last = lrsi.update(100.0 + f64::from(i));
40/// }
41/// assert!(last.is_some());
42/// ```
43#[derive(Debug, Clone)]
44pub struct LaguerreRsi {
45    gamma: f64,
46    alpha: f64,
47    l0: f64,
48    l1: f64,
49    l2: f64,
50    l3: f64,
51    seeded: bool,
52    current: Option<f64>,
53}
54
55impl LaguerreRsi {
56    /// # Errors
57    /// Returns [`Error::InvalidPeriod`] if `gamma` is non-finite or outside `[0, 1]`.
58    pub fn new(gamma: f64) -> Result<Self> {
59        if !gamma.is_finite() || !(0.0..=1.0).contains(&gamma) {
60            return Err(Error::InvalidPeriod {
61                message: "LaguerreRSI gamma must be a finite value in [0, 1]",
62            });
63        }
64        Ok(Self {
65            gamma,
66            alpha: 1.0 - gamma,
67            l0: 0.0,
68            l1: 0.0,
69            l2: 0.0,
70            l3: 0.0,
71            seeded: false,
72            current: None,
73        })
74    }
75
76    /// Ehlers' recommended `gamma = 0.5`.
77    pub fn classic() -> Self {
78        Self::new(0.5).expect("classic LaguerreRSI gamma is valid")
79    }
80
81    /// Configured `gamma`.
82    pub const fn gamma(&self) -> f64 {
83        self.gamma
84    }
85}
86
87impl Indicator for LaguerreRsi {
88    type Input = f64;
89    type Output = f64;
90
91    fn update(&mut self, input: f64) -> Option<f64> {
92        if !input.is_finite() {
93            return None;
94        }
95        if !self.seeded {
96            // Seed all four polynomial stages with the first input so a
97            // constant series produces zero up/down accumulators (which we
98            // map to 50.0 below — the canonical neutral mid-band reading).
99            self.l0 = input;
100            self.l1 = input;
101            self.l2 = input;
102            self.l3 = input;
103            self.seeded = true;
104            self.current = Some(50.0);
105            return self.current;
106        }
107        let (l0_prev, l1_prev, l2_prev) = (self.l0, self.l1, self.l2);
108        let l0_new = self.alpha * input + self.gamma * l0_prev;
109        let l1_new = -self.gamma * l0_new + l0_prev + self.gamma * self.l1;
110        let l2_new = -self.gamma * l1_new + l1_prev + self.gamma * self.l2;
111        let l3_new = -self.gamma * l2_new + l2_prev + self.gamma * self.l3;
112        self.l0 = l0_new;
113        self.l1 = l1_new;
114        self.l2 = l2_new;
115        self.l3 = l3_new;
116
117        let mut cu = 0.0;
118        let mut cd = 0.0;
119        let pairs = [(l0_new, l1_new), (l1_new, l2_new), (l2_new, l3_new)];
120        for (upper, lower) in pairs {
121            if upper >= lower {
122                cu += upper - lower;
123            } else {
124                cd += lower - upper;
125            }
126        }
127        let total = cu + cd;
128        let value = if total > 0.0 {
129            // Floating-point rounding can push `cu / total` a hair above 1.0;
130            // clamp to the algebraic bound to keep the output strictly inside
131            // [0, 100].
132            (100.0 * cu / total).clamp(0.0, 100.0)
133        } else {
134            // No up- or down-displacements between stages: stay at the
135            // neutral mid-band rather than report 0 / 0.
136            50.0
137        };
138        self.current = Some(value);
139        Some(value)
140    }
141
142    fn reset(&mut self) {
143        self.l0 = 0.0;
144        self.l1 = 0.0;
145        self.l2 = 0.0;
146        self.l3 = 0.0;
147        self.seeded = false;
148        self.current = None;
149    }
150
151    #[inline]
152    fn warmup_period(&self) -> usize {
153        1
154    }
155
156    #[inline]
157    fn is_ready(&self) -> bool {
158        self.current.is_some()
159    }
160
161    #[inline]
162    fn name(&self) -> &'static str {
163        "LaguerreRSI"
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::traits::BatchExt;
171    use approx::assert_relative_eq;
172
173    #[test]
174    fn rejects_invalid_gamma() {
175        assert!(matches!(
176            LaguerreRsi::new(-0.1),
177            Err(Error::InvalidPeriod { .. })
178        ));
179        assert!(matches!(
180            LaguerreRsi::new(1.1),
181            Err(Error::InvalidPeriod { .. })
182        ));
183        assert!(matches!(
184            LaguerreRsi::new(f64::NAN),
185            Err(Error::InvalidPeriod { .. })
186        ));
187    }
188
189    #[test]
190    fn accessors_and_metadata() {
191        let lrsi = LaguerreRsi::new(0.5).unwrap();
192        assert_eq!(lrsi.gamma(), 0.5);
193        assert_eq!(lrsi.warmup_period(), 1);
194        assert_eq!(lrsi.name(), "LaguerreRSI");
195    }
196
197    #[test]
198    fn classic_factory() {
199        assert_eq!(LaguerreRsi::classic().gamma(), 0.5);
200    }
201
202    #[test]
203    fn constant_series_stays_at_mid_band() {
204        // All four L_i seed to the constant; on subsequent flat inputs they
205        // stay equal, so cu = cd = 0 and LRSI reports the neutral 50.
206        let mut lrsi = LaguerreRsi::classic();
207        let out = lrsi.batch(&[42.0_f64; 60]);
208        for v in out.iter().flatten() {
209            assert_relative_eq!(*v, 50.0, epsilon = 1e-12);
210        }
211    }
212
213    #[test]
214    fn output_is_bounded() {
215        let mut lrsi = LaguerreRsi::classic();
216        let prices: Vec<f64> = (0..200)
217            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 25.0)
218            .collect();
219        for v in lrsi.batch(&prices).iter().flatten() {
220            assert!(*v >= 0.0 && *v <= 100.0, "out of range: {v}");
221        }
222    }
223
224    #[test]
225    fn pure_uptrend_saturates_high() {
226        let mut lrsi = LaguerreRsi::classic();
227        for i in 0..200 {
228            lrsi.update(100.0 + f64::from(i));
229        }
230        let v = lrsi.current.unwrap();
231        assert!(v > 80.0, "uptrend should drive LRSI well above 50: {v}");
232    }
233
234    #[test]
235    fn pure_downtrend_saturates_low() {
236        let mut lrsi = LaguerreRsi::classic();
237        for i in 0..200 {
238            lrsi.update(300.0 - f64::from(i));
239        }
240        let v = lrsi.current.unwrap();
241        assert!(v < 20.0, "downtrend should drive LRSI well below 50: {v}");
242    }
243
244    #[test]
245    fn batch_equals_streaming() {
246        let prices: Vec<f64> = (1..=120)
247            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
248            .collect();
249        let mut a = LaguerreRsi::classic();
250        let mut b = LaguerreRsi::classic();
251        assert_eq!(
252            a.batch(&prices),
253            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
254        );
255    }
256
257    #[test]
258    fn reset_clears_state() {
259        let mut lrsi = LaguerreRsi::classic();
260        lrsi.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
261        assert!(lrsi.is_ready());
262        lrsi.reset();
263        assert!(!lrsi.is_ready());
264        assert!(!lrsi.seeded);
265    }
266
267    #[test]
268    fn ignores_non_finite_input() {
269        let mut lrsi = LaguerreRsi::classic();
270        lrsi.update(10.0).unwrap();
271        assert_eq!(lrsi.update(f64::NAN), None);
272        assert_eq!(lrsi.update(f64::INFINITY), None);
273    }
274
275    #[test]
276    fn gamma_zero_passes_through_l0() {
277        // gamma = 0 -> alpha = 1, so L0 mirrors the input exactly each step.
278        // The polynomial chain then lags by one stage; the up/down accumulator
279        // still produces a bounded reading and the first non-seed step shifts
280        // off 50 as soon as input changes.
281        let mut lrsi = LaguerreRsi::new(0.0).unwrap();
282        assert_eq!(lrsi.update(10.0), Some(50.0));
283        let v = lrsi.update(11.0).unwrap();
284        assert!((0.0..=100.0).contains(&v));
285    }
286}