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 #[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 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 #[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 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 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 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 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}