Skip to main content

egui_charts/studies/builtin/
connors_rsi.rs

1use crate::model::Bar;
2use crate::studies::palette;
3use crate::studies::stepper::Stepper;
4use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
5use egui::Color32;
6
7/// Connors RSI (CRSI)
8///
9/// Composite momentum indicator combining:
10/// 1. Standard RSI
11/// 2. Up/Down Streak Length RSI
12/// 3. Percent Rank of Rate of Change
13///
14/// Developed by Larry Connors
15#[derive(Clone)]
16pub struct ConnorsRSI {
17    /// RSI period (typically 3)
18    rsi_period: usize,
19    /// Streak RSI period (typically 2)
20    streak_period: usize,
21    /// Percent Rank lookback (typically 100)
22    rank_period: usize,
23    values: IndicatorValues,
24    stepper: Stepper<State>,
25    color: Color32,
26    visible: bool,
27}
28
29#[derive(Clone, Default)]
30struct State {
31    previous: Option<f64>,
32    streak: f64,
33    price_rsi: (usize, f64, f64),
34    streak_rsi: (usize, f64, f64),
35    roc: std::collections::VecDeque<f64>,
36}
37
38impl ConnorsRSI {
39    /// Create with price RSI 3, streak RSI 2 and percent-rank period 100.
40    pub fn new() -> Self {
41        Self {
42            rsi_period: 3,
43            streak_period: 2,
44            rank_period: 100,
45            values: IndicatorValues::new(),
46            stepper: Stepper::default(),
47            color: palette::BLUE,
48            visible: true,
49        }
50    }
51
52    /// Set price RSI, streak RSI and percent-rank periods in bars, each at least one.
53    pub fn with_periods(mut self, rsi: usize, streak: usize, rank: usize) -> Self {
54        self.rsi_period = rsi.max(1);
55        self.streak_period = streak.max(1);
56        self.rank_period = rank.max(1);
57        self
58    }
59
60    /// Set the plot color.
61    pub fn with_color(mut self, color: Color32) -> Self {
62        self.color = color;
63        self
64    }
65
66    /// Resume the mean of gains and losses, then Wilder smoothing.
67    fn next_rsi(state: &mut (usize, f64, f64), change: f64, period: usize) -> f64 {
68        let gain = if change > 0.0 { change } else { 0.0 };
69        let loss = if change < 0.0 { -change } else { 0.0 };
70        if state.0 < period {
71            state.0 += 1;
72            state.1 += gain;
73            state.2 += loss;
74            if state.0 < period {
75                return f64::NAN;
76            }
77            state.1 /= period as f64;
78            state.2 /= period as f64;
79        } else {
80            state.1 = (state.1 * (period - 1) as f64 + gain) / period as f64;
81            state.2 = (state.2 * (period - 1) as f64 + loss) / period as f64;
82        }
83        super::rsi::from_averages(state.1, state.2)
84    }
85}
86
87impl Default for ConnorsRSI {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl Indicator for ConnorsRSI {
94    fn source(&self) -> Option<crate::studies::StudySource> {
95        Some(crate::studies::StudySource::Price(
96            crate::model::PriceSource::Close,
97        ))
98    }
99
100    crate::studies::inputs::study_inputs!(self;
101        rsi_period: length("RSI period", 1, usize::MAX),
102        streak_period: length("Streak period", 1, usize::MAX),
103        rank_period: length("Rank period", 1, usize::MAX)
104    ; {});
105
106    fn name(&self) -> &str {
107        "Connors RSI"
108    }
109
110    fn desc(&self) -> &str {
111        "Connors RSI - Composite momentum indicator"
112    }
113
114    fn calculate(&mut self, data: &[Bar]) {
115        self.update(data, 0);
116    }
117
118    fn update(&mut self, data: &[Bar], from: usize) {
119        self.stepper
120            .run(data, from, &mut self.values, |state, bar| {
121                if !bar.close.is_finite() {
122                    return IndicatorValue::None;
123                }
124                let previous = state.previous.replace(bar.close);
125                let (mut price_rsi, mut streak_rsi, mut roc) = (f64::NAN, f64::NAN, f64::NAN);
126                if let Some(previous) = previous {
127                    price_rsi =
128                        Self::next_rsi(&mut state.price_rsi, bar.close - previous, self.rsi_period);
129                    let streak = if bar.close > previous {
130                        if state.streak > 0.0 {
131                            state.streak + 1.0
132                        } else {
133                            1.0
134                        }
135                    } else if bar.close < previous {
136                        if state.streak < 0.0 {
137                            state.streak - 1.0
138                        } else {
139                            -1.0
140                        }
141                    } else {
142                        0.0
143                    };
144                    streak_rsi = Self::next_rsi(
145                        &mut state.streak_rsi,
146                        streak - state.streak,
147                        self.streak_period,
148                    );
149                    state.streak = streak;
150                    if previous != 0.0 {
151                        roc = (bar.close - previous) / previous * 100.0;
152                    }
153                }
154                let rank = if state.roc.len() == self.rank_period && !roc.is_nan() {
155                    state
156                        .roc
157                        .iter()
158                        .filter(|&&v| !v.is_nan() && v < roc)
159                        .count() as f64
160                        / self.rank_period as f64
161                        * 100.0
162                } else {
163                    f64::NAN
164                };
165                state.roc.push_back(roc);
166                if state.roc.len() > self.rank_period {
167                    state.roc.pop_front();
168                }
169                if price_rsi.is_nan() || streak_rsi.is_nan() || rank.is_nan() {
170                    IndicatorValue::None
171                } else {
172                    IndicatorValue::Single((price_rsi + streak_rsi + rank) / 3.0)
173                }
174            });
175    }
176
177    fn is_incremental(&self) -> bool {
178        true
179    }
180
181    fn values(&self) -> &IndicatorValues {
182        &self.values
183    }
184
185    fn colors(&self) -> Vec<Color32> {
186        vec![self.color]
187    }
188
189    fn set_colors(&mut self, colors: Vec<Color32>) {
190        if !colors.is_empty() {
191            self.color = colors[0];
192        }
193    }
194
195    fn is_overlay(&self) -> bool {
196        false // Separate pane
197    }
198
199    fn is_visible(&self) -> bool {
200        self.visible
201    }
202
203    fn set_visible(&mut self, visible: bool) {
204        self.visible = visible;
205    }
206
207    fn clone_box(&self) -> Box<dyn Indicator> {
208        Box::new(self.clone())
209    }
210
211    fn line_names(&self) -> Vec<String> {
212        vec![format!(
213            "CRSI({},{},{})",
214            self.rsi_period, self.streak_period, self.rank_period
215        )]
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use chrono::Utc;
223
224    fn make_bar(close: f64) -> Bar {
225        Bar {
226            time: Utc::now(),
227            open: close,
228            high: close + 1.0,
229            low: close - 1.0,
230            close,
231            volume: 1000.0,
232            colors: None,
233            payload: None,
234        }
235    }
236
237    #[test]
238    fn test_streak_calculation() {
239        let data = [
240            make_bar(100.0),
241            make_bar(101.0), // +1
242            make_bar(102.0), // +2
243            make_bar(101.0), // -1
244            make_bar(100.0), // -2
245            make_bar(100.0), // 0
246        ];
247
248        // Wilder RSI(2): price = 100, 50, 25, 25; streak = 100, 25, 50/3, 450/7.
249        // The last two returns rank the current return at 0, 0, 0, then 100 percent.
250        let expected = [200.0 / 3.0, 25.0, 125.0 / 9.0, 1325.0 / 21.0];
251        let mut study = ConnorsRSI::new().with_periods(2, 2, 2);
252        study.calculate(&data);
253        for (bar, want) in (2..data.len()).zip(expected) {
254            let got = study.values().value(0, bar).expect("defined Connors RSI");
255            assert!((got - want).abs() < 1e-9, "bar {bar}: {got} != {want}");
256        }
257    }
258
259    /// A flat series has no gains or losses: both RSI components are the neutral 50 and no
260    /// return ranks above an equal one, so the composite is (50 + 50 + 0) / 3.
261    #[test]
262    fn flat_series_uses_neutral_rsi() {
263        let mut crsi = ConnorsRSI::new().with_periods(3, 2, 5);
264        let data: Vec<Bar> = (0..20).map(|_| make_bar(100.0)).collect();
265        crsi.calculate(&data);
266        match crsi.values.to_rows()[19] {
267            IndicatorValue::Single(v) => assert!((v - 100.0 / 3.0).abs() < 1e-9),
268            _ => panic!("expected a value after warmup"),
269        }
270    }
271
272    #[test]
273    fn test_connors_rsi_range() {
274        let mut crsi = ConnorsRSI::new().with_periods(3, 2, 20);
275
276        let data: Vec<Bar> = (0..100).map(|i| make_bar(100.0 + i as f64)).collect();
277
278        crsi.calculate(&data);
279
280        // CRSI should be bounded between 0 and 100
281        let mut checked = 0;
282        for value in &crsi.values.to_rows() {
283            if let IndicatorValue::Single(v) = value {
284                assert!(*v >= 0.0 && *v <= 100.0, "CRSI {v} out of range");
285                checked += 1;
286            }
287        }
288        assert!(checked > 0, "no value to check");
289    }
290}