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 None;
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 #[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 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 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}