kestrel-chartkit 0.11.3

High-performance Rust technical analysis library for indicator math, market regime classification, composite scoring, and SVG visualization.
Documentation
use super::rsi::Rsi;
use super::{Indicator, IndicatorAlert, IndicatorOutput};
use crate::model::Bar;
use crate::stats::percent_rank;
use std::collections::VecDeque;

/// Connors RSI: the mean of a price RSI, a streak RSI and a percentile rank of the one-bar return.
///
/// As implemented, and in each point different from the usual definition:
///
/// - The price RSI and the streak RSI are the **lines** of [`super::rsi::Rsi`] (an `Ema(3)` over
///   the raw RSI), not raw RSI values.
/// - The streak counts consecutive higher (positive) or lower (negative) closes and is `0` on an
///   unchanged close. Streak and one-bar return only start updating on the bar *after* the price
///   RSI first exists; the streak RSI runs over the streak values from the price RSI's first bar.
/// - The percentile rank is `100 * count(r <= last) / count` over all one-bar returns seen so far,
///   at most the last 100. **`rank_len` has no effect.**
///
/// `CRSI = (rsi_close + rsi_streak + rank) / 3`, clamped to `0..=100`.
///
/// First output: once the streak RSI exists. [`Indicator::reset`] clears all state.
#[derive(Debug, Clone)]
pub struct ConnorsRsiEngine {
    rsi_close: Rsi,
    rsi_streak: Rsi,
    prev_close: Option<f64>,
    current_streak: f64,
    roc_history: VecDeque<f64>,
}

impl ConnorsRsiEngine {
    pub fn new(rsi_len: usize, streak_len: usize, rank_len: usize) -> Self {
        Self {
            rsi_close: Rsi::with_period(rsi_len),
            rsi_streak: Rsi::with_period(streak_len),
            prev_close: None,
            current_streak: 0.0,
            roc_history: VecDeque::with_capacity(rank_len),
        }
    }

    pub fn with_defaults() -> Self {
        Self::new(3, 2, 100)
    }
}

impl Indicator for ConnorsRsiEngine {
    fn name(&self) -> &str {
        "connors_rsi"
    }

    fn warmup_period(&self) -> usize {
        100
    }

    fn reset(&mut self) {
        self.rsi_close.reset();
        self.rsi_streak.reset();
        self.prev_close = None;
        self.current_streak = 0.0;
        self.roc_history.clear();
    }

    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
        let rsi_c = self.rsi_close.on_bar(bar)?.value;

        if let Some(prev) = self.prev_close {
            if bar.close > prev {
                self.current_streak = if self.current_streak >= 0.0 {
                    self.current_streak + 1.0
                } else {
                    1.0
                };
            } else if bar.close < prev {
                self.current_streak = if self.current_streak <= 0.0 {
                    self.current_streak - 1.0
                } else {
                    -1.0
                };
            } else {
                self.current_streak = 0.0;
            }

            let roc = if prev > 0.0 {
                (bar.close - prev) / prev
            } else {
                0.0
            };
            self.roc_history.push_back(roc);
            if self.roc_history.len() > 100 {
                self.roc_history.pop_front();
            }
        }
        self.prev_close = Some(bar.close);

        // Dummy bar wrapping streak as price
        let streak_bar = Bar::new(
            bar.timestamp,
            self.current_streak,
            self.current_streak,
            self.current_streak,
            self.current_streak,
            1.0,
        );
        let rsi_s = self.rsi_streak.on_bar(&streak_bar)?.value;

        let last_roc = self.roc_history.back().copied().unwrap_or(0.0);
        let slice: Vec<f64> = self.roc_history.iter().copied().collect();
        let prank = percent_rank(&slice, last_roc);

        let crsi = (rsi_c + rsi_s + prank) / 3.0;
        Some(IndicatorOutput::new(crsi.clamp(0.0, 100.0)))
    }

    fn alerts(&self) -> Vec<IndicatorAlert> {
        Vec::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_connors_rsi() {
        let mut crsi = ConnorsRsiEngine::with_defaults();
        let mut out = None;
        for i in 0..120 {
            let b = Bar::new(i, 100.0, 105.0, 95.0, 100.0 + (i % 5) as f64, 1000.0);
            out = crsi.on_bar(&b);
        }
        assert!(out.is_some());
        let val = out.unwrap().value;
        assert!((0.0..=100.0).contains(&val));
    }
}