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        Ok(Self {
63            window,
64            history: VecDeque::with_capacity(window + 1),
65        })
66    }
67
68    /// The configured window length, in ticks.
69    #[must_use]
70    pub fn window(&self) -> usize {
71        self.window
72    }
73}
74
75impl Indicator for OIPriceDivergence {
76    type Input = DerivativesTick;
77    type Output = f64;
78
79    fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
80        self.history
81            .push_back((tick.open_interest, tick.mark_price));
82        if self.history.len() > self.window + 1 {
83            self.history.pop_front();
84        }
85        if self.history.len() < self.window + 1 {
86            return None;
87        }
88        let (old_oi, old_mark) = *self.history.front().expect("len == window + 1");
89        let (cur_oi, cur_mark) = *self.history.back().expect("len == window + 1");
90        // Open interest can legitimately be zero; with no base there is no
91        // relative change to report from it.
92        let oi_change = if old_oi == 0.0 {
93            0.0
94        } else {
95            (cur_oi - old_oi) / old_oi
96        };
97        // The mark price is finite and positive by `DerivativesTick`
98        // construction, so the denominator is always well-defined.
99        let price_change = (cur_mark - old_mark) / old_mark;
100        Some(oi_change - price_change)
101    }
102
103    fn reset(&mut self) {
104        self.history.clear();
105    }
106
107    fn warmup_period(&self) -> usize {
108        self.window + 1
109    }
110
111    fn is_ready(&self) -> bool {
112        self.history.len() == self.window + 1
113    }
114
115    fn name(&self) -> &'static str {
116        "OIPriceDivergence"
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::traits::BatchExt;
124
125    fn tick(oi: f64, mark: f64) -> DerivativesTick {
126        DerivativesTick::new_unchecked(0.0, mark, mark, mark, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0)
127    }
128
129    #[test]
130    fn rejects_zero_window() {
131        assert!(matches!(OIPriceDivergence::new(0), Err(Error::PeriodZero)));
132    }
133
134    #[test]
135    fn accessors_and_metadata() {
136        let div = OIPriceDivergence::new(5).unwrap();
137        assert_eq!(div.name(), "OIPriceDivergence");
138        assert_eq!(div.warmup_period(), 6);
139        assert_eq!(div.window(), 5);
140        assert!(!div.is_ready());
141    }
142
143    #[test]
144    fn oi_up_price_flat_is_positive() {
145        let mut div = OIPriceDivergence::new(1).unwrap();
146        assert_eq!(div.update(tick(1_000.0, 100.0)), None);
147        let out = div.update(tick(1_100.0, 100.0)).unwrap();
148        assert!((out - 0.1).abs() < 1e-12);
149        assert!(div.is_ready());
150    }
151
152    #[test]
153    fn oi_flat_price_up_is_negative() {
154        let mut div = OIPriceDivergence::new(1).unwrap();
155        div.update(tick(1_000.0, 100.0));
156        // OI flat, price +10% -> divergence -0.1.
157        let out = div.update(tick(1_000.0, 110.0)).unwrap();
158        assert!((out + 0.1).abs() < 1e-12);
159    }
160
161    #[test]
162    fn zero_reference_oi_drops_oi_term() {
163        let mut div = OIPriceDivergence::new(1).unwrap();
164        div.update(tick(0.0, 100.0));
165        // Reference OI is zero -> only the price term contributes: -(110-100)/100.
166        let out = div.update(tick(500.0, 110.0)).unwrap();
167        assert!((out + 0.1).abs() < 1e-12);
168    }
169
170    #[test]
171    fn batch_equals_streaming() {
172        let ticks: Vec<DerivativesTick> = (0..30)
173            .map(|i| tick(1_000.0 + f64::from(i % 7) * 10.0, 100.0 + f64::from(i % 5)))
174            .collect();
175        let mut a = OIPriceDivergence::new(4).unwrap();
176        let mut b = OIPriceDivergence::new(4).unwrap();
177        assert_eq!(
178            a.batch(&ticks),
179            ticks.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
180        );
181    }
182
183    #[test]
184    fn reset_clears_state() {
185        let mut div = OIPriceDivergence::new(1).unwrap();
186        div.update(tick(1_000.0, 100.0));
187        div.update(tick(1_100.0, 100.0));
188        assert!(div.is_ready());
189        div.reset();
190        assert!(!div.is_ready());
191        assert_eq!(div.update(tick(1_000.0, 100.0)), None);
192    }
193}