Skip to main content

wickra_core/indicators/
rmi.rs

1//! Relative Momentum Index (RMI).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Relative Momentum Index — RSI generalised to a multi-bar momentum lookback.
9///
10/// Wilder's [`Rsi`](crate::Rsi) compares each close to the *previous* close.
11/// The RMI (Roger Altman, 1993) compares it to the close `momentum` bars ago,
12/// then applies the same Wilder-smoothed up/down accumulator over `period`:
13///
14/// ```text
15/// change_t = close_t - close_{t-momentum}
16/// gain     = max(change, 0),  loss = max(-change, 0)
17/// avg_gain, avg_loss = Wilder-smoothed over `period`
18/// RMI      = 100 * avg_gain / (avg_gain + avg_loss)
19/// ```
20///
21/// `momentum = 1` reduces the RMI exactly to the RSI. Larger `momentum` makes
22/// the oscillator smoother and slower to flip, holding overbought/oversold
23/// readings longer in a trend. Output is bounded in `[0, 100]`; a flat market
24/// (no gains and no losses) returns the neutral `50`.
25///
26/// The first value lands after `momentum + period` inputs: `momentum` to fill
27/// the lookback, then `period` changes to seed Wilder's averages.
28///
29/// # Example
30///
31/// ```
32/// use wickra_core::{Indicator, Rmi};
33///
34/// let mut indicator = Rmi::new(14, 5).unwrap();
35/// let mut last = None;
36/// for i in 0..80 {
37///     last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
38/// }
39/// assert!(last.is_some());
40/// ```
41#[derive(Debug, Clone)]
42pub struct Rmi {
43    period: usize,
44    momentum: usize,
45    /// The last `momentum` prices, oldest at the front.
46    window: VecDeque<f64>,
47    seed_gains: Vec<f64>,
48    seed_losses: Vec<f64>,
49    avg_gain: Option<f64>,
50    avg_loss: Option<f64>,
51    last_value: Option<f64>,
52}
53
54impl Rmi {
55    /// Construct an RMI with the given smoothing `period` and `momentum`
56    /// lookback.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`Error::PeriodZero`] if either `period` or `momentum` is `0`.
61    pub fn new(period: usize, momentum: usize) -> Result<Self> {
62        if period == 0 || momentum == 0 {
63            return Err(Error::PeriodZero);
64        }
65        Ok(Self {
66            period,
67            momentum,
68            window: VecDeque::with_capacity(momentum),
69            seed_gains: Vec::with_capacity(period),
70            seed_losses: Vec::with_capacity(period),
71            avg_gain: None,
72            avg_loss: None,
73            last_value: None,
74        })
75    }
76
77    /// Configured smoothing period.
78    pub const fn period(&self) -> usize {
79        self.period
80    }
81
82    /// Configured momentum lookback.
83    pub const fn momentum(&self) -> usize {
84        self.momentum
85    }
86
87    /// Current value if available.
88    pub const fn value(&self) -> Option<f64> {
89        self.last_value
90    }
91
92    fn rmi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
93        let denom = avg_gain + avg_loss;
94        if denom == 0.0 {
95            50.0
96        } else {
97            // Ratio first, then scale, so `100 * g / g` cannot round above 100.
98            100.0 * (avg_gain / denom)
99        }
100    }
101}
102
103impl Indicator for Rmi {
104    type Input = f64;
105    type Output = f64;
106
107    fn update(&mut self, input: f64) -> Option<f64> {
108        if !input.is_finite() {
109            return self.last_value;
110        }
111        if self.window.len() < self.momentum {
112            // Still filling the momentum lookback; no change to measure yet.
113            self.window.push_back(input);
114            return None;
115        }
116        let past = self.window.pop_front().expect("window full");
117        self.window.push_back(input);
118
119        let change = input - past;
120        let gain = if change > 0.0 { change } else { 0.0 };
121        let loss = if change < 0.0 { -change } else { 0.0 };
122
123        if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
124            let n = self.period as f64;
125            let new_ag = (ag * (n - 1.0) + gain) / n;
126            let new_al = (al * (n - 1.0) + loss) / n;
127            self.avg_gain = Some(new_ag);
128            self.avg_loss = Some(new_al);
129            let v = Self::rmi_from_avgs(new_ag, new_al);
130            self.last_value = Some(v);
131            return Some(v);
132        }
133
134        self.seed_gains.push(gain);
135        self.seed_losses.push(loss);
136        if self.seed_gains.len() == self.period {
137            let ag = self.seed_gains.iter().sum::<f64>() / self.period as f64;
138            let al = self.seed_losses.iter().sum::<f64>() / self.period as f64;
139            self.avg_gain = Some(ag);
140            self.avg_loss = Some(al);
141            let v = Self::rmi_from_avgs(ag, al);
142            self.last_value = Some(v);
143            return Some(v);
144        }
145        None
146    }
147
148    fn reset(&mut self) {
149        self.window.clear();
150        self.seed_gains.clear();
151        self.seed_losses.clear();
152        self.avg_gain = None;
153        self.avg_loss = None;
154        self.last_value = None;
155    }
156
157    fn warmup_period(&self) -> usize {
158        self.momentum + self.period
159    }
160
161    fn is_ready(&self) -> bool {
162        self.last_value.is_some()
163    }
164
165    fn name(&self) -> &'static str {
166        "RMI"
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::indicators::Rsi;
174    use crate::traits::BatchExt;
175    use approx::assert_relative_eq;
176
177    #[test]
178    fn rejects_zero_params() {
179        assert!(matches!(Rmi::new(0, 5), Err(Error::PeriodZero)));
180        assert!(matches!(Rmi::new(14, 0), Err(Error::PeriodZero)));
181    }
182
183    /// Cover the const accessors `period` + `momentum` + `value` and the
184    /// Indicator-impl `warmup_period` + `name`.
185    #[test]
186    fn accessors_and_metadata() {
187        let rmi = Rmi::new(14, 5).unwrap();
188        assert_eq!(rmi.period(), 14);
189        assert_eq!(rmi.momentum(), 5);
190        assert_eq!(rmi.value(), None);
191        assert_eq!(rmi.warmup_period(), 19);
192        assert_eq!(rmi.name(), "RMI");
193    }
194
195    #[test]
196    fn momentum_one_equals_rsi() {
197        // With momentum = 1 the RMI is exactly Wilder's RSI.
198        let prices: Vec<f64> = (0..60)
199            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
200            .collect();
201        let mut rmi = Rmi::new(14, 1).unwrap();
202        let mut rsi = Rsi::new(14).unwrap();
203        for (i, &p) in prices.iter().enumerate() {
204            let got = rmi.update(p);
205            let want = rsi.update(p);
206            assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
207            if let (Some(a), Some(b)) = (got, want) {
208                assert_relative_eq!(a, b, epsilon = 1e-9);
209            }
210        }
211    }
212
213    #[test]
214    fn warmup_then_emits() {
215        // momentum + period = 3 + 2 = 5 inputs before the first value.
216        let mut rmi = Rmi::new(2, 3).unwrap();
217        let out = rmi.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
218        for (i, v) in out.iter().enumerate().take(4) {
219            assert!(v.is_none(), "index {i} must be None during warmup");
220        }
221        assert!(out[4].is_some(), "first value at warmup_period - 1");
222    }
223
224    #[test]
225    fn pure_uptrend_is_one_hundred() {
226        // Every momentum-spaced change is positive -> avg_loss 0 -> RMI 100.
227        let prices: Vec<f64> = (1..=40).map(f64::from).collect();
228        let mut rmi = Rmi::new(5, 3).unwrap();
229        let last = rmi.batch(&prices).into_iter().flatten().last().unwrap();
230        assert_relative_eq!(last, 100.0, epsilon = 1e-9);
231    }
232
233    #[test]
234    fn flat_market_is_neutral() {
235        // No change -> no gains and no losses -> neutral 50.
236        let mut rmi = Rmi::new(3, 2).unwrap();
237        let last = rmi.batch(&[7.0; 20]).into_iter().flatten().last().unwrap();
238        assert_relative_eq!(last, 50.0, epsilon = 1e-12);
239    }
240
241    #[test]
242    fn ignores_non_finite_input() {
243        let mut rmi = Rmi::new(2, 2).unwrap();
244        let ready = rmi
245            .batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
246            .into_iter()
247            .flatten()
248            .last()
249            .unwrap();
250        assert_eq!(rmi.update(f64::NAN), Some(ready));
251        assert_eq!(rmi.update(f64::INFINITY), Some(ready));
252    }
253
254    #[test]
255    fn reset_clears_state() {
256        let mut rmi = Rmi::new(3, 2).unwrap();
257        rmi.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
258        assert!(rmi.is_ready());
259        rmi.reset();
260        assert!(!rmi.is_ready());
261        assert_eq!(rmi.update(1.0), None);
262    }
263
264    #[test]
265    fn batch_equals_streaming() {
266        let prices: Vec<f64> = (1..=40)
267            .map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
268            .collect();
269        let mut a = Rmi::new(14, 5).unwrap();
270        let mut b = Rmi::new(14, 5).unwrap();
271        assert_eq!(
272            a.batch(&prices),
273            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
274        );
275    }
276}