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 [`OIDelta`](crate::OIDelta) 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        Ok(Self {
60            period,
61            window: VecDeque::with_capacity(period + 1),
62            last: None,
63        })
64    }
65
66    /// Configured lookback period.
67    pub const fn period(&self) -> usize {
68        self.period
69    }
70
71    /// Current value if available.
72    pub const fn value(&self) -> Option<f64> {
73        self.last
74    }
75}
76
77impl Indicator for OpenInterestMomentum {
78    type Input = DerivativesTick;
79    type Output = f64;
80
81    fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
82        if self.window.len() == self.period + 1 {
83            self.window.pop_front();
84        }
85        self.window.push_back(tick.open_interest);
86        if self.window.len() < self.period + 1 {
87            return None;
88        }
89        let base = *self.window.front().expect("non-empty");
90        let current = tick.open_interest;
91        let oim = if base > 0.0 {
92            100.0 * (current - base) / base
93        } else {
94            0.0
95        };
96        self.last = Some(oim);
97        Some(oim)
98    }
99
100    fn reset(&mut self) {
101        self.window.clear();
102        self.last = None;
103    }
104
105    fn warmup_period(&self) -> usize {
106        self.period + 1
107    }
108
109    fn is_ready(&self) -> bool {
110        self.last.is_some()
111    }
112
113    fn name(&self) -> &'static str {
114        "OpenInterestMomentum"
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::traits::BatchExt;
122    use approx::assert_relative_eq;
123
124    fn tick(oi: f64) -> DerivativesTick {
125        DerivativesTick::new_unchecked(
126            0.0, 100.0, 100.0, 100.0, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
127        )
128    }
129
130    #[test]
131    fn rejects_zero_period() {
132        assert!(matches!(
133            OpenInterestMomentum::new(0),
134            Err(Error::PeriodZero)
135        ));
136    }
137
138    #[test]
139    fn accessors_and_metadata() {
140        let o = OpenInterestMomentum::new(5).unwrap();
141        assert_eq!(o.period(), 5);
142        assert_eq!(o.warmup_period(), 6);
143        assert_eq!(o.name(), "OpenInterestMomentum");
144        assert!(!o.is_ready());
145        assert_eq!(o.value(), None);
146    }
147
148    #[test]
149    fn first_emission_at_warmup_period() {
150        let mut o = OpenInterestMomentum::new(3).unwrap();
151        let ticks: Vec<DerivativesTick> = (0..6)
152            .map(|i| tick(1_000.0 + f64::from(i) * 100.0))
153            .collect();
154        let out = o.batch(&ticks);
155        for v in out.iter().take(3) {
156            assert!(v.is_none());
157        }
158        assert!(out[3].is_some());
159    }
160
161    #[test]
162    fn reference_value() {
163        // period 2: OI 1000 -> 1200 over the window -> +20%.
164        let mut o = OpenInterestMomentum::new(2).unwrap();
165        let out = o.batch(&[tick(1_000.0), tick(1_100.0), tick(1_200.0)]);
166        assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-9);
167    }
168
169    #[test]
170    fn expanding_oi_is_positive() {
171        let mut o = OpenInterestMomentum::new(5).unwrap();
172        let ticks: Vec<DerivativesTick> = (0..20)
173            .map(|i| tick(1_000.0 + f64::from(i) * 100.0))
174            .collect();
175        let last = o.batch(&ticks).into_iter().flatten().last().unwrap();
176        assert!(last > 0.0);
177    }
178
179    #[test]
180    fn contracting_oi_is_negative() {
181        let mut o = OpenInterestMomentum::new(5).unwrap();
182        let ticks: Vec<DerivativesTick> = (0..20)
183            .map(|i| tick(3_000.0 - f64::from(i) * 100.0))
184            .collect();
185        let last = o.batch(&ticks).into_iter().flatten().last().unwrap();
186        assert!(last < 0.0);
187    }
188
189    #[test]
190    fn zero_base_is_zero() {
191        let mut o = OpenInterestMomentum::new(2).unwrap();
192        let out = o.batch(&[tick(0.0), tick(100.0), tick(200.0)]);
193        assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-12);
194    }
195
196    #[test]
197    fn reset_clears_state() {
198        let mut o = OpenInterestMomentum::new(3).unwrap();
199        o.batch(
200            &(0..10)
201                .map(|i| tick(1_000.0 + f64::from(i) * 50.0))
202                .collect::<Vec<_>>(),
203        );
204        assert!(o.is_ready());
205        o.reset();
206        assert!(!o.is_ready());
207        assert_eq!(o.value(), None);
208        assert_eq!(o.update(tick(1_000.0)), None);
209    }
210
211    #[test]
212    fn batch_equals_streaming() {
213        let ticks: Vec<DerivativesTick> = (0..80)
214            .map(|i| tick(1_000.0 + (f64::from(i) * 0.25).sin() * 300.0))
215            .collect();
216        let batch = OpenInterestMomentum::new(10).unwrap().batch(&ticks);
217        let mut b = OpenInterestMomentum::new(10).unwrap();
218        let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect();
219        assert_eq!(batch, streamed);
220    }
221}