wickra_core/indicators/
laguerre_rsi.rs1use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6#[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 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 pub fn classic() -> Self {
78 Self::new(0.5).expect("classic LaguerreRSI gamma is valid")
79 }
80
81 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 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 (100.0 * cu / total).clamp(0.0, 100.0)
133 } else {
134 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 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 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}