Skip to main content

wickra_core/indicators/
oi_price_divergence.rs

1//! Open-Interest / Price Divergence — relative OI change minus relative price
2//! change over a window.
3
4use std::collections::VecDeque;
5
6use crate::derivatives::DerivativesTick;
7use crate::error::{Error, Result};
8use crate::traits::Indicator;
9
10/// Open-Interest / Price Divergence — the gap between how fast open interest and
11/// the mark price have moved over the trailing window of `window` ticks.
12///
13/// ```text
14/// oiChange    = (openInterestₜ − openInterestₜ₋ₙ) / openInterestₜ₋ₙ
15/// priceChange = (markPriceₜ    − markPriceₜ₋ₙ)    / markPriceₜ₋ₙ
16/// divergence  = oiChange − priceChange                          (n = window)
17/// ```
18///
19/// Reading the two together is a classic positioning signal: open interest
20/// rising while price falls (a positive divergence) marks fresh shorts piling
21/// in; open interest falling while price rises marks a short squeeze / unwind.
22/// A value near zero means OI and price moved in step. If the reference open
23/// interest is zero, the OI term contributes zero (no base to grow from).
24///
25/// The indicator warms up for `window + 1` ticks — `update` returns `None` until
26/// the window spans a full `window`-tick lookback — then emits the divergence,
27/// maintained in O(1) per tick via a ring buffer.
28///
29/// `Input = DerivativesTick`, `Output = f64`.
30///
31/// # Example
32///
33/// ```
34/// use wickra_core::{DerivativesTick, Indicator, OIPriceDivergence};
35///
36/// fn tick(oi: f64, mark: f64) -> DerivativesTick {
37///     DerivativesTick::new(0.0, mark, mark, mark, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0)
38///         .unwrap()
39/// }
40///
41/// let mut div = OIPriceDivergence::new(1).unwrap();
42/// assert_eq!(div.update(tick(1_000.0, 100.0)), None);
43/// // OI +10% while price flat -> divergence +0.1.
44/// assert!((div.update(tick(1_100.0, 100.0)).unwrap() - 0.1).abs() < 1e-12);
45/// ```
46#[derive(Debug, Clone)]
47pub struct OIPriceDivergence {
48    window: usize,
49    history: VecDeque<(f64, f64)>,
50}
51
52impl OIPriceDivergence {
53    /// Construct an OI / price divergence over a window of `window` ticks.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`Error::PeriodZero`] if `window` is zero.
58    pub fn new(window: usize) -> Result<Self> {
59        if window == 0 {
60            return Err(Error::PeriodZero);
61        }
62        if window > crate::error::MAX_PERIOD {
63            return Err(Error::InvalidPeriod {
64                message: crate::error::PERIOD_ABOVE_MAX,
65            });
66        }
67        Ok(Self {
68            window,
69            history: VecDeque::with_capacity(window + 1),
70        })
71    }
72
73    /// The configured window length, in ticks.
74    #[must_use]
75    pub fn window(&self) -> usize {
76        self.window
77    }
78}
79
80impl Indicator for OIPriceDivergence {
81    type Input = DerivativesTick;
82    type Output = f64;
83
84    #[inline]
85    fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
86        self.history
87            .push_back((tick.open_interest, tick.mark_price));
88        if self.history.len() > self.window + 1 {
89            self.history.pop_front();
90        }
91        if self.history.len() < self.window + 1 {
92            return None;
93        }
94        let (old_oi, old_mark) = *self.history.front().expect("len == window + 1");
95        let (cur_oi, cur_mark) = *self.history.back().expect("len == window + 1");
96        // Open interest can legitimately be zero; with no base there is no
97        // relative change to report from it.
98        let oi_change = if old_oi == 0.0 {
99            0.0
100        } else {
101            (cur_oi - old_oi) / old_oi
102        };
103        // The mark price is finite and positive by `DerivativesTick`
104        // construction, so the denominator is always well-defined.
105        let price_change = (cur_mark - old_mark) / old_mark;
106        Some(oi_change - price_change)
107    }
108
109    fn reset(&mut self) {
110        self.history.clear();
111    }
112
113    #[inline]
114    fn warmup_period(&self) -> usize {
115        self.window + 1
116    }
117
118    #[inline]
119    fn is_ready(&self) -> bool {
120        self.history.len() == self.window + 1
121    }
122
123    #[inline]
124    fn name(&self) -> &'static str {
125        "OIPriceDivergence"
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::traits::BatchExt;
133
134    fn tick(oi: f64, mark: f64) -> DerivativesTick {
135        DerivativesTick::new_unchecked(0.0, mark, mark, mark, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0)
136    }
137
138    #[test]
139    fn rejects_zero_window() {
140        assert!(matches!(OIPriceDivergence::new(0), Err(Error::PeriodZero)));
141    }
142
143    #[test]
144    fn accessors_and_metadata() {
145        let div = OIPriceDivergence::new(5).unwrap();
146        assert_eq!(div.name(), "OIPriceDivergence");
147        assert_eq!(div.warmup_period(), 6);
148        assert_eq!(div.window(), 5);
149        assert!(!div.is_ready());
150    }
151
152    #[test]
153    fn oi_up_price_flat_is_positive() {
154        let mut div = OIPriceDivergence::new(1).unwrap();
155        assert_eq!(div.update(tick(1_000.0, 100.0)), None);
156        let out = div.update(tick(1_100.0, 100.0)).unwrap();
157        assert!((out - 0.1).abs() < 1e-12);
158        assert!(div.is_ready());
159    }
160
161    #[test]
162    fn oi_flat_price_up_is_negative() {
163        let mut div = OIPriceDivergence::new(1).unwrap();
164        div.update(tick(1_000.0, 100.0));
165        // OI flat, price +10% -> divergence -0.1.
166        let out = div.update(tick(1_000.0, 110.0)).unwrap();
167        assert!((out + 0.1).abs() < 1e-12);
168    }
169
170    #[test]
171    fn zero_reference_oi_drops_oi_term() {
172        let mut div = OIPriceDivergence::new(1).unwrap();
173        div.update(tick(0.0, 100.0));
174        // Reference OI is zero -> only the price term contributes: -(110-100)/100.
175        let out = div.update(tick(500.0, 110.0)).unwrap();
176        assert!((out + 0.1).abs() < 1e-12);
177    }
178
179    #[test]
180    fn batch_equals_streaming() {
181        let ticks: Vec<DerivativesTick> = (0..30)
182            .map(|i| tick(1_000.0 + f64::from(i % 7) * 10.0, 100.0 + f64::from(i % 5)))
183            .collect();
184        let mut a = OIPriceDivergence::new(4).unwrap();
185        let mut b = OIPriceDivergence::new(4).unwrap();
186        assert_eq!(
187            a.batch(&ticks),
188            ticks.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
189        );
190    }
191
192    #[test]
193    fn reset_clears_state() {
194        let mut div = OIPriceDivergence::new(1).unwrap();
195        div.update(tick(1_000.0, 100.0));
196        div.update(tick(1_100.0, 100.0));
197        assert!(div.is_ready());
198        div.reset();
199        assert!(!div.is_ready());
200        assert_eq!(div.update(tick(1_000.0, 100.0)), None);
201    }
202}