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    #[inline]
101    fn update(&mut self, input: f64) -> Option<f64> {
102        if !input.is_finite() {
103            return None;
104        }
105
106        if self.pending_anchor {
107            self.prev_close = None;
108            self.sum_gain = 0.0;
109            self.sum_loss = 0.0;
110            self.last_value = None;
111            self.pending_anchor = false;
112        }
113
114        let Some(prev) = self.prev_close else {
115            self.prev_close = Some(input);
116            return None;
117        };
118        self.prev_close = Some(input);
119
120        let diff = input - prev;
121        if diff > 0.0 {
122            self.sum_gain += diff;
123        } else if diff < 0.0 {
124            self.sum_loss -= diff;
125        }
126
127        let value = Self::rsi_from_sums(self.sum_gain, self.sum_loss);
128        self.last_value = Some(value);
129        Some(value)
130    }
131
132    fn reset(&mut self) {
133        self.prev_close = None;
134        self.sum_gain = 0.0;
135        self.sum_loss = 0.0;
136        self.last_value = None;
137        self.pending_anchor = false;
138    }
139
140    #[inline]
141    fn warmup_period(&self) -> usize {
142        2
143    }
144
145    #[inline]
146    fn is_ready(&self) -> bool {
147        self.last_value.is_some()
148    }
149
150    #[inline]
151    fn name(&self) -> &'static str {
152        "AnchoredRSI"
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::traits::BatchExt;
160    use approx::assert_relative_eq;
161
162    #[test]
163    fn accessors_and_metadata() {
164        let indicator = AnchoredRsi::new();
165        assert_eq!(indicator.name(), "AnchoredRSI");
166        assert_eq!(indicator.warmup_period(), 2);
167        assert_eq!(indicator.value(), None);
168        assert!(!indicator.is_ready());
169    }
170
171    #[test]
172    fn first_bar_seeds_and_returns_none() {
173        let mut indicator = AnchoredRsi::new();
174        assert_eq!(indicator.update(100.0), None);
175        assert!(!indicator.is_ready());
176        // Second bar produces the first value.
177        assert!(indicator.update(101.0).is_some());
178        assert!(indicator.is_ready());
179    }
180
181    #[test]
182    fn pure_uptrend_saturates_at_100() {
183        let mut indicator = AnchoredRsi::new();
184        let out = indicator.batch(&[10.0, 11.0, 12.0, 13.0]);
185        assert_relative_eq!(out[3].unwrap(), 100.0, epsilon = 1e-12);
186    }
187
188    #[test]
189    fn pure_downtrend_saturates_at_0() {
190        let mut indicator = AnchoredRsi::new();
191        let out = indicator.batch(&[13.0, 12.0, 11.0, 10.0]);
192        assert_relative_eq!(out[3].unwrap(), 0.0, epsilon = 1e-12);
193    }
194
195    #[test]
196    fn flat_window_reads_50() {
197        let mut indicator = AnchoredRsi::new();
198        let out = indicator.batch(&[42.0, 42.0, 42.0]);
199        assert_relative_eq!(out[2].unwrap(), 50.0, epsilon = 1e-12);
200    }
201
202    #[test]
203    fn cumulative_reference_values() {
204        // prices 10 -> 11 (+1) -> 9 (-2) -> 12 (+3)
205        // after bar2: sum_gain=1, sum_loss=2 -> rs=0.5 -> 100 - 100/1.5 = 33.3333
206        // after bar3: sum_gain=4, sum_loss=2 -> rs=2.0 -> 100 - 100/3   = 66.6667
207        let mut indicator = AnchoredRsi::new();
208        let out = indicator.batch(&[10.0, 11.0, 9.0, 12.0]);
209        assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9);
210        assert_relative_eq!(out[2].unwrap(), 33.333_333_333, epsilon = 1e-6);
211        assert_relative_eq!(out[3].unwrap(), 66.666_666_666, epsilon = 1e-6);
212    }
213
214    #[test]
215    fn set_anchor_clears_old_window() {
216        // Downtrend, then re-anchor and pump an uptrend: the new window must
217        // read 100, not the blended value.
218        let mut indicator = AnchoredRsi::new();
219        indicator.batch(&[20.0, 19.0, 18.0, 17.0]);
220        assert_relative_eq!(indicator.value().unwrap(), 0.0, epsilon = 1e-12);
221        indicator.set_anchor();
222        // First bar after anchor re-seeds (None), second bar emits.
223        assert_eq!(indicator.update(50.0), None);
224        let after = indicator.update(51.0).unwrap();
225        assert_relative_eq!(after, 100.0, epsilon = 1e-12);
226    }
227
228    #[test]
229    fn set_anchor_before_first_bar_acts_as_normal_start() {
230        let mut indicator = AnchoredRsi::new();
231        indicator.set_anchor();
232        assert_eq!(indicator.update(10.0), None);
233        assert_relative_eq!(indicator.update(11.0).unwrap(), 100.0, epsilon = 1e-12);
234    }
235
236    #[test]
237    fn ignores_non_finite_input() {
238        let mut indicator = AnchoredRsi::new();
239        indicator.batch(&[10.0, 11.0, 12.0]);
240        let before = indicator.value();
241        assert!(before.is_some());
242        assert_eq!(indicator.update(f64::NAN), None);
243        assert_eq!(indicator.update(f64::INFINITY), None);
244        assert_eq!(indicator.value(), before);
245    }
246
247    #[test]
248    fn non_finite_before_any_bar_returns_none() {
249        let mut indicator = AnchoredRsi::new();
250        assert_eq!(indicator.update(f64::NAN), None);
251        assert!(!indicator.is_ready());
252    }
253
254    #[test]
255    fn reset_clears_state() {
256        let mut indicator = AnchoredRsi::new();
257        indicator.batch(&[10.0, 11.0, 12.0]);
258        assert!(indicator.is_ready());
259        indicator.reset();
260        assert!(!indicator.is_ready());
261        assert_eq!(indicator.value(), None);
262        assert_eq!(indicator.update(50.0), None);
263    }
264
265    #[test]
266    fn stays_in_0_100_range() {
267        let prices: Vec<f64> = (0..200)
268            .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 10.0)
269            .collect();
270        let mut indicator = AnchoredRsi::new();
271        for value in indicator.batch(&prices).into_iter().flatten() {
272            assert!((0.0..=100.0).contains(&value), "RSI out of range: {value}");
273        }
274    }
275
276    #[test]
277    fn batch_equals_streaming() {
278        let prices: Vec<f64> = (1..=40)
279            .map(|i| (f64::from(i) * 0.3).sin() * 5.0 + f64::from(i))
280            .collect();
281        let mut a = AnchoredRsi::new();
282        let mut b = AnchoredRsi::new();
283        assert_eq!(
284            a.batch(&prices),
285            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
286        );
287    }
288}