Skip to main content

wickra_core/indicators/
anchored_rsi.rs

1//! Anchored Relative Strength Index.
2
3use crate::traits::Indicator;
4
5/// Anchored RSI — a cumulative Relative Strength Index whose averaging begins at
6/// a user-chosen anchor bar rather than over a fixed Wilder period.
7///
8/// Where [`crate::Rsi`] uses Wilder's `period`-length smoothing, Anchored RSI
9/// accumulates *every* up- and down-move since the anchor with equal weight, so
10/// it answers "what is the RSI of the entire move since the anchor point?". The
11/// running relative strength is `Σ gains / Σ losses` over all bars in the
12/// current anchor window (the bar count cancels, so this equals
13/// `avg_gain / avg_loss`):
14///
15/// ```text
16/// RSI_t = 100 - 100 / (1 + Σ_{i ≥ anchor} gain_i / Σ_{i ≥ anchor} loss_i)
17/// ```
18///
19/// As with [`crate::AnchoredVwap`], the anchor is chosen at runtime:
20/// [`AnchoredRsi::set_anchor`] re-anchors at the **next** bar that arrives,
21/// clearing the running sums. Because RSI needs a price *change*, the first bar
22/// of a fresh anchor window only seeds the previous close and emits `None`; the
23/// first value follows on the second bar (warmup period 2).
24///
25/// Saturation follows the standard convention: a window with no losses yet (and
26/// at least one gain) reads 100, no gains yet reads 0, and a perfectly flat
27/// window reads the neutral 50. Non-finite inputs are ignored, leaving the last
28/// value unchanged.
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{AnchoredRsi, Indicator};
34///
35/// let mut indicator = AnchoredRsi::new();
36/// let mut last = None;
37/// for i in 0..80 {
38///     let price = 100.0 + (f64::from(i) * 0.5).sin() * 5.0;
39///     // Re-anchor at bar 40 (e.g. a major swing low).
40///     if i == 40 {
41///         indicator.set_anchor();
42///     }
43///     last = indicator.update(price);
44/// }
45/// assert!(last.is_some());
46/// ```
47#[derive(Debug, Clone, Default)]
48pub struct AnchoredRsi {
49    prev_close: Option<f64>,
50    sum_gain: f64,
51    sum_loss: f64,
52    last_value: Option<f64>,
53    pending_anchor: bool,
54}
55
56impl AnchoredRsi {
57    /// Construct a fresh Anchored RSI. The first bar to arrive is the anchor.
58    pub const fn new() -> Self {
59        Self {
60            prev_close: None,
61            sum_gain: 0.0,
62            sum_loss: 0.0,
63            last_value: None,
64            pending_anchor: false,
65        }
66    }
67
68    /// Mark a re-anchor: the **next** [`Indicator::update`] call clears the
69    /// running sums and previous close before folding in its own bar, starting
70    /// a fresh anchored window.
71    pub fn set_anchor(&mut self) {
72        self.pending_anchor = true;
73    }
74
75    /// Current anchored RSI value if at least one price change has been
76    /// observed in the current anchor window.
77    pub const fn value(&self) -> Option<f64> {
78        self.last_value
79    }
80
81    fn rsi_from_sums(sum_gain: f64, sum_loss: f64) -> f64 {
82        if sum_loss == 0.0 {
83            if sum_gain == 0.0 {
84                // No movement at all -> RSI undefined; standard convention returns 50.
85                50.0
86            } else {
87                100.0
88            }
89        } else {
90            let rs = sum_gain / sum_loss;
91            100.0 - 100.0 / (1.0 + rs)
92        }
93    }
94}
95
96impl Indicator for AnchoredRsi {
97    type Input = f64;
98    type Output = f64;
99
100    fn update(&mut self, input: f64) -> Option<f64> {
101        if !input.is_finite() {
102            return self.last_value;
103        }
104
105        if self.pending_anchor {
106            self.prev_close = None;
107            self.sum_gain = 0.0;
108            self.sum_loss = 0.0;
109            self.last_value = None;
110            self.pending_anchor = false;
111        }
112
113        let Some(prev) = self.prev_close else {
114            self.prev_close = Some(input);
115            return None;
116        };
117        self.prev_close = Some(input);
118
119        let diff = input - prev;
120        if diff > 0.0 {
121            self.sum_gain += diff;
122        } else if diff < 0.0 {
123            self.sum_loss -= diff;
124        }
125
126        let value = Self::rsi_from_sums(self.sum_gain, self.sum_loss);
127        self.last_value = Some(value);
128        Some(value)
129    }
130
131    fn reset(&mut self) {
132        self.prev_close = None;
133        self.sum_gain = 0.0;
134        self.sum_loss = 0.0;
135        self.last_value = None;
136        self.pending_anchor = false;
137    }
138
139    fn warmup_period(&self) -> usize {
140        2
141    }
142
143    fn is_ready(&self) -> bool {
144        self.last_value.is_some()
145    }
146
147    fn name(&self) -> &'static str {
148        "AnchoredRSI"
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::traits::BatchExt;
156    use approx::assert_relative_eq;
157
158    #[test]
159    fn accessors_and_metadata() {
160        let indicator = AnchoredRsi::new();
161        assert_eq!(indicator.name(), "AnchoredRSI");
162        assert_eq!(indicator.warmup_period(), 2);
163        assert_eq!(indicator.value(), None);
164        assert!(!indicator.is_ready());
165    }
166
167    #[test]
168    fn first_bar_seeds_and_returns_none() {
169        let mut indicator = AnchoredRsi::new();
170        assert_eq!(indicator.update(100.0), None);
171        assert!(!indicator.is_ready());
172        // Second bar produces the first value.
173        assert!(indicator.update(101.0).is_some());
174        assert!(indicator.is_ready());
175    }
176
177    #[test]
178    fn pure_uptrend_saturates_at_100() {
179        let mut indicator = AnchoredRsi::new();
180        let out = indicator.batch(&[10.0, 11.0, 12.0, 13.0]);
181        assert_relative_eq!(out[3].unwrap(), 100.0, epsilon = 1e-12);
182    }
183
184    #[test]
185    fn pure_downtrend_saturates_at_0() {
186        let mut indicator = AnchoredRsi::new();
187        let out = indicator.batch(&[13.0, 12.0, 11.0, 10.0]);
188        assert_relative_eq!(out[3].unwrap(), 0.0, epsilon = 1e-12);
189    }
190
191    #[test]
192    fn flat_window_reads_50() {
193        let mut indicator = AnchoredRsi::new();
194        let out = indicator.batch(&[42.0, 42.0, 42.0]);
195        assert_relative_eq!(out[2].unwrap(), 50.0, epsilon = 1e-12);
196    }
197
198    #[test]
199    fn cumulative_reference_values() {
200        // prices 10 -> 11 (+1) -> 9 (-2) -> 12 (+3)
201        // after bar2: sum_gain=1, sum_loss=2 -> rs=0.5 -> 100 - 100/1.5 = 33.3333
202        // after bar3: sum_gain=4, sum_loss=2 -> rs=2.0 -> 100 - 100/3   = 66.6667
203        let mut indicator = AnchoredRsi::new();
204        let out = indicator.batch(&[10.0, 11.0, 9.0, 12.0]);
205        assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9);
206        assert_relative_eq!(out[2].unwrap(), 33.333_333_333, epsilon = 1e-6);
207        assert_relative_eq!(out[3].unwrap(), 66.666_666_666, epsilon = 1e-6);
208    }
209
210    #[test]
211    fn set_anchor_clears_old_window() {
212        // Downtrend, then re-anchor and pump an uptrend: the new window must
213        // read 100, not the blended value.
214        let mut indicator = AnchoredRsi::new();
215        indicator.batch(&[20.0, 19.0, 18.0, 17.0]);
216        assert_relative_eq!(indicator.value().unwrap(), 0.0, epsilon = 1e-12);
217        indicator.set_anchor();
218        // First bar after anchor re-seeds (None), second bar emits.
219        assert_eq!(indicator.update(50.0), None);
220        let after = indicator.update(51.0).unwrap();
221        assert_relative_eq!(after, 100.0, epsilon = 1e-12);
222    }
223
224    #[test]
225    fn set_anchor_before_first_bar_acts_as_normal_start() {
226        let mut indicator = AnchoredRsi::new();
227        indicator.set_anchor();
228        assert_eq!(indicator.update(10.0), None);
229        assert_relative_eq!(indicator.update(11.0).unwrap(), 100.0, epsilon = 1e-12);
230    }
231
232    #[test]
233    fn ignores_non_finite_input() {
234        let mut indicator = AnchoredRsi::new();
235        indicator.batch(&[10.0, 11.0, 12.0]);
236        let before = indicator.value();
237        assert!(before.is_some());
238        assert_eq!(indicator.update(f64::NAN), before);
239        assert_eq!(indicator.update(f64::INFINITY), before);
240        assert_eq!(indicator.value(), before);
241    }
242
243    #[test]
244    fn non_finite_before_any_bar_returns_none() {
245        let mut indicator = AnchoredRsi::new();
246        assert_eq!(indicator.update(f64::NAN), None);
247        assert!(!indicator.is_ready());
248    }
249
250    #[test]
251    fn reset_clears_state() {
252        let mut indicator = AnchoredRsi::new();
253        indicator.batch(&[10.0, 11.0, 12.0]);
254        assert!(indicator.is_ready());
255        indicator.reset();
256        assert!(!indicator.is_ready());
257        assert_eq!(indicator.value(), None);
258        assert_eq!(indicator.update(50.0), None);
259    }
260
261    #[test]
262    fn stays_in_0_100_range() {
263        let prices: Vec<f64> = (0..200)
264            .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 10.0)
265            .collect();
266        let mut indicator = AnchoredRsi::new();
267        for value in indicator.batch(&prices).into_iter().flatten() {
268            assert!((0.0..=100.0).contains(&value), "RSI out of range: {value}");
269        }
270    }
271
272    #[test]
273    fn batch_equals_streaming() {
274        let prices: Vec<f64> = (1..=40)
275            .map(|i| (f64::from(i) * 0.3).sin() * 5.0 + f64::from(i))
276            .collect();
277        let mut a = AnchoredRsi::new();
278        let mut b = AnchoredRsi::new();
279        assert_eq!(
280            a.batch(&prices),
281            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
282        );
283    }
284}