Skip to main content

wickra_core/indicators/
rsx.rs

1//! RSX — Jurik-style smoothed RSI.
2
3use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6/// RSX — a noise-free RSI built from Jurik's three-stage smoothing cascade.
7///
8/// Where Wilder's [`Rsi`](crate::Rsi) smooths the up/down moves with a single
9/// EMA, the RSX runs the signed price change *and* its absolute value through
10/// three cascaded "double-EMA with overshoot" stages (each stage is
11/// `x = 1.5·a − 0.5·b`, the same lag-cancelling trick as a DEMA), then forms the
12/// RSI-style ratio from the two smoothed streams:
13///
14/// ```text
15/// f18 = 3 / (length + 2),  f20 = 1 - f18
16/// each stage: a = f20·a + f18·in;  b = f18·a + f20·b;  out = 1.5·a − 0.5·b
17/// v14 = stage3(signed change),  v1C = stage3(|change|)
18/// RSX = clamp((v14 / v1C + 1) · 50, 0, 100)        (50 when v1C == 0)
19/// ```
20///
21/// The result is an oscillator in `[0, 100]` that tracks the RSI but is far
22/// smoother for the same responsiveness — it has very little of the RSI's
23/// bar-to-bar jitter, so threshold crosses and divergences are cleaner. A flat
24/// market returns the neutral `50`.
25///
26/// # Example
27///
28/// ```
29/// use wickra_core::{Indicator, Rsx};
30///
31/// let mut indicator = Rsx::new(14).unwrap();
32/// let mut last = None;
33/// for i in 0..80 {
34///     last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
35/// }
36/// assert!(last.is_some());
37/// ```
38#[derive(Debug, Clone)]
39pub struct Rsx {
40    length: usize,
41    f18: f64,
42    f20: f64,
43    prev: Option<f64>,
44    count: usize,
45    // Signed-change cascade (three stages: a/b pairs).
46    s_a0: f64,
47    s_b0: f64,
48    s_a1: f64,
49    s_b1: f64,
50    s_a2: f64,
51    s_b2: f64,
52    // Absolute-change cascade.
53    a_a0: f64,
54    a_b0: f64,
55    a_a1: f64,
56    a_b1: f64,
57    a_a2: f64,
58    a_b2: f64,
59    last_value: Option<f64>,
60}
61
62impl Rsx {
63    /// Construct an RSX with the given smoothing length.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`Error::PeriodZero`] if `length == 0`.
68    pub fn new(length: usize) -> Result<Self> {
69        if length == 0 {
70            return Err(Error::PeriodZero);
71        }
72        let f18 = 3.0 / (length as f64 + 2.0);
73        Ok(Self {
74            length,
75            f18,
76            f20: 1.0 - f18,
77            prev: None,
78            count: 0,
79            s_a0: 0.0,
80            s_b0: 0.0,
81            s_a1: 0.0,
82            s_b1: 0.0,
83            s_a2: 0.0,
84            s_b2: 0.0,
85            a_a0: 0.0,
86            a_b0: 0.0,
87            a_a1: 0.0,
88            a_b1: 0.0,
89            a_a2: 0.0,
90            a_b2: 0.0,
91            last_value: None,
92        })
93    }
94
95    /// Configured length.
96    pub const fn length(&self) -> usize {
97        self.length
98    }
99
100    /// Current value if available.
101    pub const fn value(&self) -> Option<f64> {
102        self.last_value
103    }
104
105    /// One double-EMA-with-overshoot stage: updates the `(a, b)` pair in place
106    /// and returns `1.5·a − 0.5·b`.
107    fn stage(&self, a: &mut f64, b: &mut f64, input: f64) -> f64 {
108        *a = self.f20 * *a + self.f18 * input;
109        *b = self.f18 * *a + self.f20 * *b;
110        1.5 * *a - 0.5 * *b
111    }
112}
113
114impl Indicator for Rsx {
115    type Input = f64;
116    type Output = f64;
117
118    fn update(&mut self, price: f64) -> Option<f64> {
119        if !price.is_finite() {
120            return self.last_value;
121        }
122        let Some(prev) = self.prev else {
123            self.prev = Some(price);
124            return None;
125        };
126        self.prev = Some(price);
127
128        let change = price - prev;
129
130        // Signed-change cascade.
131        let (mut sa0, mut sb0) = (self.s_a0, self.s_b0);
132        let v_c = self.stage(&mut sa0, &mut sb0, change);
133        self.s_a0 = sa0;
134        self.s_b0 = sb0;
135        let (mut sa1, mut sb1) = (self.s_a1, self.s_b1);
136        let v_10 = self.stage(&mut sa1, &mut sb1, v_c);
137        self.s_a1 = sa1;
138        self.s_b1 = sb1;
139        let (mut sa2, mut sb2) = (self.s_a2, self.s_b2);
140        let v_14 = self.stage(&mut sa2, &mut sb2, v_10);
141        self.s_a2 = sa2;
142        self.s_b2 = sb2;
143
144        // Absolute-change cascade.
145        let abs = change.abs();
146        let (mut aa0, mut ab0) = (self.a_a0, self.a_b0);
147        let v_c1 = self.stage(&mut aa0, &mut ab0, abs);
148        self.a_a0 = aa0;
149        self.a_b0 = ab0;
150        let (mut aa1, mut ab1) = (self.a_a1, self.a_b1);
151        let v_18 = self.stage(&mut aa1, &mut ab1, v_c1);
152        self.a_a1 = aa1;
153        self.a_b1 = ab1;
154        let (mut aa2, mut ab2) = (self.a_a2, self.a_b2);
155        let v_1c = self.stage(&mut aa2, &mut ab2, v_18);
156        self.a_a2 = aa2;
157        self.a_b2 = ab2;
158
159        let v4 = if v_1c > 0.0 {
160            (v_14 / v_1c + 1.0) * 50.0
161        } else {
162            50.0
163        };
164        let rsx = v4.clamp(0.0, 100.0);
165
166        self.count += 1;
167        self.last_value = Some(rsx);
168        if self.count >= self.length {
169            Some(rsx)
170        } else {
171            None
172        }
173    }
174
175    fn reset(&mut self) {
176        *self = Self::new(self.length).expect("length already validated");
177    }
178
179    fn warmup_period(&self) -> usize {
180        // One input to seed `prev`, then `length` changes to settle the cascade.
181        self.length + 1
182    }
183
184    fn is_ready(&self) -> bool {
185        self.count >= self.length
186    }
187
188    fn name(&self) -> &'static str {
189        "RSX"
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::traits::BatchExt;
197    use approx::assert_relative_eq;
198
199    #[test]
200    fn rejects_zero_length() {
201        assert!(matches!(Rsx::new(0), Err(Error::PeriodZero)));
202    }
203
204    /// Cover the const accessors `length` + `value` and the Indicator-impl
205    /// `warmup_period` + `name`.
206    #[test]
207    fn accessors_and_metadata() {
208        let rsx = Rsx::new(14).unwrap();
209        assert_eq!(rsx.length(), 14);
210        assert_eq!(rsx.value(), None);
211        assert_eq!(rsx.warmup_period(), 15);
212        assert_eq!(rsx.name(), "RSX");
213    }
214
215    #[test]
216    fn warmup_then_emits() {
217        let mut rsx = Rsx::new(3).unwrap();
218        // 1 input seeds prev; then 3 changes settle -> first Some on input 4.
219        assert_eq!(rsx.update(10.0), None);
220        assert_eq!(rsx.update(11.0), None);
221        assert_eq!(rsx.update(12.0), None);
222        assert!(rsx.update(13.0).is_some());
223    }
224
225    #[test]
226    fn flat_market_is_neutral() {
227        // No movement -> absolute cascade is zero -> neutral 50.
228        let mut rsx = Rsx::new(5).unwrap();
229        let last = rsx.batch(&[7.0; 40]).into_iter().flatten().last().unwrap();
230        assert_relative_eq!(last, 50.0, epsilon = 1e-12);
231    }
232
233    #[test]
234    fn output_stays_in_range() {
235        let prices: Vec<f64> = (0..120)
236            .map(|i| 100.0 + (f64::from(i) * 0.35).sin() * 12.0)
237            .collect();
238        let mut rsx = Rsx::new(14).unwrap();
239        for v in rsx.batch(&prices).into_iter().flatten() {
240            assert!((0.0..=100.0).contains(&v), "RSX {v} left [0, 100]");
241        }
242    }
243
244    #[test]
245    fn strong_uptrend_is_high() {
246        // A sustained rise drives RSX well above the neutral 50.
247        let prices: Vec<f64> = (1..=60).map(f64::from).collect();
248        let mut rsx = Rsx::new(14).unwrap();
249        let last = rsx.batch(&prices).into_iter().flatten().last().unwrap();
250        assert!(
251            last > 80.0,
252            "strong uptrend should push RSX high, got {last}"
253        );
254    }
255
256    #[test]
257    fn ignores_non_finite_input() {
258        let mut rsx = Rsx::new(3).unwrap();
259        let ready = rsx
260            .batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
261            .into_iter()
262            .flatten()
263            .last()
264            .unwrap();
265        assert_eq!(rsx.update(f64::NAN), Some(ready));
266        assert_eq!(rsx.update(f64::INFINITY), Some(ready));
267    }
268
269    #[test]
270    fn reset_clears_state() {
271        let mut rsx = Rsx::new(5).unwrap();
272        rsx.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
273        assert!(rsx.is_ready());
274        rsx.reset();
275        assert!(!rsx.is_ready());
276        assert_eq!(rsx.update(1.0), None);
277    }
278
279    #[test]
280    fn batch_equals_streaming() {
281        let prices: Vec<f64> = (1..=60)
282            .map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
283            .collect();
284        let mut a = Rsx::new(14).unwrap();
285        let mut b = Rsx::new(14).unwrap();
286        assert_eq!(
287            a.batch(&prices),
288            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
289        );
290    }
291}