Skip to main content

wickra_embed_core/indicators/
rsi.rs

1//! Relative Strength Index (Wilder) — const-generic, no-alloc, byte-parity with
2//! `wickra_core::Rsi`.
3
4use crate::math;
5use crate::traits::Indicator;
6
7/// Relative Strength Index over a Wilder period `N`.
8///
9/// Uses Wilder's smoothing (`alpha = 1 / N`). The seed averages the first `N`
10/// gains and losses; from there each step is the reciprocal-hoisted fused
11/// smoothing `avg.mul_add(N - 1, x) / N`, identical to `wickra_core::Rsi`. The
12/// first output appears after `N + 1` inputs.
13///
14/// # Example
15///
16/// ```
17/// use wickra_embed_core::{Indicator, Rsi};
18///
19/// let mut rsi = Rsi::<14>::new();
20/// let mut last = None;
21/// for i in 0..40 {
22///     last = rsi.update(100.0 + f64::from(i % 5));
23/// }
24/// assert!(last.is_some());
25/// ```
26#[derive(Debug, Clone)]
27pub struct Rsi<const N: usize> {
28    n_minus_1: f64,
29    inv_period: f64,
30    prev_close: f64,
31    has_prev: bool,
32    /// Running sums and count of the seed gains/losses, replacing wickra-core's
33    /// two `Vec<f64>` seed buffers. Incremental summation in arrival order is the
34    /// identical fold to `seed_buf.iter().sum()`, so the seed averages match.
35    seed_count: usize,
36    seed_sum_gain: f64,
37    seed_sum_loss: f64,
38    avg_gain: f64,
39    avg_loss: f64,
40    avgs_seeded: bool,
41    last_value: Option<f64>,
42}
43
44impl<const N: usize> Rsi<N> {
45    /// Construct a new RSI over a Wilder period of `N`.
46    #[must_use]
47    pub fn new() -> Self {
48        Self {
49            n_minus_1: (N - 1) as f64,
50            inv_period: 1.0 / N as f64,
51            prev_close: 0.0,
52            has_prev: false,
53            seed_count: 0,
54            seed_sum_gain: 0.0,
55            seed_sum_loss: 0.0,
56            avg_gain: 0.0,
57            avg_loss: 0.0,
58            avgs_seeded: false,
59            last_value: None,
60        }
61    }
62
63    /// Current value if available.
64    #[must_use]
65    pub const fn value(&self) -> Option<f64> {
66        self.last_value
67    }
68
69    /// RSI from smoothed average gain/loss, collapsed to a single division.
70    /// `100·ag/(ag+al)`, with the no-movement case (`ag+al == 0`) neutral at 50.
71    fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
72        let denom = avg_gain + avg_loss;
73        if denom == 0.0 {
74            50.0
75        } else {
76            100.0 * avg_gain / denom
77        }
78    }
79}
80
81impl<const N: usize> Default for Rsi<N> {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl<const N: usize> Indicator for Rsi<N> {
88    type Input = f64;
89
90    fn update(&mut self, input: f64) -> Option<f64> {
91        if !input.is_finite() {
92            return self.last_value;
93        }
94        if !self.has_prev {
95            self.prev_close = input;
96            self.has_prev = true;
97            return None;
98        }
99        let prev = self.prev_close;
100        self.prev_close = input;
101
102        let diff = input - prev;
103        let gain = if diff > 0.0 { diff } else { 0.0 };
104        let loss = if diff < 0.0 { -diff } else { 0.0 };
105
106        if self.avgs_seeded {
107            let smoothed_gain =
108                math::mul_add(self.avg_gain, self.n_minus_1, gain) * self.inv_period;
109            let smoothed_loss =
110                math::mul_add(self.avg_loss, self.n_minus_1, loss) * self.inv_period;
111            self.avg_gain = smoothed_gain;
112            self.avg_loss = smoothed_loss;
113            let v = Self::rsi_from_avgs(smoothed_gain, smoothed_loss);
114            self.last_value = Some(v);
115            return Some(v);
116        }
117
118        self.seed_sum_gain += gain;
119        self.seed_sum_loss += loss;
120        self.seed_count += 1;
121        if self.seed_count == N {
122            let ag = self.seed_sum_gain / N as f64;
123            let al = self.seed_sum_loss / N as f64;
124            self.avg_gain = ag;
125            self.avg_loss = al;
126            self.avgs_seeded = true;
127            let v = Self::rsi_from_avgs(ag, al);
128            self.last_value = Some(v);
129            return Some(v);
130        }
131        None
132    }
133
134    fn reset(&mut self) {
135        *self = Self::new();
136    }
137
138    fn warmup_period(&self) -> usize {
139        N + 1
140    }
141
142    fn is_ready(&self) -> bool {
143        self.last_value.is_some()
144    }
145
146    fn name(&self) -> &'static str {
147        "rsi"
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::Rsi;
154    use crate::traits::Indicator;
155
156    #[test]
157    fn warmup_and_bounds() {
158        let mut rsi = Rsi::<3>::new();
159        // First input only sets the baseline.
160        assert_eq!(rsi.update(100.0), None);
161        assert_eq!(rsi.warmup_period(), 4);
162        let mut last = None;
163        for i in 1..40 {
164            last = rsi.update(100.0 + f64::from(i % 7));
165        }
166        let v = last.unwrap();
167        assert!((0.0..=100.0).contains(&v), "rsi out of range: {v}");
168        assert!(rsi.is_ready());
169    }
170
171    #[test]
172    fn all_gains_saturates_to_100() {
173        let mut rsi = Rsi::<3>::new();
174        let mut last = None;
175        for i in 0..20 {
176            last = rsi.update(100.0 + f64::from(i));
177        }
178        assert_eq!(last, Some(100.0));
179    }
180
181    #[test]
182    fn byte_parity_with_wickra_core() {
183        let mut ours = Rsi::<14>::new();
184        let mut theirs = wickra_core::Rsi::new(14).unwrap();
185        for i in 0..1000 {
186            let x = 100.0 + 20.0 * (f64::from(i) * 0.09).sin() + 5.0 * (f64::from(i) * 0.3).cos();
187            let a = ours.update(x);
188            let b = <wickra_core::Rsi as wickra_core::Indicator>::update(&mut theirs, x);
189            assert_eq!(a.map(f64::to_bits), b.map(f64::to_bits), "index {i}");
190        }
191    }
192}