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    #[inline]
108    fn update(&mut self, input: f64) -> Option<f64> {
109        if !input.is_finite() {
110            return None;
111        }
112        if self.window.len() < self.momentum {
113            // Still filling the momentum lookback; no change to measure yet.
114            self.window.push_back(input);
115            return None;
116        }
117        let past = self.window.pop_front().expect("window full");
118        self.window.push_back(input);
119
120        let change = input - past;
121        let gain = if change > 0.0 { change } else { 0.0 };
122        let loss = if change < 0.0 { -change } else { 0.0 };
123
124        if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
125            let n = self.period as f64;
126            let new_ag = (ag * (n - 1.0) + gain) / n;
127            let new_al = (al * (n - 1.0) + loss) / n;
128            self.avg_gain = Some(new_ag);
129            self.avg_loss = Some(new_al);
130            let v = Self::rmi_from_avgs(new_ag, new_al);
131            self.last_value = Some(v);
132            return Some(v);
133        }
134
135        self.seed_gains.push(gain);
136        self.seed_losses.push(loss);
137        if self.seed_gains.len() == self.period {
138            let ag = self.seed_gains.iter().sum::<f64>() / self.period as f64;
139            let al = self.seed_losses.iter().sum::<f64>() / self.period as f64;
140            self.avg_gain = Some(ag);
141            self.avg_loss = Some(al);
142            let v = Self::rmi_from_avgs(ag, al);
143            self.last_value = Some(v);
144            return Some(v);
145        }
146        None
147    }
148
149    fn reset(&mut self) {
150        self.window.clear();
151        self.seed_gains.clear();
152        self.seed_losses.clear();
153        self.avg_gain = None;
154        self.avg_loss = None;
155        self.last_value = None;
156    }
157
158    #[inline]
159    fn warmup_period(&self) -> usize {
160        self.momentum + self.period
161    }
162
163    #[inline]
164    fn is_ready(&self) -> bool {
165        self.last_value.is_some()
166    }
167
168    #[inline]
169    fn name(&self) -> &'static str {
170        "RMI"
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::indicators::Rsi;
178    use crate::traits::BatchExt;
179    use approx::assert_relative_eq;
180
181    #[test]
182    fn rejects_zero_params() {
183        assert!(matches!(Rmi::new(0, 5), Err(Error::PeriodZero)));
184        assert!(matches!(Rmi::new(14, 0), Err(Error::PeriodZero)));
185    }
186
187    /// Cover the const accessors `period` + `momentum` + `value` and the
188    /// Indicator-impl `warmup_period` + `name`.
189    #[test]
190    fn accessors_and_metadata() {
191        let rmi = Rmi::new(14, 5).unwrap();
192        assert_eq!(rmi.period(), 14);
193        assert_eq!(rmi.momentum(), 5);
194        assert_eq!(rmi.value(), None);
195        assert_eq!(rmi.warmup_period(), 19);
196        assert_eq!(rmi.name(), "RMI");
197    }
198
199    #[test]
200    fn momentum_one_equals_rsi() {
201        // With momentum = 1 the RMI is exactly Wilder's RSI.
202        let prices: Vec<f64> = (0..60)
203            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
204            .collect();
205        let mut rmi = Rmi::new(14, 1).unwrap();
206        let mut rsi = Rsi::new(14).unwrap();
207        for (i, &p) in prices.iter().enumerate() {
208            let got = rmi.update(p);
209            let want = rsi.update(p);
210            assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
211            if let (Some(a), Some(b)) = (got, want) {
212                assert_relative_eq!(a, b, epsilon = 1e-9);
213            }
214        }
215    }
216
217    #[test]
218    fn warmup_then_emits() {
219        // momentum + period = 3 + 2 = 5 inputs before the first value.
220        let mut rmi = Rmi::new(2, 3).unwrap();
221        let out = rmi.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
222        for (i, v) in out.iter().enumerate().take(4) {
223            assert!(v.is_none(), "index {i} must be None during warmup");
224        }
225        assert!(out[4].is_some(), "first value at warmup_period - 1");
226    }
227
228    #[test]
229    fn pure_uptrend_is_one_hundred() {
230        // Every momentum-spaced change is positive -> avg_loss 0 -> RMI 100.
231        let prices: Vec<f64> = (1..=40).map(f64::from).collect();
232        let mut rmi = Rmi::new(5, 3).unwrap();
233        let last = rmi.batch(&prices).into_iter().flatten().last().unwrap();
234        assert_relative_eq!(last, 100.0, epsilon = 1e-9);
235    }
236
237    #[test]
238    fn flat_market_is_neutral() {
239        // No change -> no gains and no losses -> neutral 50.
240        let mut rmi = Rmi::new(3, 2).unwrap();
241        let last = rmi.batch(&[7.0; 20]).into_iter().flatten().last().unwrap();
242        assert_relative_eq!(last, 50.0, epsilon = 1e-12);
243    }
244
245    #[test]
246    fn ignores_non_finite_input() {
247        let mut rmi = Rmi::new(2, 2).unwrap();
248        let _ready = rmi
249            .batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
250            .into_iter()
251            .flatten()
252            .last()
253            .unwrap();
254        assert_eq!(rmi.update(f64::NAN), None);
255        assert_eq!(rmi.update(f64::INFINITY), None);
256    }
257
258    #[test]
259    fn reset_clears_state() {
260        let mut rmi = Rmi::new(3, 2).unwrap();
261        rmi.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
262        assert!(rmi.is_ready());
263        rmi.reset();
264        assert!(!rmi.is_ready());
265        assert_eq!(rmi.update(1.0), None);
266    }
267
268    #[test]
269    fn batch_equals_streaming() {
270        let prices: Vec<f64> = (1..=40)
271            .map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
272            .collect();
273        let mut a = Rmi::new(14, 5).unwrap();
274        let mut b = Rmi::new(14, 5).unwrap();
275        assert_eq!(
276            a.batch(&prices),
277            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
278        );
279    }
280}