Skip to main content

wickra_core/indicators/
rsi.rs

1//! Relative Strength Index using Wilder's smoothing.
2
3use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6/// Relative Strength Index (Wilder, 1978).
7///
8/// Uses Wilder's smoothing (an EMA with `alpha = 1 / period`). The first output
9/// is produced after `period + 1` inputs: the seed averages the first `period`
10/// gains and losses, and the first emitted RSI corresponds to the input at
11/// index `period`.
12///
13/// # Example
14///
15/// ```
16/// use wickra_core::{Indicator, Rsi};
17///
18/// let mut indicator = Rsi::new(3).unwrap();
19/// let mut last = None;
20/// for i in 0..80 {
21///     last = indicator.update(100.0 + f64::from(i));
22/// }
23/// assert!(last.is_some());
24/// ```
25#[derive(Debug, Clone)]
26pub struct Rsi {
27    period: usize,
28    /// `period - 1` as `f64`, precomputed for the Wilder smoothing step.
29    n_minus_1: f64,
30    /// `1 / period`, precomputed so the per-tick smoothing multiplies instead of
31    /// divides (a reciprocal is hoisted out of the hot path).
32    inv_period: f64,
33    /// Previous close, valid once `has_prev` is set. Bare `f64` + flag instead of
34    /// `Option<f64>` to avoid an enum-tag read on every tick.
35    prev_close: f64,
36    has_prev: bool,
37    // Wilder seeds with the simple average of the first `period` gains/losses,
38    // then transitions to recursive smoothing.
39    seed_buf_gains: Vec<f64>,
40    seed_buf_losses: Vec<f64>,
41    /// Smoothed average gain / loss, valid once `avgs_seeded` is set. Bare `f64`s
42    /// + flag so the hot recurrence avoids reading two `Option<f64>` tags per tick.
43    avg_gain: f64,
44    avg_loss: f64,
45    avgs_seeded: bool,
46    last_value: Option<f64>,
47}
48
49impl Rsi {
50    /// Construct an RSI with the given Wilder period.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`Error::PeriodZero`] if `period == 0`.
55    pub fn new(period: usize) -> Result<Self> {
56        if period == 0 {
57            return Err(Error::PeriodZero);
58        }
59        if period > crate::error::MAX_PERIOD {
60            return Err(Error::InvalidPeriod {
61                message: crate::error::PERIOD_ABOVE_MAX,
62            });
63        }
64        Ok(Self {
65            period,
66            n_minus_1: (period - 1) as f64,
67            inv_period: 1.0 / period as f64,
68            prev_close: 0.0,
69            has_prev: false,
70            seed_buf_gains: Vec::with_capacity(period),
71            seed_buf_losses: Vec::with_capacity(period),
72            avg_gain: 0.0,
73            avg_loss: 0.0,
74            avgs_seeded: false,
75            last_value: None,
76        })
77    }
78
79    /// Configured period.
80    pub const fn period(&self) -> usize {
81        self.period
82    }
83
84    /// Current value if available.
85    pub const fn value(&self) -> Option<f64> {
86        self.last_value
87    }
88
89    /// Vectorized batch returning one `f64` per input (`NaN` during warmup).
90    ///
91    /// Shadows the generic [`BatchNanExt::batch_nan`](crate::BatchNanExt) blanket
92    /// default. RSI is a recursive (IIR) filter — Wilder smoothing — so it cannot
93    /// be SIMD-vectorized any more than the C peers manage; the win is purely in
94    /// stripping per-tick overhead. For a fresh indicator over an all-finite slice
95    /// long enough to seed (`n > period`) it runs the seed once and then the bare
96    /// smoothing recurrence in a tight loop with no per-tick `is_finite`/`has_prev`/
97    /// `avgs_seeded` branch and no `Option`, using the identical division at the
98    /// seed and `mul_add`/`rsi_from_avgs` afterwards — so it is *bit-for-bit* equal
99    /// to replaying `update`. Shorter or non-fresh/non-finite inputs defer to the
100    /// exact `update` replay.
101    pub fn batch_nan(&mut self, inputs: &[f64]) -> Vec<f64> {
102        let p = self.period;
103        let n = inputs.len();
104        if self.has_prev
105            || self.avgs_seeded
106            || !self.seed_buf_gains.is_empty()
107            || n <= p
108            || !inputs.iter().all(|x| x.is_finite())
109        {
110            return inputs
111                .iter()
112                .map(|&x| self.update(x).unwrap_or(f64::NAN))
113                .collect();
114        }
115
116        // Warmup `[0, p)` is `NaN`; outputs from index `p` on are pushed once each.
117        let mut out = vec![f64::NAN; p];
118        out.reserve(n - p);
119        // Seed from the first `period` diffs (inputs[1..=p]); index 0 only sets the
120        // baseline. Retain the seed gains/losses exactly as `update` leaves them.
121        let mut prev = inputs[0];
122        let (mut sum_gain, mut sum_loss) = (0.0_f64, 0.0_f64);
123        for &x in &inputs[1..=p] {
124            let diff = x - prev;
125            prev = x;
126            let gain = if diff > 0.0 { diff } else { 0.0 };
127            let loss = if diff < 0.0 { -diff } else { 0.0 };
128            self.seed_buf_gains.push(gain);
129            self.seed_buf_losses.push(loss);
130            sum_gain += gain;
131            sum_loss += loss;
132        }
133        let p_f64 = p as f64;
134        let mut ag = sum_gain / p_f64;
135        let mut al = sum_loss / p_f64;
136        out.push(Self::rsi_from_avgs(ag, al));
137
138        // Steady state: Wilder smoothing, reciprocal hoisted, one `rsi_from_avgs`.
139        for &x in &inputs[p + 1..] {
140            let diff = x - prev;
141            prev = x;
142            let gain = if diff > 0.0 { diff } else { 0.0 };
143            let loss = if diff < 0.0 { -diff } else { 0.0 };
144            ag = ag.mul_add(self.n_minus_1, gain) * self.inv_period;
145            al = al.mul_add(self.n_minus_1, loss) * self.inv_period;
146            out.push(Self::rsi_from_avgs(ag, al));
147        }
148
149        // Leave state where a full `update` replay would.
150        self.prev_close = prev;
151        self.has_prev = true;
152        self.avg_gain = ag;
153        self.avg_loss = al;
154        self.avgs_seeded = true;
155        self.last_value = Some(out[n - 1]);
156        out
157    }
158
159    fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
160        // Algebraically `100 - 100/(1 + ag/al)` collapses to `100·ag/(ag+al)`,
161        // which needs a single division instead of two and removes the separate
162        // `rs` step. Edge cases stay exact: `al == 0, ag > 0` gives `100·ag/ag =
163        // 100`; `ag == 0, al > 0` gives `0`; both zero (no movement) is the
164        // undefined case and returns the neutral 50.
165        let denom = avg_gain + avg_loss;
166        if denom == 0.0 {
167            50.0
168        } else {
169            100.0 * avg_gain / denom
170        }
171    }
172}
173
174impl Indicator for Rsi {
175    type Input = f64;
176    type Output = f64;
177
178    fn update(&mut self, input: f64) -> Option<f64> {
179        if !input.is_finite() {
180            return None;
181        }
182
183        if !self.has_prev {
184            self.prev_close = input;
185            self.has_prev = true;
186            return None;
187        }
188        let prev = self.prev_close;
189        self.prev_close = input;
190
191        let diff = input - prev;
192        let gain = if diff > 0.0 { diff } else { 0.0 };
193        let loss = if diff < 0.0 { -diff } else { 0.0 };
194
195        if self.avgs_seeded {
196            // Wilder smoothing `(prev·(n-1) + x) / n` with the reciprocal hoisted:
197            // a fused multiply-add then a multiply by `1/n`, no per-tick division.
198            let new_ag = self.avg_gain.mul_add(self.n_minus_1, gain) * self.inv_period;
199            let new_al = self.avg_loss.mul_add(self.n_minus_1, loss) * self.inv_period;
200            self.avg_gain = new_ag;
201            self.avg_loss = new_al;
202            let v = Self::rsi_from_avgs(new_ag, new_al);
203            self.last_value = Some(v);
204            return Some(v);
205        }
206
207        self.seed_buf_gains.push(gain);
208        self.seed_buf_losses.push(loss);
209        if self.seed_buf_gains.len() == self.period {
210            let ag = self.seed_buf_gains.iter().sum::<f64>() / self.period as f64;
211            let al = self.seed_buf_losses.iter().sum::<f64>() / self.period as f64;
212            self.avg_gain = ag;
213            self.avg_loss = al;
214            self.avgs_seeded = true;
215            let v = Self::rsi_from_avgs(ag, al);
216            self.last_value = Some(v);
217            return Some(v);
218        }
219        None
220    }
221
222    fn reset(&mut self) {
223        self.prev_close = 0.0;
224        self.has_prev = false;
225        self.seed_buf_gains.clear();
226        self.seed_buf_losses.clear();
227        self.avg_gain = 0.0;
228        self.avg_loss = 0.0;
229        self.avgs_seeded = false;
230        self.last_value = None;
231    }
232
233    #[inline]
234    fn warmup_period(&self) -> usize {
235        self.period + 1
236    }
237
238    #[inline]
239    fn is_ready(&self) -> bool {
240        self.last_value.is_some()
241    }
242
243    #[inline]
244    fn name(&self) -> &'static str {
245        "RSI"
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::traits::BatchExt;
253    use approx::assert_relative_eq;
254
255    /// Independent reference: Wilder RSI computed straight from the definition.
256    fn rsi_naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
257        let n = period as f64;
258        let mut out = vec![None; prices.len()];
259        let mut gains: Vec<f64> = Vec::new();
260        let mut losses: Vec<f64> = Vec::new();
261        let mut avg_gain: Option<f64> = None;
262        let mut avg_loss: Option<f64> = None;
263        let rsi_val = |ag: f64, al: f64| -> f64 {
264            if al == 0.0 {
265                if ag == 0.0 {
266                    50.0
267                } else {
268                    100.0
269                }
270            } else {
271                100.0 - 100.0 / (1.0 + ag / al)
272            }
273        };
274        for i in 1..prices.len() {
275            let diff = prices[i] - prices[i - 1];
276            let gain = if diff > 0.0 { diff } else { 0.0 };
277            let loss = if diff < 0.0 { -diff } else { 0.0 };
278            if let (Some(ag), Some(al)) = (avg_gain, avg_loss) {
279                let nag = (ag * (n - 1.0) + gain) / n;
280                let nal = (al * (n - 1.0) + loss) / n;
281                avg_gain = Some(nag);
282                avg_loss = Some(nal);
283                out[i] = Some(rsi_val(nag, nal));
284            } else {
285                gains.push(gain);
286                losses.push(loss);
287                if gains.len() == period {
288                    let ag = gains.iter().sum::<f64>() / n;
289                    let al = losses.iter().sum::<f64>() / n;
290                    avg_gain = Some(ag);
291                    avg_loss = Some(al);
292                    out[i] = Some(rsi_val(ag, al));
293                }
294            }
295        }
296        out
297    }
298
299    #[test]
300    fn new_rejects_zero_period() {
301        assert!(matches!(Rsi::new(0), Err(Error::PeriodZero)));
302    }
303
304    /// Cover the const accessors `period` / `value` (60-67) and the
305    /// Indicator-impl `name` body (145-147). `warmup_period` is covered
306    /// already by `warmup_period_is_period_plus_one`.
307    #[test]
308    fn accessors_and_metadata() {
309        let mut rsi = Rsi::new(14).unwrap();
310        assert_eq!(rsi.period(), 14);
311        assert_eq!(rsi.name(), "RSI");
312        assert_eq!(rsi.value(), None);
313        for i in 1..=15 {
314            rsi.update(100.0 + f64::from(i));
315        }
316        assert!(rsi.value().is_some());
317    }
318
319    /// Cover the `ag == 0` branch (line 167) of the test-helper `rsi_naive`:
320    /// when both `avg_gain` and `avg_loss` are 0 (a perfectly flat series),
321    /// the helper must return the neutral 50.0. The proptest reference uses
322    /// random inputs that essentially never hit zero gains AND zero losses
323    /// simultaneously, leaving this branch dead in the helper.
324    #[test]
325    fn naive_helper_flat_series_yields_50() {
326        let ks = rsi_naive(&[42.0; 20], 5);
327        for r in ks.into_iter().skip(5) {
328            assert_eq!(r.expect("ready after period+1 inputs"), 50.0);
329        }
330    }
331
332    /// Cover the `100.0` branch (line 169) of the test-helper `rsi_naive`:
333    /// strictly increasing prices give `avg_loss == 0` while `avg_gain > 0`,
334    /// the textbook overbought saturation case. Random proptest inputs
335    /// virtually never satisfy `al == 0 && ag != 0`, so this needs an
336    /// explicit monotone series.
337    #[test]
338    fn naive_helper_monotone_up_yields_100() {
339        let prices: Vec<f64> = (1..=20).map(f64::from).collect();
340        let ks = rsi_naive(&prices, 5);
341        for r in ks.into_iter().skip(5) {
342            assert_eq!(r.expect("ready after period+1 inputs"), 100.0);
343        }
344    }
345
346    #[test]
347    fn warmup_period_is_period_plus_one() {
348        let rsi = Rsi::new(14).unwrap();
349        assert_eq!(rsi.warmup_period(), 15);
350    }
351
352    #[test]
353    fn first_emission_at_index_period() {
354        // RSI(14) needs 14 diffs => 15 inputs before first value.
355        let prices: Vec<f64> = (1..=20).map(f64::from).collect();
356        let mut rsi = Rsi::new(14).unwrap();
357        let out = rsi.batch(&prices);
358        // indices 0..14 -> None, index 14 -> first Some
359        for x in &out[..14] {
360            assert!(x.is_none());
361        }
362        assert!(out[14].is_some());
363    }
364
365    #[test]
366    fn pure_uptrend_yields_rsi_100() {
367        let prices: Vec<f64> = (1..=20).map(f64::from).collect();
368        let mut rsi = Rsi::new(14).unwrap();
369        let out = rsi.batch(&prices);
370        // All diffs are positive => avg_loss == 0 => RSI == 100
371        for v in out.iter().filter_map(|x| x.as_ref()) {
372            assert_relative_eq!(*v, 100.0, epsilon = 1e-9);
373        }
374    }
375
376    #[test]
377    fn pure_downtrend_yields_rsi_0() {
378        let prices: Vec<f64> = (1..=20).rev().map(f64::from).collect();
379        let mut rsi = Rsi::new(14).unwrap();
380        let out = rsi.batch(&prices);
381        for v in out.iter().filter_map(|x| x.as_ref()) {
382            assert_relative_eq!(*v, 0.0, epsilon = 1e-9);
383        }
384    }
385
386    #[test]
387    fn flat_series_yields_rsi_50() {
388        let prices = [10.0_f64; 30];
389        let mut rsi = Rsi::new(14).unwrap();
390        let out = rsi.batch(&prices);
391        for v in out.iter().filter_map(|x| x.as_ref()) {
392            assert_relative_eq!(*v, 50.0, epsilon = 1e-12);
393        }
394    }
395
396    #[test]
397    fn classic_wilder_textbook_values() {
398        // Wilder's original example from "New Concepts in Technical Trading Systems",
399        // 14-period RSI. We compute the first value at index 14 and compare to the
400        // value Wilder publishes (~70.46).
401        // Source: classic textbook table, reproduced in many references (e.g. Investopedia).
402        let prices = [
403            44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03,
404            45.61, 46.28, 46.28,
405        ];
406        let mut rsi = Rsi::new(14).unwrap();
407        let out = rsi.batch(&prices);
408        let first = out[14].expect("first RSI emitted at index period");
409        assert_relative_eq!(first, 70.464, epsilon = 0.05);
410    }
411
412    #[test]
413    fn rsi_stays_in_0_100_range() {
414        let prices: Vec<f64> = (0..200)
415            .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 10.0)
416            .collect();
417        let mut rsi = Rsi::new(14).unwrap();
418        for x in rsi.batch(&prices).into_iter().flatten() {
419            assert!((0.0..=100.0).contains(&x), "RSI out of range: {x}");
420        }
421    }
422
423    #[test]
424    fn reset_clears_state() {
425        let mut rsi = Rsi::new(5).unwrap();
426        rsi.batch(&[1.0, 2.0, 3.0, 2.0, 4.0, 5.0, 6.0]);
427        assert!(rsi.is_ready());
428        rsi.reset();
429        assert!(!rsi.is_ready());
430        assert_eq!(rsi.update(1.0), None);
431    }
432
433    #[test]
434    fn batch_equals_streaming() {
435        let prices: Vec<f64> = (1..=40)
436            .map(|i| (f64::from(i) * 0.3).sin() * 5.0 + f64::from(i))
437            .collect();
438        let mut a = Rsi::new(7).unwrap();
439        let mut b = Rsi::new(7).unwrap();
440        assert_eq!(
441            a.batch(&prices),
442            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
443        );
444    }
445
446    #[test]
447    fn ignores_non_finite_input() {
448        let mut rsi = Rsi::new(3).unwrap();
449        rsi.batch(&[1.0, 2.0, 3.0, 4.0]);
450        let before = rsi.value();
451        assert!(before.is_some());
452        assert_eq!(rsi.update(f64::NAN), None);
453        assert_eq!(rsi.update(f64::INFINITY), None);
454        assert_eq!(rsi.value(), before);
455    }
456
457    fn bits_eq(a: &[f64], b: &[f64]) -> bool {
458        a.len() == b.len()
459            && a.iter()
460                .zip(b)
461                .all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
462    }
463
464    fn rsi_replay(period: usize, series: &[f64]) -> Vec<f64> {
465        let mut r = Rsi::new(period).unwrap();
466        series
467            .iter()
468            .map(|&x| r.update(x).unwrap_or(f64::NAN))
469            .collect()
470    }
471
472    #[test]
473    fn batch_nan_fast_path_is_bit_identical() {
474        let series: Vec<f64> = (0..300)
475            .map(|i| (f64::from(i) * 0.3).sin() * 5.0 + f64::from(i) * 0.1 + 100.0)
476            .collect();
477        let mut rsi = Rsi::new(14).unwrap();
478        let got = rsi.batch_nan(&series);
479        assert!(bits_eq(&got, &rsi_replay(14, &series)));
480        let mut ref_rsi = Rsi::new(14).unwrap();
481        for &x in &series {
482            ref_rsi.update(x);
483        }
484        assert_eq!(rsi.update(123.0), ref_rsi.update(123.0));
485    }
486
487    #[test]
488    fn batch_nan_falls_back_on_non_finite() {
489        let series = [10.0, 11.0, 9.0, f64::NAN, 12.0, 13.0, 8.0];
490        let mut rsi = Rsi::new(3).unwrap();
491        assert!(bits_eq(&rsi.batch_nan(&series), &rsi_replay(3, &series)));
492    }
493
494    #[test]
495    fn batch_nan_falls_back_when_not_fresh() {
496        let mut rsi = Rsi::new(3).unwrap();
497        rsi.update(50.0);
498        let series = [51.0, 49.0, 52.0, 53.0, 50.0];
499        let mut ref_rsi = Rsi::new(3).unwrap();
500        ref_rsi.update(50.0);
501        let want: Vec<f64> = series
502            .iter()
503            .map(|&x| ref_rsi.update(x).unwrap_or(f64::NAN))
504            .collect();
505        assert!(bits_eq(&rsi.batch_nan(&series), &want));
506    }
507
508    #[test]
509    fn batch_nan_too_short_to_seed_falls_back() {
510        // n <= period: routed to the exact replay (cannot seed yet).
511        let series = [10.0, 11.0, 12.0];
512        let mut rsi = Rsi::new(3).unwrap();
513        assert!(bits_eq(&rsi.batch_nan(&series), &rsi_replay(3, &series)));
514    }
515
516    proptest::proptest! {
517        #![proptest_config(proptest::test_runner::Config::with_cases(48))]
518        #[test]
519        fn rsi_matches_naive(
520            period in 1usize..20,
521            prices in proptest::collection::vec(1.0_f64..1000.0, 0..150),
522        ) {
523            let mut rsi = Rsi::new(period).unwrap();
524            let got = rsi.batch(&prices);
525            let want = rsi_naive(&prices, period);
526            proptest::prop_assert_eq!(got.len(), want.len());
527            for (g, w) in got.iter().zip(want.iter()) {
528                match (g, w) {
529                    (None, None) => {}
530                    (Some(a), Some(b)) => proptest::prop_assert!(
531                        (a - b).abs() < 1e-7,
532                        "got={a} want={b}"
533                    ),
534                    _ => proptest::prop_assert!(false, "warmup mismatch"),
535                }
536            }
537        }
538    }
539}