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 self.current;
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    fn warmup_period(&self) -> usize {
152        1
153    }
154
155    fn is_ready(&self) -> bool {
156        self.current.is_some()
157    }
158
159    fn name(&self) -> &'static str {
160        "LaguerreRSI"
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::traits::BatchExt;
168    use approx::assert_relative_eq;
169
170    #[test]
171    fn rejects_invalid_gamma() {
172        assert!(matches!(
173            LaguerreRsi::new(-0.1),
174            Err(Error::InvalidPeriod { .. })
175        ));
176        assert!(matches!(
177            LaguerreRsi::new(1.1),
178            Err(Error::InvalidPeriod { .. })
179        ));
180        assert!(matches!(
181            LaguerreRsi::new(f64::NAN),
182            Err(Error::InvalidPeriod { .. })
183        ));
184    }
185
186    #[test]
187    fn accessors_and_metadata() {
188        let lrsi = LaguerreRsi::new(0.5).unwrap();
189        assert_eq!(lrsi.gamma(), 0.5);
190        assert_eq!(lrsi.warmup_period(), 1);
191        assert_eq!(lrsi.name(), "LaguerreRSI");
192    }
193
194    #[test]
195    fn classic_factory() {
196        assert_eq!(LaguerreRsi::classic().gamma(), 0.5);
197    }
198
199    #[test]
200    fn constant_series_stays_at_mid_band() {
201        // All four L_i seed to the constant; on subsequent flat inputs they
202        // stay equal, so cu = cd = 0 and LRSI reports the neutral 50.
203        let mut lrsi = LaguerreRsi::classic();
204        let out = lrsi.batch(&[42.0_f64; 60]);
205        for v in out.iter().flatten() {
206            assert_relative_eq!(*v, 50.0, epsilon = 1e-12);
207        }
208    }
209
210    #[test]
211    fn output_is_bounded() {
212        let mut lrsi = LaguerreRsi::classic();
213        let prices: Vec<f64> = (0..200)
214            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 25.0)
215            .collect();
216        for v in lrsi.batch(&prices).iter().flatten() {
217            assert!(*v >= 0.0 && *v <= 100.0, "out of range: {v}");
218        }
219    }
220
221    #[test]
222    fn pure_uptrend_saturates_high() {
223        let mut lrsi = LaguerreRsi::classic();
224        for i in 0..200 {
225            lrsi.update(100.0 + f64::from(i));
226        }
227        let v = lrsi.current.unwrap();
228        assert!(v > 80.0, "uptrend should drive LRSI well above 50: {v}");
229    }
230
231    #[test]
232    fn pure_downtrend_saturates_low() {
233        let mut lrsi = LaguerreRsi::classic();
234        for i in 0..200 {
235            lrsi.update(300.0 - f64::from(i));
236        }
237        let v = lrsi.current.unwrap();
238        assert!(v < 20.0, "downtrend should drive LRSI well below 50: {v}");
239    }
240
241    #[test]
242    fn batch_equals_streaming() {
243        let prices: Vec<f64> = (1..=120)
244            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
245            .collect();
246        let mut a = LaguerreRsi::classic();
247        let mut b = LaguerreRsi::classic();
248        assert_eq!(
249            a.batch(&prices),
250            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
251        );
252    }
253
254    #[test]
255    fn reset_clears_state() {
256        let mut lrsi = LaguerreRsi::classic();
257        lrsi.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
258        assert!(lrsi.is_ready());
259        lrsi.reset();
260        assert!(!lrsi.is_ready());
261        assert!(!lrsi.seeded);
262    }
263
264    #[test]
265    fn ignores_non_finite_input() {
266        let mut lrsi = LaguerreRsi::classic();
267        let before = lrsi.update(10.0).unwrap();
268        assert_eq!(lrsi.update(f64::NAN), Some(before));
269        assert_eq!(lrsi.update(f64::INFINITY), Some(before));
270    }
271
272    #[test]
273    fn gamma_zero_passes_through_l0() {
274        // gamma = 0 -> alpha = 1, so L0 mirrors the input exactly each step.
275        // The polynomial chain then lags by one stage; the up/down accumulator
276        // still produces a bounded reading and the first non-seed step shifts
277        // off 50 as soon as input changes.
278        let mut lrsi = LaguerreRsi::new(0.0).unwrap();
279        assert_eq!(lrsi.update(10.0), Some(50.0));
280        let v = lrsi.update(11.0).unwrap();
281        assert!((0.0..=100.0).contains(&v));
282    }
283}