wickra_core/indicators/
intraday_momentum_index.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
42pub struct IntradayMomentumIndex {
43 period: usize,
44 window: VecDeque<(f64, f64)>,
46 sum_gain: f64,
47 sum_loss: f64,
48}
49
50impl IntradayMomentumIndex {
51 pub fn new(period: usize) -> Result<Self> {
57 if period == 0 {
58 return Err(Error::PeriodZero);
59 }
60 if period > crate::error::MAX_PERIOD {
61 return Err(Error::InvalidPeriod {
62 message: crate::error::PERIOD_ABOVE_MAX,
63 });
64 }
65 Ok(Self {
66 period,
67 window: VecDeque::with_capacity(period),
68 sum_gain: 0.0,
69 sum_loss: 0.0,
70 })
71 }
72
73 pub const fn period(&self) -> usize {
75 self.period
76 }
77
78 pub fn value(&self) -> Option<f64> {
80 if self.window.len() != self.period {
81 return None;
82 }
83 let denom = self.sum_gain + self.sum_loss;
84 if denom == 0.0 {
85 Some(50.0)
86 } else {
87 Some(100.0 * self.sum_gain / denom)
88 }
89 }
90}
91
92impl Indicator for IntradayMomentumIndex {
93 type Input = Candle;
94 type Output = f64;
95
96 #[inline]
97 fn update(&mut self, candle: Candle) -> Option<f64> {
98 let body = candle.close - candle.open;
99 let gain = if body > 0.0 { body } else { 0.0 };
100 let loss = if body < 0.0 { -body } else { 0.0 };
101
102 if self.window.len() == self.period {
103 let (old_g, old_l) = self.window.pop_front().expect("window full");
104 self.sum_gain -= old_g;
105 self.sum_loss -= old_l;
106 }
107 self.window.push_back((gain, loss));
108 self.sum_gain += gain;
109 self.sum_loss += loss;
110 self.value()
111 }
112
113 fn reset(&mut self) {
114 self.window.clear();
115 self.sum_gain = 0.0;
116 self.sum_loss = 0.0;
117 }
118
119 #[inline]
120 fn warmup_period(&self) -> usize {
121 self.period
122 }
123
124 #[inline]
125 fn is_ready(&self) -> bool {
126 self.window.len() == self.period
127 }
128
129 #[inline]
130 fn name(&self) -> &'static str {
131 "IMI"
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use crate::traits::BatchExt;
139 use approx::assert_relative_eq;
140
141 fn candle(open: f64, close: f64) -> Candle {
142 let hi = open.max(close) + 1.0;
143 let lo = open.min(close) - 1.0;
144 Candle::new(open, hi, lo, close, 1.0, 0).unwrap()
145 }
146
147 #[test]
148 fn rejects_zero_period() {
149 assert!(matches!(
150 IntradayMomentumIndex::new(0),
151 Err(Error::PeriodZero)
152 ));
153 }
154
155 #[test]
158 fn accessors_and_metadata() {
159 let imi = IntradayMomentumIndex::new(14).unwrap();
160 assert_eq!(imi.period(), 14);
161 assert_eq!(imi.warmup_period(), 14);
162 assert_eq!(imi.name(), "IMI");
163 }
164
165 #[test]
166 fn all_up_bodies_is_one_hundred() {
167 let mut imi = IntradayMomentumIndex::new(3).unwrap();
168 let bars = [candle(10.0, 11.0), candle(11.0, 13.0), candle(13.0, 14.0)];
169 let out = imi.batch(&bars);
170 assert!(out[0].is_none());
171 assert!(out[1].is_none());
172 assert_relative_eq!(out[2].unwrap(), 100.0, epsilon = 1e-12);
173 }
174
175 #[test]
176 fn all_down_bodies_is_zero() {
177 let mut imi = IntradayMomentumIndex::new(3).unwrap();
178 let bars = [candle(14.0, 13.0), candle(13.0, 11.0), candle(11.0, 10.0)];
179 assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 0.0, epsilon = 1e-12);
180 }
181
182 #[test]
183 fn known_value_mixed_bodies() {
184 let mut imi = IntradayMomentumIndex::new(3).unwrap();
186 let bars = [candle(10.0, 11.0), candle(11.0, 10.0), candle(10.0, 12.0)];
187 assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 75.0, epsilon = 1e-12);
188 }
189
190 #[test]
191 fn doji_window_is_neutral() {
192 let mut imi = IntradayMomentumIndex::new(3).unwrap();
194 let bars = [candle(10.0, 10.0), candle(11.0, 11.0), candle(12.0, 12.0)];
195 assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 50.0, epsilon = 1e-12);
196 }
197
198 #[test]
199 fn slides_window() {
200 let mut imi = IntradayMomentumIndex::new(3).unwrap();
202 let bars = [
203 candle(10.0, 11.0),
204 candle(11.0, 10.0),
205 candle(10.0, 12.0),
206 candle(12.0, 12.0),
207 ];
208 let out = imi.batch(&bars);
209 assert_relative_eq!(out[3].unwrap(), 100.0 * 2.0 / 3.0, epsilon = 1e-12);
210 }
211
212 #[test]
213 fn reset_clears_state() {
214 let mut imi = IntradayMomentumIndex::new(3).unwrap();
215 imi.batch(&[candle(10.0, 11.0), candle(11.0, 12.0), candle(12.0, 13.0)]);
216 assert!(imi.is_ready());
217 imi.reset();
218 assert!(!imi.is_ready());
219 assert_eq!(imi.update(candle(1.0, 2.0)), None);
220 }
221
222 #[test]
223 fn batch_equals_streaming() {
224 let bars: Vec<Candle> = (0..30)
225 .map(|i| {
226 let base = 100.0 + f64::from(i);
227 candle(base, base + (f64::from(i) * 0.5).sin())
228 })
229 .collect();
230 let mut a = IntradayMomentumIndex::new(7).unwrap();
231 let mut b = IntradayMomentumIndex::new(7).unwrap();
232 assert_eq!(
233 a.batch(&bars),
234 bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
235 );
236 }
237}