Skip to main content

wickra_core/indicators/
connors_rsi.rs

1//! Connors RSI (CRSI).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rsi::Rsi;
7use crate::traits::Indicator;
8
9/// Larry Connors' RSI — average of three short-term mean-reversion components,
10/// each individually bounded in `[0, 100]` so the aggregate is too:
11///
12/// 1. `RSI(close, period_rsi)` — a fast `RSI` (Connors' default `3`).
13/// 2. `RSI(streak, period_streak)` — `RSI` of the current up/down run length
14///    (`+1, +2, ...` for consecutive up closes, `−1, −2, ...` for down closes,
15///    `0` for unchanged). Connors' default `2`.
16/// 3. `PercentRank(ROC(1), period_rank)` — the percentile rank of yesterday's
17///    1-period return in the last `period_rank` returns. Connors' default `100`.
18///
19/// ```text
20/// CRSI = (RSI(close)_t + RSI(streak)_t + PercentRank(roc1)_t) / 3
21/// ```
22///
23/// All three components live in `[0, 100]`, so `CRSI ∈ [0, 100]`. Connors'
24/// trading rule of thumb: `CRSI < 5` is oversold, `CRSI > 95` is overbought
25/// — both rare conditions, hence the short lookbacks.
26///
27/// # Example
28///
29/// ```
30/// use wickra_core::{ConnorsRsi, Indicator};
31///
32/// let mut crsi = ConnorsRsi::classic();
33/// let mut last = None;
34/// for i in 0..200 {
35///     last = crsi.update(100.0 + f64::from(i));
36/// }
37/// assert!(last.is_some());
38/// ```
39#[derive(Debug, Clone)]
40pub struct ConnorsRsi {
41    period_rsi: usize,
42    period_streak: usize,
43    period_rank: usize,
44    rsi_close: Rsi,
45    rsi_streak: Rsi,
46    prev_price: Option<f64>,
47    streak: f64,
48    /// Rolling window of the last `period_rank` 1-period returns
49    /// (`(price_t − price_{t-1}) / price_{t-1}`).
50    rocs: VecDeque<f64>,
51    current: Option<f64>,
52}
53
54impl ConnorsRsi {
55    /// # Errors
56    /// Returns [`Error::PeriodZero`] if any of the three periods is zero.
57    pub fn new(period_rsi: usize, period_streak: usize, period_rank: usize) -> Result<Self> {
58        if period_rsi == 0 || period_streak == 0 || period_rank == 0 {
59            return Err(Error::PeriodZero);
60        }
61        Ok(Self {
62            period_rsi,
63            period_streak,
64            period_rank,
65            rsi_close: Rsi::new(period_rsi)?,
66            rsi_streak: Rsi::new(period_streak)?,
67            prev_price: None,
68            streak: 0.0,
69            rocs: VecDeque::with_capacity(period_rank),
70            current: None,
71        })
72    }
73
74    /// Connors' recommended defaults: `(period_rsi = 3, period_streak = 2, period_rank = 100)`.
75    pub fn classic() -> Self {
76        Self::new(3, 2, 100).expect("classic Connors RSI parameters are valid")
77    }
78
79    /// Configured `(period_rsi, period_streak, period_rank)`.
80    pub const fn periods(&self) -> (usize, usize, usize) {
81        (self.period_rsi, self.period_streak, self.period_rank)
82    }
83}
84
85impl Indicator for ConnorsRsi {
86    type Input = f64;
87    type Output = f64;
88
89    fn update(&mut self, input: f64) -> Option<f64> {
90        if !input.is_finite() {
91            return self.current;
92        }
93        // Run the close-RSI on every input so it warms up regardless of the
94        // streak / percent-rank branches.
95        let rsi_close = self.rsi_close.update(input);
96
97        let Some(prev) = self.prev_price else {
98            self.prev_price = Some(input);
99            return None;
100        };
101
102        // Update the up/down streak run length.
103        self.streak = if input > prev {
104            self.streak.max(0.0) + 1.0
105        } else if input < prev {
106            self.streak.min(0.0) - 1.0
107        } else {
108            0.0
109        };
110        let rsi_streak = self.rsi_streak.update(self.streak);
111
112        // 1-period return; defined only when the previous price is non-zero.
113        if prev != 0.0 {
114            let roc = (input - prev) / prev;
115            if self.rocs.len() == self.period_rank {
116                self.rocs.pop_front();
117            }
118            self.rocs.push_back(roc);
119        }
120        self.prev_price = Some(input);
121
122        // PercentRank emits once the ROC window has filled.
123        let percent_rank = if self.rocs.len() == self.period_rank {
124            let latest = *self.rocs.back().expect("non-empty window");
125            let below = self.rocs.iter().filter(|&&r| r < latest).count();
126            Some(100.0 * below as f64 / self.period_rank as f64)
127        } else {
128            None
129        };
130
131        let value = (rsi_close?, rsi_streak?, percent_rank?);
132        let crsi = (value.0 + value.1 + value.2) / 3.0;
133        self.current = Some(crsi);
134        Some(crsi)
135    }
136
137    fn reset(&mut self) {
138        self.rsi_close.reset();
139        self.rsi_streak.reset();
140        self.prev_price = None;
141        self.streak = 0.0;
142        self.rocs.clear();
143        self.current = None;
144    }
145
146    fn warmup_period(&self) -> usize {
147        // The slowest branch is the percent-rank: it needs period_rank + 1
148        // prices (period_rank one-period returns). The close-RSI needs
149        // period_rsi + 1 prices and the streak-RSI needs period_streak + 1
150        // streak values = period_streak + 2 prices. The rank branch dominates
151        // for Connors' defaults.
152        let rsi_close = self.period_rsi + 1;
153        let rsi_streak = self.period_streak + 2;
154        let rank = self.period_rank + 1;
155        rsi_close.max(rsi_streak).max(rank)
156    }
157
158    fn is_ready(&self) -> bool {
159        self.current.is_some()
160    }
161
162    fn name(&self) -> &'static str {
163        "ConnorsRSI"
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_zero_period() {
175        assert!(matches!(ConnorsRsi::new(0, 2, 100), Err(Error::PeriodZero)));
176        assert!(matches!(ConnorsRsi::new(3, 0, 100), Err(Error::PeriodZero)));
177        assert!(matches!(ConnorsRsi::new(3, 2, 0), Err(Error::PeriodZero)));
178    }
179
180    #[test]
181    fn accessors_and_metadata() {
182        let crsi = ConnorsRsi::classic();
183        assert_eq!(crsi.periods(), (3, 2, 100));
184        assert_eq!(crsi.name(), "ConnorsRSI");
185        // Slowest branch: percent_rank with period_rank + 1 = 101.
186        assert_eq!(crsi.warmup_period(), 101);
187    }
188
189    #[test]
190    fn classic_factory() {
191        assert_eq!(ConnorsRsi::classic().periods(), (3, 2, 100));
192    }
193
194    #[test]
195    fn warmup_emits_first_value_at_warmup_period() {
196        // Use small periods so the test is fast.
197        let mut crsi = ConnorsRsi::new(3, 2, 5).unwrap();
198        // Slowest: 5 + 1 = 6.
199        assert_eq!(crsi.warmup_period(), 6);
200        let prices: Vec<f64> = (1..=8).map(f64::from).collect();
201        let out = crsi.batch(&prices);
202        for v in out.iter().take(5) {
203            assert!(v.is_none());
204        }
205        assert!(out[5].is_some());
206    }
207
208    #[test]
209    fn pure_uptrend_saturates_high() {
210        // A monotonic uptrend drives all three components toward 100:
211        // RSI of monotonic ups is 100, streak stays positive and growing so
212        // its RSI is 100, and every new 1-period return matches the prior
213        // ones so percent rank stabilises near 0 — but the average of all
214        // three still climbs well above 50.
215        let mut crsi = ConnorsRsi::classic();
216        for i in 1..=200 {
217            crsi.update(f64::from(i));
218        }
219        let v = crsi.current.unwrap();
220        assert!(
221            v > 60.0,
222            "uptrend should drive Connors RSI well above 50: {v}"
223        );
224    }
225
226    #[test]
227    fn output_is_bounded() {
228        let mut crsi = ConnorsRsi::classic();
229        let prices: Vec<f64> = (0..300)
230            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 20.0)
231            .collect();
232        for v in crsi.batch(&prices).iter().flatten() {
233            assert!(
234                (0.0..=100.0).contains(v),
235                "Connors RSI out of [0, 100]: {v}"
236            );
237        }
238    }
239
240    #[test]
241    fn streak_resets_to_zero_on_unchanged_close() {
242        // Helper: feed a sequence and inspect the internal streak.
243        let mut crsi = ConnorsRsi::new(3, 2, 100).unwrap();
244        crsi.update(10.0);
245        crsi.update(11.0);
246        crsi.update(12.0);
247        assert_eq!(crsi.streak, 2.0);
248        crsi.update(12.0);
249        assert_relative_eq!(crsi.streak, 0.0, epsilon = 1e-12);
250        crsi.update(11.0);
251        assert_eq!(crsi.streak, -1.0);
252        crsi.update(10.0);
253        assert_eq!(crsi.streak, -2.0);
254    }
255
256    #[test]
257    fn batch_equals_streaming() {
258        let prices: Vec<f64> = (1..=200)
259            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1)
260            .collect();
261        let mut a = ConnorsRsi::classic();
262        let mut b = ConnorsRsi::classic();
263        assert_eq!(
264            a.batch(&prices),
265            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
266        );
267    }
268
269    #[test]
270    fn reset_clears_state() {
271        let mut crsi = ConnorsRsi::classic();
272        let prices: Vec<f64> = (1..=200).map(f64::from).collect();
273        crsi.batch(&prices);
274        assert!(crsi.is_ready());
275        crsi.reset();
276        assert!(!crsi.is_ready());
277        assert_eq!(crsi.streak, 0.0);
278        assert!(crsi.prev_price.is_none());
279    }
280
281    #[test]
282    fn ignores_non_finite_input() {
283        let mut crsi = ConnorsRsi::classic();
284        let prices: Vec<f64> = (1..=200).map(f64::from).collect();
285        crsi.batch(&prices);
286        let before = crsi.current;
287        assert_eq!(crsi.update(f64::NAN), before);
288        assert_eq!(crsi.update(f64::INFINITY), before);
289    }
290
291    #[test]
292    fn zero_prev_skips_roc_update() {
293        // A previous price of 0.0 makes the 1-bar return undefined; the
294        // ROC ring buffer must be left unchanged on that step. Feeding
295        // 0.0 as the very first price seeds `prev_price = Some(0.0)`, so
296        // the next bar takes the `prev == 0.0` branch.
297        let mut crsi = ConnorsRsi::new(3, 2, 4).unwrap();
298        // Bar 1 seeds prev_price to 0.0.
299        crsi.update(0.0);
300        // Bar 2 must not push onto the ROC window; we cannot observe the
301        // ring directly but the indicator must not panic and must not
302        // emit until at least period_rank distinct non-zero returns have
303        // accumulated.
304        let after = crsi.update(1.0);
305        assert!(after.is_none(), "CRSI cannot emit on bar 2: {after:?}");
306    }
307}