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