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 None;
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    #[inline]
147    fn warmup_period(&self) -> usize {
148        // The slowest branch is the percent-rank: it needs period_rank + 1
149        // prices (period_rank one-period returns). The close-RSI needs
150        // period_rsi + 1 prices and the streak-RSI needs period_streak + 1
151        // streak values = period_streak + 2 prices. The rank branch dominates
152        // for Connors' defaults.
153        let rsi_close = self.period_rsi + 1;
154        let rsi_streak = self.period_streak + 2;
155        let rank = self.period_rank + 1;
156        rsi_close.max(rsi_streak).max(rank)
157    }
158
159    #[inline]
160    fn is_ready(&self) -> bool {
161        self.current.is_some()
162    }
163
164    #[inline]
165    fn name(&self) -> &'static str {
166        "ConnorsRSI"
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::traits::BatchExt;
174    use approx::assert_relative_eq;
175
176    #[test]
177    fn rejects_zero_period() {
178        assert!(matches!(ConnorsRsi::new(0, 2, 100), Err(Error::PeriodZero)));
179        assert!(matches!(ConnorsRsi::new(3, 0, 100), Err(Error::PeriodZero)));
180        assert!(matches!(ConnorsRsi::new(3, 2, 0), Err(Error::PeriodZero)));
181    }
182
183    #[test]
184    fn accessors_and_metadata() {
185        let crsi = ConnorsRsi::classic();
186        assert_eq!(crsi.periods(), (3, 2, 100));
187        assert_eq!(crsi.name(), "ConnorsRSI");
188        // Slowest branch: percent_rank with period_rank + 1 = 101.
189        assert_eq!(crsi.warmup_period(), 101);
190    }
191
192    #[test]
193    fn classic_factory() {
194        assert_eq!(ConnorsRsi::classic().periods(), (3, 2, 100));
195    }
196
197    #[test]
198    fn warmup_emits_first_value_at_warmup_period() {
199        // Use small periods so the test is fast.
200        let mut crsi = ConnorsRsi::new(3, 2, 5).unwrap();
201        // Slowest: 5 + 1 = 6.
202        assert_eq!(crsi.warmup_period(), 6);
203        let prices: Vec<f64> = (1..=8).map(f64::from).collect();
204        let out = crsi.batch(&prices);
205        for v in out.iter().take(5) {
206            assert!(v.is_none());
207        }
208        assert!(out[5].is_some());
209    }
210
211    #[test]
212    fn pure_uptrend_saturates_high() {
213        // A monotonic uptrend drives all three components toward 100:
214        // RSI of monotonic ups is 100, streak stays positive and growing so
215        // its RSI is 100, and every new 1-period return matches the prior
216        // ones so percent rank stabilises near 0 — but the average of all
217        // three still climbs well above 50.
218        let mut crsi = ConnorsRsi::classic();
219        for i in 1..=200 {
220            crsi.update(f64::from(i));
221        }
222        let v = crsi.current.unwrap();
223        assert!(
224            v > 60.0,
225            "uptrend should drive Connors RSI well above 50: {v}"
226        );
227    }
228
229    #[test]
230    fn output_is_bounded() {
231        let mut crsi = ConnorsRsi::classic();
232        let prices: Vec<f64> = (0..300)
233            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 20.0)
234            .collect();
235        for v in crsi.batch(&prices).iter().flatten() {
236            assert!(
237                (0.0..=100.0).contains(v),
238                "Connors RSI out of [0, 100]: {v}"
239            );
240        }
241    }
242
243    #[test]
244    fn streak_resets_to_zero_on_unchanged_close() {
245        // Helper: feed a sequence and inspect the internal streak.
246        let mut crsi = ConnorsRsi::new(3, 2, 100).unwrap();
247        crsi.update(10.0);
248        crsi.update(11.0);
249        crsi.update(12.0);
250        assert_eq!(crsi.streak, 2.0);
251        crsi.update(12.0);
252        assert_relative_eq!(crsi.streak, 0.0, epsilon = 1e-12);
253        crsi.update(11.0);
254        assert_eq!(crsi.streak, -1.0);
255        crsi.update(10.0);
256        assert_eq!(crsi.streak, -2.0);
257    }
258
259    #[test]
260    fn batch_equals_streaming() {
261        let prices: Vec<f64> = (1..=200)
262            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1)
263            .collect();
264        let mut a = ConnorsRsi::classic();
265        let mut b = ConnorsRsi::classic();
266        assert_eq!(
267            a.batch(&prices),
268            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
269        );
270    }
271
272    #[test]
273    fn reset_clears_state() {
274        let mut crsi = ConnorsRsi::classic();
275        let prices: Vec<f64> = (1..=200).map(f64::from).collect();
276        crsi.batch(&prices);
277        assert!(crsi.is_ready());
278        crsi.reset();
279        assert!(!crsi.is_ready());
280        assert_eq!(crsi.streak, 0.0);
281        assert!(crsi.prev_price.is_none());
282    }
283
284    #[test]
285    fn ignores_non_finite_input() {
286        let mut crsi = ConnorsRsi::classic();
287        let prices: Vec<f64> = (1..=200).map(f64::from).collect();
288        crsi.batch(&prices);
289        let before = crsi.current;
290        assert_eq!(crsi.update(f64::NAN), None);
291        assert_eq!(crsi.update(f64::INFINITY), None);
292        // The rejected input must not have disturbed the state.
293        assert_eq!(crsi.current, before);
294    }
295
296    #[test]
297    fn zero_prev_skips_roc_update() {
298        // A previous price of 0.0 makes the 1-bar return undefined; the
299        // ROC ring buffer must be left unchanged on that step. Feeding
300        // 0.0 as the very first price seeds `prev_price = Some(0.0)`, so
301        // the next bar takes the `prev == 0.0` branch.
302        let mut crsi = ConnorsRsi::new(3, 2, 4).unwrap();
303        // Bar 1 seeds prev_price to 0.0.
304        crsi.update(0.0);
305        // Bar 2 must not push onto the ROC window; we cannot observe the
306        // ring directly but the indicator must not panic and must not
307        // emit until at least period_rank distinct non-zero returns have
308        // accumulated.
309        let after = crsi.update(1.0);
310        assert!(after.is_none(), "CRSI cannot emit on bar 2: {after:?}");
311    }
312}