Skip to main content

wickra_core/indicators/
open_interest_momentum.rs

1//! Open-Interest Momentum — the rate of change of open interest over a lookback.
2
3use std::collections::VecDeque;
4
5use crate::derivatives::DerivativesTick;
6use crate::error::{Error, Result};
7use crate::traits::Indicator;
8
9/// Open-Interest Momentum — the percentage rate of change of open interest over a
10/// `period`-tick lookback.
11///
12/// ```text
13/// OIM = 100 · (OI_t − OI_{t−period}) / OI_{t−period}
14/// ```
15///
16/// Where [`OpenInterestDelta`](crate::OpenInterestDelta) reports the single-tick change in open
17/// interest, OI Momentum measures the trend in positioning over a window: positive
18/// values mean open interest is expanding (new money entering — a position build
19/// that fuels the prevailing move), negative values mean it is contracting
20/// (positions being closed — deleveraging or short-covering). Read alongside price:
21/// rising OI with rising price is a strong new-long trend, while rising price with
22/// falling OI is a short-covering rally on borrowed time.
23///
24/// The output is a percentage and may be negative. A zero base open interest
25/// `period` ticks ago reports `0` rather than dividing by zero. The first value
26/// lands after `period + 1` inputs. Each `update` is O(1).
27///
28/// # Example
29///
30/// ```
31/// use wickra_core::{DerivativesTick, Indicator, OpenInterestMomentum};
32///
33/// let mut indicator = OpenInterestMomentum::new(5).unwrap();
34/// let mut last = None;
35/// for i in 0..20 {
36///     let oi = 1_000.0 + f64::from(i) * 100.0;
37///     let tick = DerivativesTick::new(0.0, 100.0, 100.0, 100.0, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0).unwrap();
38///     last = indicator.update(tick);
39/// }
40/// assert!(last.unwrap() > 0.0); // expanding OI
41/// ```
42#[derive(Debug, Clone)]
43pub struct OpenInterestMomentum {
44    period: usize,
45    window: VecDeque<f64>,
46    last: Option<f64>,
47}
48
49impl OpenInterestMomentum {
50    /// Construct an OI Momentum over a `period`-tick lookback.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`Error::PeriodZero`] if `period == 0`.
55    pub fn new(period: usize) -> Result<Self> {
56        if period == 0 {
57            return Err(Error::PeriodZero);
58        }
59        if period > crate::error::MAX_PERIOD {
60            return Err(Error::InvalidPeriod {
61                message: crate::error::PERIOD_ABOVE_MAX,
62            });
63        }
64        Ok(Self {
65            period,
66            window: VecDeque::with_capacity(period + 1),
67            last: None,
68        })
69    }
70
71    /// Configured lookback period.
72    pub const fn period(&self) -> usize {
73        self.period
74    }
75
76    /// Current value if available.
77    pub const fn value(&self) -> Option<f64> {
78        self.last
79    }
80}
81
82impl Indicator for OpenInterestMomentum {
83    type Input = DerivativesTick;
84    type Output = f64;
85
86    #[inline]
87    fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
88        if self.window.len() == self.period + 1 {
89            self.window.pop_front();
90        }
91        self.window.push_back(tick.open_interest);
92        if self.window.len() < self.period + 1 {
93            return None;
94        }
95        let base = *self.window.front().expect("non-empty");
96        let current = tick.open_interest;
97        let oim = if base > 0.0 {
98            100.0 * (current - base) / base
99        } else {
100            0.0
101        };
102        self.last = Some(oim);
103        Some(oim)
104    }
105
106    fn reset(&mut self) {
107        self.window.clear();
108        self.last = None;
109    }
110
111    #[inline]
112    fn warmup_period(&self) -> usize {
113        self.period + 1
114    }
115
116    #[inline]
117    fn is_ready(&self) -> bool {
118        self.last.is_some()
119    }
120
121    #[inline]
122    fn name(&self) -> &'static str {
123        "OpenInterestMomentum"
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::traits::BatchExt;
131    use approx::assert_relative_eq;
132
133    fn tick(oi: f64) -> DerivativesTick {
134        DerivativesTick::new_unchecked(
135            0.0, 100.0, 100.0, 100.0, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
136        )
137    }
138
139    #[test]
140    fn rejects_zero_period() {
141        assert!(matches!(
142            OpenInterestMomentum::new(0),
143            Err(Error::PeriodZero)
144        ));
145    }
146
147    #[test]
148    fn accessors_and_metadata() {
149        let o = OpenInterestMomentum::new(5).unwrap();
150        assert_eq!(o.period(), 5);
151        assert_eq!(o.warmup_period(), 6);
152        assert_eq!(o.name(), "OpenInterestMomentum");
153        assert!(!o.is_ready());
154        assert_eq!(o.value(), None);
155    }
156
157    #[test]
158    fn first_emission_at_warmup_period() {
159        let mut o = OpenInterestMomentum::new(3).unwrap();
160        let ticks: Vec<DerivativesTick> = (0..6)
161            .map(|i| tick(1_000.0 + f64::from(i) * 100.0))
162            .collect();
163        let out = o.batch(&ticks);
164        for v in out.iter().take(3) {
165            assert!(v.is_none());
166        }
167        assert!(out[3].is_some());
168    }
169
170    #[test]
171    fn reference_value() {
172        // period 2: OI 1000 -> 1200 over the window -> +20%.
173        let mut o = OpenInterestMomentum::new(2).unwrap();
174        let out = o.batch(&[tick(1_000.0), tick(1_100.0), tick(1_200.0)]);
175        assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-9);
176    }
177
178    #[test]
179    fn expanding_oi_is_positive() {
180        let mut o = OpenInterestMomentum::new(5).unwrap();
181        let ticks: Vec<DerivativesTick> = (0..20)
182            .map(|i| tick(1_000.0 + f64::from(i) * 100.0))
183            .collect();
184        let last = o.batch(&ticks).into_iter().flatten().last().unwrap();
185        assert!(last > 0.0);
186    }
187
188    #[test]
189    fn contracting_oi_is_negative() {
190        let mut o = OpenInterestMomentum::new(5).unwrap();
191        let ticks: Vec<DerivativesTick> = (0..20)
192            .map(|i| tick(3_000.0 - f64::from(i) * 100.0))
193            .collect();
194        let last = o.batch(&ticks).into_iter().flatten().last().unwrap();
195        assert!(last < 0.0);
196    }
197
198    #[test]
199    fn zero_base_is_zero() {
200        let mut o = OpenInterestMomentum::new(2).unwrap();
201        let out = o.batch(&[tick(0.0), tick(100.0), tick(200.0)]);
202        assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-12);
203    }
204
205    #[test]
206    fn reset_clears_state() {
207        let mut o = OpenInterestMomentum::new(3).unwrap();
208        o.batch(
209            &(0..10)
210                .map(|i| tick(1_000.0 + f64::from(i) * 50.0))
211                .collect::<Vec<_>>(),
212        );
213        assert!(o.is_ready());
214        o.reset();
215        assert!(!o.is_ready());
216        assert_eq!(o.value(), None);
217        assert_eq!(o.update(tick(1_000.0)), None);
218    }
219
220    #[test]
221    fn batch_equals_streaming() {
222        let ticks: Vec<DerivativesTick> = (0..80)
223            .map(|i| tick(1_000.0 + (f64::from(i) * 0.25).sin() * 300.0))
224            .collect();
225        let batch = OpenInterestMomentum::new(10).unwrap().batch(&ticks);
226        let mut b = OpenInterestMomentum::new(10).unwrap();
227        let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect();
228        assert_eq!(batch, streamed);
229    }
230}