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        if length > crate::error::MAX_PERIOD {
73            return Err(Error::InvalidPeriod {
74                message: crate::error::PERIOD_ABOVE_MAX,
75            });
76        }
77        let f18 = 3.0 / (length as f64 + 2.0);
78        Ok(Self {
79            length,
80            f18,
81            f20: 1.0 - f18,
82            prev: None,
83            count: 0,
84            s_a0: 0.0,
85            s_b0: 0.0,
86            s_a1: 0.0,
87            s_b1: 0.0,
88            s_a2: 0.0,
89            s_b2: 0.0,
90            a_a0: 0.0,
91            a_b0: 0.0,
92            a_a1: 0.0,
93            a_b1: 0.0,
94            a_a2: 0.0,
95            a_b2: 0.0,
96            last_value: None,
97        })
98    }
99
100    /// Configured length.
101    pub const fn length(&self) -> usize {
102        self.length
103    }
104
105    /// Current value if available.
106    pub const fn value(&self) -> Option<f64> {
107        self.last_value
108    }
109
110    /// One double-EMA-with-overshoot stage: updates the `(a, b)` pair in place
111    /// and returns `1.5·a − 0.5·b`.
112    fn stage(&self, a: &mut f64, b: &mut f64, input: f64) -> f64 {
113        *a = self.f20 * *a + self.f18 * input;
114        *b = self.f18 * *a + self.f20 * *b;
115        1.5 * *a - 0.5 * *b
116    }
117}
118
119impl Indicator for Rsx {
120    type Input = f64;
121    type Output = f64;
122
123    fn update(&mut self, price: f64) -> Option<f64> {
124        if !price.is_finite() {
125            return None;
126        }
127        let Some(prev) = self.prev else {
128            self.prev = Some(price);
129            return None;
130        };
131        self.prev = Some(price);
132
133        let change = price - prev;
134
135        // Signed-change cascade.
136        let (mut sa0, mut sb0) = (self.s_a0, self.s_b0);
137        let v_c = self.stage(&mut sa0, &mut sb0, change);
138        self.s_a0 = sa0;
139        self.s_b0 = sb0;
140        let (mut sa1, mut sb1) = (self.s_a1, self.s_b1);
141        let v_10 = self.stage(&mut sa1, &mut sb1, v_c);
142        self.s_a1 = sa1;
143        self.s_b1 = sb1;
144        let (mut sa2, mut sb2) = (self.s_a2, self.s_b2);
145        let v_14 = self.stage(&mut sa2, &mut sb2, v_10);
146        self.s_a2 = sa2;
147        self.s_b2 = sb2;
148
149        // Absolute-change cascade.
150        let abs = change.abs();
151        let (mut aa0, mut ab0) = (self.a_a0, self.a_b0);
152        let v_c1 = self.stage(&mut aa0, &mut ab0, abs);
153        self.a_a0 = aa0;
154        self.a_b0 = ab0;
155        let (mut aa1, mut ab1) = (self.a_a1, self.a_b1);
156        let v_18 = self.stage(&mut aa1, &mut ab1, v_c1);
157        self.a_a1 = aa1;
158        self.a_b1 = ab1;
159        let (mut aa2, mut ab2) = (self.a_a2, self.a_b2);
160        let v_1c = self.stage(&mut aa2, &mut ab2, v_18);
161        self.a_a2 = aa2;
162        self.a_b2 = ab2;
163
164        let v4 = if v_1c > 0.0 {
165            (v_14 / v_1c + 1.0) * 50.0
166        } else {
167            50.0
168        };
169        let rsx = v4.clamp(0.0, 100.0);
170
171        self.count += 1;
172        self.last_value = Some(rsx);
173        if self.count >= self.length {
174            Some(rsx)
175        } else {
176            None
177        }
178    }
179
180    fn reset(&mut self) {
181        *self = Self::new(self.length).expect("length already validated");
182    }
183
184    #[inline]
185    fn warmup_period(&self) -> usize {
186        // One input to seed `prev`, then `length` changes to settle the cascade.
187        self.length + 1
188    }
189
190    #[inline]
191    fn is_ready(&self) -> bool {
192        self.count >= self.length
193    }
194
195    #[inline]
196    fn name(&self) -> &'static str {
197        "RSX"
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::traits::BatchExt;
205    use approx::assert_relative_eq;
206
207    #[test]
208    fn rejects_zero_length() {
209        assert!(matches!(Rsx::new(0), Err(Error::PeriodZero)));
210    }
211
212    /// Cover the const accessors `length` + `value` and the Indicator-impl
213    /// `warmup_period` + `name`.
214    #[test]
215    fn accessors_and_metadata() {
216        let rsx = Rsx::new(14).unwrap();
217        assert_eq!(rsx.length(), 14);
218        assert_eq!(rsx.value(), None);
219        assert_eq!(rsx.warmup_period(), 15);
220        assert_eq!(rsx.name(), "RSX");
221    }
222
223    #[test]
224    fn warmup_then_emits() {
225        let mut rsx = Rsx::new(3).unwrap();
226        // 1 input seeds prev; then 3 changes settle -> first Some on input 4.
227        assert_eq!(rsx.update(10.0), None);
228        assert_eq!(rsx.update(11.0), None);
229        assert_eq!(rsx.update(12.0), None);
230        assert!(rsx.update(13.0).is_some());
231    }
232
233    #[test]
234    fn flat_market_is_neutral() {
235        // No movement -> absolute cascade is zero -> neutral 50.
236        let mut rsx = Rsx::new(5).unwrap();
237        let last = rsx.batch(&[7.0; 40]).into_iter().flatten().last().unwrap();
238        assert_relative_eq!(last, 50.0, epsilon = 1e-12);
239    }
240
241    #[test]
242    fn output_stays_in_range() {
243        let prices: Vec<f64> = (0..120)
244            .map(|i| 100.0 + (f64::from(i) * 0.35).sin() * 12.0)
245            .collect();
246        let mut rsx = Rsx::new(14).unwrap();
247        for v in rsx.batch(&prices).into_iter().flatten() {
248            assert!((0.0..=100.0).contains(&v), "RSX {v} left [0, 100]");
249        }
250    }
251
252    #[test]
253    fn strong_uptrend_is_high() {
254        // A sustained rise drives RSX well above the neutral 50.
255        let prices: Vec<f64> = (1..=60).map(f64::from).collect();
256        let mut rsx = Rsx::new(14).unwrap();
257        let last = rsx.batch(&prices).into_iter().flatten().last().unwrap();
258        assert!(
259            last > 80.0,
260            "strong uptrend should push RSX high, got {last}"
261        );
262    }
263
264    #[test]
265    fn ignores_non_finite_input() {
266        let mut rsx = Rsx::new(3).unwrap();
267        let _ready = rsx
268            .batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
269            .into_iter()
270            .flatten()
271            .last()
272            .unwrap();
273        assert_eq!(rsx.update(f64::NAN), None);
274        assert_eq!(rsx.update(f64::INFINITY), None);
275    }
276
277    #[test]
278    fn reset_clears_state() {
279        let mut rsx = Rsx::new(5).unwrap();
280        rsx.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
281        assert!(rsx.is_ready());
282        rsx.reset();
283        assert!(!rsx.is_ready());
284        assert_eq!(rsx.update(1.0), None);
285    }
286
287    #[test]
288    fn batch_equals_streaming() {
289        let prices: Vec<f64> = (1..=60)
290            .map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
291            .collect();
292        let mut a = Rsx::new(14).unwrap();
293        let mut b = Rsx::new(14).unwrap();
294        assert_eq!(
295            a.batch(&prices),
296            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
297        );
298    }
299}