wickra_core/indicators/
rmi.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
42pub struct Rmi {
43 period: usize,
44 momentum: usize,
45 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 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 pub const fn period(&self) -> usize {
79 self.period
80 }
81
82 pub const fn momentum(&self) -> usize {
84 self.momentum
85 }
86
87 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 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 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 #[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 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 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 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 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}