Skip to main content

wickra_core/indicators/
adaptive_rsi.rs

1//! Adaptive RSI — an RSI whose up/down averaging adapts to the efficiency ratio.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Adaptive RSI — Wilder's RSI in which the smoothing of the average gain and
9/// average loss **adapts to trendiness** via Kaufman's efficiency ratio, so the
10/// oscillator reacts fast in a clean move and smooths through chop.
11///
12/// ```text
13/// ER     = |price_t − price_{t−period}| / Σ |Δprice| over the window   (efficiency ratio, 0..1)
14/// sc     = ( ER·(2/3 − 2/31) + 2/31 )²                                  (KAMA smoothing constant)
15/// avg_gain += sc·(gain − avg_gain),  avg_loss += sc·(loss − avg_loss)
16/// RSI    = 100 · avg_gain / (avg_gain + avg_loss)
17/// ```
18///
19/// A fixed-period [`Rsi`](crate::Rsi) is a compromise: short periods whip in
20/// ranges, long ones lag in trends. This adaptive form borrows Kaufman's
21/// efficiency ratio (`directional move / total path`) to set the smoothing each
22/// bar — near `1` (a clean trend) the averages track gains and losses almost
23/// immediately; near `0` (noise) they barely move, filtering the chop. The result
24/// is an RSI that is responsive when it should be and quiet when it should be. It
25/// is the efficiency-ratio cousin of Ehlers' cycle-adaptive RSI, which instead
26/// sets the lookback from the measured dominant cycle.
27///
28/// Output is bounded in `[0, 100]`; a flat market returns the neutral `50`. The
29/// first value lands after `period + 1` inputs. Each `update` is O(1).
30///
31/// # Example
32///
33/// ```
34/// use wickra_core::{Indicator, AdaptiveRsi};
35///
36/// let mut indicator = AdaptiveRsi::new(14).unwrap();
37/// let mut last = None;
38/// for i in 0..60 {
39///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
40/// }
41/// assert!(last.is_some());
42/// ```
43#[derive(Debug, Clone)]
44pub struct AdaptiveRsi {
45    period: usize,
46    prices: VecDeque<f64>,
47    abs_changes: VecDeque<f64>,
48    abs_sum: f64,
49    prev: Option<f64>,
50    seed_gain: f64,
51    seed_loss: f64,
52    seed_count: usize,
53    avg_gain: Option<f64>,
54    avg_loss: Option<f64>,
55    last: Option<f64>,
56}
57
58impl AdaptiveRsi {
59    /// Construct an adaptive RSI with the given efficiency-ratio `period`.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`Error::PeriodZero`] if `period == 0`.
64    pub fn new(period: usize) -> Result<Self> {
65        if period == 0 {
66            return Err(Error::PeriodZero);
67        }
68        if period > crate::error::MAX_PERIOD {
69            return Err(Error::InvalidPeriod {
70                message: crate::error::PERIOD_ABOVE_MAX,
71            });
72        }
73        Ok(Self {
74            period,
75            prices: VecDeque::with_capacity(period + 1),
76            abs_changes: VecDeque::with_capacity(period),
77            abs_sum: 0.0,
78            prev: None,
79            seed_gain: 0.0,
80            seed_loss: 0.0,
81            seed_count: 0,
82            avg_gain: None,
83            avg_loss: None,
84            last: None,
85        })
86    }
87
88    /// Configured efficiency-ratio period.
89    pub const fn period(&self) -> usize {
90        self.period
91    }
92
93    /// Current value if available.
94    pub const fn value(&self) -> Option<f64> {
95        self.last
96    }
97
98    fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
99        let denom = avg_gain + avg_loss;
100        if denom == 0.0 {
101            50.0
102        } else {
103            100.0 * (avg_gain / denom)
104        }
105    }
106
107    fn efficiency_ratio(&self, price: f64) -> f64 {
108        let oldest = *self.prices.front().expect("window non-empty");
109        let direction = (price - oldest).abs();
110        if self.abs_sum == 0.0 {
111            0.0
112        } else {
113            (direction / self.abs_sum).clamp(0.0, 1.0)
114        }
115    }
116}
117
118impl Indicator for AdaptiveRsi {
119    type Input = f64;
120    type Output = f64;
121
122    fn update(&mut self, price: f64) -> Option<f64> {
123        if !price.is_finite() {
124            return None;
125        }
126        let Some(prev) = self.prev else {
127            self.prev = Some(price);
128            self.prices.push_back(price);
129            return None;
130        };
131        let change = price - prev;
132        self.prev = Some(price);
133        let gain = if change > 0.0 { change } else { 0.0 };
134        let loss = if change < 0.0 { -change } else { 0.0 };
135
136        // Maintain the price window (period + 1) and the |Δ| window (period).
137        self.prices.push_back(price);
138        if self.prices.len() > self.period + 1 {
139            self.prices.pop_front();
140        }
141        if self.abs_changes.len() == self.period {
142            self.abs_sum -= self.abs_changes.pop_front().expect("non-empty");
143        }
144        self.abs_changes.push_back(change.abs());
145        self.abs_sum += change.abs();
146
147        if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
148            let er = self.efficiency_ratio(price);
149            let fast = 2.0 / 3.0;
150            let slow = 2.0 / 31.0;
151            let sc = (er * (fast - slow) + slow).powi(2);
152            let new_ag = ag + sc * (gain - ag);
153            let new_al = al + sc * (loss - al);
154            self.avg_gain = Some(new_ag);
155            self.avg_loss = Some(new_al);
156            let v = Self::rsi_from_avgs(new_ag, new_al);
157            self.last = Some(v);
158            return Some(v);
159        }
160
161        self.seed_gain += gain;
162        self.seed_loss += loss;
163        self.seed_count += 1;
164        if self.seed_count == self.period {
165            let ag = self.seed_gain / self.period as f64;
166            let al = self.seed_loss / self.period as f64;
167            self.avg_gain = Some(ag);
168            self.avg_loss = Some(al);
169            let v = Self::rsi_from_avgs(ag, al);
170            self.last = Some(v);
171            return Some(v);
172        }
173        None
174    }
175
176    fn reset(&mut self) {
177        self.prices.clear();
178        self.abs_changes.clear();
179        self.abs_sum = 0.0;
180        self.prev = None;
181        self.seed_gain = 0.0;
182        self.seed_loss = 0.0;
183        self.seed_count = 0;
184        self.avg_gain = None;
185        self.avg_loss = None;
186        self.last = None;
187    }
188
189    #[inline]
190    fn warmup_period(&self) -> usize {
191        self.period + 1
192    }
193
194    #[inline]
195    fn is_ready(&self) -> bool {
196        self.last.is_some()
197    }
198
199    #[inline]
200    fn name(&self) -> &'static str {
201        "AdaptiveRsi"
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::traits::BatchExt;
209    use approx::assert_relative_eq;
210
211    #[test]
212    fn rejects_zero_period() {
213        assert!(matches!(AdaptiveRsi::new(0), Err(Error::PeriodZero)));
214    }
215
216    #[test]
217    fn accessors_and_metadata() {
218        let r = AdaptiveRsi::new(14).unwrap();
219        assert_eq!(r.period(), 14);
220        assert_eq!(r.warmup_period(), 15);
221        assert_eq!(r.name(), "AdaptiveRsi");
222        assert!(!r.is_ready());
223        assert_eq!(r.value(), None);
224    }
225
226    #[test]
227    fn first_emission_at_warmup_period() {
228        let mut r = AdaptiveRsi::new(4).unwrap();
229        let out = r.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
230        for v in out.iter().take(4) {
231            assert!(v.is_none());
232        }
233        assert!(out[4].is_some());
234    }
235
236    #[test]
237    fn pure_uptrend_is_one_hundred() {
238        let mut r = AdaptiveRsi::new(5).unwrap();
239        let last = r
240            .batch(&(1..=40).map(f64::from).collect::<Vec<_>>())
241            .into_iter()
242            .flatten()
243            .last()
244            .unwrap();
245        assert_relative_eq!(last, 100.0, epsilon = 1e-9);
246    }
247
248    #[test]
249    fn flat_market_is_neutral() {
250        let mut r = AdaptiveRsi::new(4).unwrap();
251        let last = r.batch(&[7.0; 20]).into_iter().flatten().last().unwrap();
252        assert_relative_eq!(last, 50.0, epsilon = 1e-9);
253    }
254
255    #[test]
256    fn output_in_range() {
257        let mut r = AdaptiveRsi::new(14).unwrap();
258        for v in r
259            .batch(
260                &(0..200)
261                    .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
262                    .collect::<Vec<_>>(),
263            )
264            .into_iter()
265            .flatten()
266        {
267            assert!((0.0..=100.0).contains(&v));
268        }
269    }
270
271    #[test]
272    fn ignores_non_finite() {
273        let mut r = AdaptiveRsi::new(4).unwrap();
274        let _ready = r
275            .batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
276            .into_iter()
277            .flatten()
278            .last()
279            .unwrap();
280        assert_eq!(r.update(f64::NAN), None);
281    }
282
283    #[test]
284    fn reset_clears_state() {
285        let mut r = AdaptiveRsi::new(4).unwrap();
286        r.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
287        assert!(r.is_ready());
288        r.reset();
289        assert!(!r.is_ready());
290        assert_eq!(r.value(), None);
291        assert_eq!(r.update(1.0), None);
292    }
293
294    #[test]
295    fn batch_equals_streaming() {
296        let xs: Vec<f64> = (0..120)
297            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
298            .collect();
299        let batch = AdaptiveRsi::new(14).unwrap().batch(&xs);
300        let mut b = AdaptiveRsi::new(14).unwrap();
301        let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
302        assert_eq!(batch, streamed);
303    }
304}