Skip to main content

wickra_core/indicators/
intraday_momentum_index.rs

1//! Intraday Momentum Index (IMI).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Intraday Momentum Index — Tushar Chande's RSI built from the open-to-close
10/// move instead of the close-to-close move.
11///
12/// For each bar the body is an up-move when `close > open` and a down-move
13/// otherwise; the IMI sums those bodies over `period` bars and forms the
14/// RSI-style ratio:
15///
16/// ```text
17/// gain = max(close - open, 0),  loss = max(open - close, 0)
18/// IMI  = 100 * Σ gain / (Σ gain + Σ loss)        over the last `period` bars
19/// ```
20///
21/// Because it measures *intraday* (body) momentum rather than the gap-inclusive
22/// close-to-close change, the IMI is a candle-pattern-flavoured overbought /
23/// oversold gauge: persistent white bodies push it up, black bodies down. It is
24/// bounded in `[0, 100]`; a window of doji-like bars (no net bodies) returns the
25/// neutral `50`.
26///
27/// # Example
28///
29/// ```
30/// use wickra_core::{Candle, IntradayMomentumIndex, Indicator};
31///
32/// let mut imi = IntradayMomentumIndex::new(14).unwrap();
33/// let mut last = None;
34/// for i in 0..40 {
35///     let base = 100.0 + f64::from(i);
36///     let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1.0, i64::from(i)).unwrap();
37///     last = imi.update(c);
38/// }
39/// assert!(last.is_some());
40/// ```
41#[derive(Debug, Clone)]
42pub struct IntradayMomentumIndex {
43    period: usize,
44    /// Per-bar `(gain, loss)` bodies, oldest at the front.
45    window: VecDeque<(f64, f64)>,
46    sum_gain: f64,
47    sum_loss: f64,
48}
49
50impl IntradayMomentumIndex {
51    /// Construct an IMI over `period` bars.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`Error::PeriodZero`] if `period == 0`.
56    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    /// Configured period.
74    pub const fn period(&self) -> usize {
75        self.period
76    }
77
78    /// Current value if the window is full.
79    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    /// Cover the const accessor `period` and the Indicator-impl `warmup_period`
156    /// + `name`.
157    #[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        // bodies: +1, -1, +2 -> sum_gain = 3, sum_loss = 1 -> 100*3/4 = 75.
185        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        // close == open every bar -> no bodies -> neutral 50.
193        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        // After [+1,-1,+2] (75) add +0 body window -> [-1,+2,0]: gain 2, loss 1 -> 66.67.
201        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}