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        Ok(Self {
69            period,
70            prices: VecDeque::with_capacity(period + 1),
71            abs_changes: VecDeque::with_capacity(period),
72            abs_sum: 0.0,
73            prev: None,
74            seed_gain: 0.0,
75            seed_loss: 0.0,
76            seed_count: 0,
77            avg_gain: None,
78            avg_loss: None,
79            last: None,
80        })
81    }
82
83    /// Configured efficiency-ratio period.
84    pub const fn period(&self) -> usize {
85        self.period
86    }
87
88    /// Current value if available.
89    pub const fn value(&self) -> Option<f64> {
90        self.last
91    }
92
93    fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
94        let denom = avg_gain + avg_loss;
95        if denom == 0.0 {
96            50.0
97        } else {
98            100.0 * (avg_gain / denom)
99        }
100    }
101
102    fn efficiency_ratio(&self, price: f64) -> f64 {
103        let oldest = *self.prices.front().expect("window non-empty");
104        let direction = (price - oldest).abs();
105        if self.abs_sum == 0.0 {
106            0.0
107        } else {
108            (direction / self.abs_sum).clamp(0.0, 1.0)
109        }
110    }
111}
112
113impl Indicator for AdaptiveRsi {
114    type Input = f64;
115    type Output = f64;
116
117    fn update(&mut self, price: f64) -> Option<f64> {
118        if !price.is_finite() {
119            return self.last;
120        }
121        let Some(prev) = self.prev else {
122            self.prev = Some(price);
123            self.prices.push_back(price);
124            return None;
125        };
126        let change = price - prev;
127        self.prev = Some(price);
128        let gain = if change > 0.0 { change } else { 0.0 };
129        let loss = if change < 0.0 { -change } else { 0.0 };
130
131        // Maintain the price window (period + 1) and the |Δ| window (period).
132        self.prices.push_back(price);
133        if self.prices.len() > self.period + 1 {
134            self.prices.pop_front();
135        }
136        if self.abs_changes.len() == self.period {
137            self.abs_sum -= self.abs_changes.pop_front().expect("non-empty");
138        }
139        self.abs_changes.push_back(change.abs());
140        self.abs_sum += change.abs();
141
142        if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
143            let er = self.efficiency_ratio(price);
144            let fast = 2.0 / 3.0;
145            let slow = 2.0 / 31.0;
146            let sc = (er * (fast - slow) + slow).powi(2);
147            let new_ag = ag + sc * (gain - ag);
148            let new_al = al + sc * (loss - al);
149            self.avg_gain = Some(new_ag);
150            self.avg_loss = Some(new_al);
151            let v = Self::rsi_from_avgs(new_ag, new_al);
152            self.last = Some(v);
153            return Some(v);
154        }
155
156        self.seed_gain += gain;
157        self.seed_loss += loss;
158        self.seed_count += 1;
159        if self.seed_count == self.period {
160            let ag = self.seed_gain / self.period as f64;
161            let al = self.seed_loss / self.period as f64;
162            self.avg_gain = Some(ag);
163            self.avg_loss = Some(al);
164            let v = Self::rsi_from_avgs(ag, al);
165            self.last = Some(v);
166            return Some(v);
167        }
168        None
169    }
170
171    fn reset(&mut self) {
172        self.prices.clear();
173        self.abs_changes.clear();
174        self.abs_sum = 0.0;
175        self.prev = None;
176        self.seed_gain = 0.0;
177        self.seed_loss = 0.0;
178        self.seed_count = 0;
179        self.avg_gain = None;
180        self.avg_loss = None;
181        self.last = None;
182    }
183
184    fn warmup_period(&self) -> usize {
185        self.period + 1
186    }
187
188    fn is_ready(&self) -> bool {
189        self.last.is_some()
190    }
191
192    fn name(&self) -> &'static str {
193        "AdaptiveRsi"
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::traits::BatchExt;
201    use approx::assert_relative_eq;
202
203    #[test]
204    fn rejects_zero_period() {
205        assert!(matches!(AdaptiveRsi::new(0), Err(Error::PeriodZero)));
206    }
207
208    #[test]
209    fn accessors_and_metadata() {
210        let r = AdaptiveRsi::new(14).unwrap();
211        assert_eq!(r.period(), 14);
212        assert_eq!(r.warmup_period(), 15);
213        assert_eq!(r.name(), "AdaptiveRsi");
214        assert!(!r.is_ready());
215        assert_eq!(r.value(), None);
216    }
217
218    #[test]
219    fn first_emission_at_warmup_period() {
220        let mut r = AdaptiveRsi::new(4).unwrap();
221        let out = r.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
222        for v in out.iter().take(4) {
223            assert!(v.is_none());
224        }
225        assert!(out[4].is_some());
226    }
227
228    #[test]
229    fn pure_uptrend_is_one_hundred() {
230        let mut r = AdaptiveRsi::new(5).unwrap();
231        let last = r
232            .batch(&(1..=40).map(f64::from).collect::<Vec<_>>())
233            .into_iter()
234            .flatten()
235            .last()
236            .unwrap();
237        assert_relative_eq!(last, 100.0, epsilon = 1e-9);
238    }
239
240    #[test]
241    fn flat_market_is_neutral() {
242        let mut r = AdaptiveRsi::new(4).unwrap();
243        let last = r.batch(&[7.0; 20]).into_iter().flatten().last().unwrap();
244        assert_relative_eq!(last, 50.0, epsilon = 1e-9);
245    }
246
247    #[test]
248    fn output_in_range() {
249        let mut r = AdaptiveRsi::new(14).unwrap();
250        for v in r
251            .batch(
252                &(0..200)
253                    .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
254                    .collect::<Vec<_>>(),
255            )
256            .into_iter()
257            .flatten()
258        {
259            assert!((0.0..=100.0).contains(&v));
260        }
261    }
262
263    #[test]
264    fn ignores_non_finite() {
265        let mut r = AdaptiveRsi::new(4).unwrap();
266        let ready = r
267            .batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
268            .into_iter()
269            .flatten()
270            .last()
271            .unwrap();
272        assert_eq!(r.update(f64::NAN), Some(ready));
273    }
274
275    #[test]
276    fn reset_clears_state() {
277        let mut r = AdaptiveRsi::new(4).unwrap();
278        r.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
279        assert!(r.is_ready());
280        r.reset();
281        assert!(!r.is_ready());
282        assert_eq!(r.value(), None);
283        assert_eq!(r.update(1.0), None);
284    }
285
286    #[test]
287    fn batch_equals_streaming() {
288        let xs: Vec<f64> = (0..120)
289            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
290            .collect();
291        let batch = AdaptiveRsi::new(14).unwrap().batch(&xs);
292        let mut b = AdaptiveRsi::new(14).unwrap();
293        let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
294        assert_eq!(batch, streamed);
295    }
296}