Skip to main content

wickra_core/indicators/
ob_imbalance_topn.rs

1//! Order-Book Imbalance over the top-N levels.
2
3use crate::error::{Error, Result};
4use crate::microstructure::OrderBook;
5use crate::traits::Indicator;
6
7/// Order-Book Imbalance aggregated over the top-N levels of each side.
8///
9/// Generalises [`crate::OrderBookImbalanceTop1`] to a configurable depth: it
10/// sums the resting size of the best `levels` bids and the best `levels` asks
11/// and compares them:
12///
13/// ```text
14/// bidDepth  = Σ size of the best `levels` bids
15/// askDepth  = Σ size of the best `levels` asks
16/// imbalance = (bidDepth − askDepth) / (bidDepth + askDepth)
17/// ```
18///
19/// If a side has fewer than `levels` levels, all available levels are summed.
20/// The output lies in `[−1, +1]`; a book with zero size across the summed
21/// levels yields `0`.
22///
23/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
24/// snapshot.
25///
26/// # Example
27///
28/// ```
29/// use wickra_core::{Indicator, Level, OrderBook, OrderBookImbalanceTopN};
30///
31/// let book = OrderBook::new(
32///     vec![Level::new(100.0, 2.0).unwrap(), Level::new(99.0, 1.0).unwrap()],
33///     vec![Level::new(101.0, 1.0).unwrap(), Level::new(102.0, 1.0).unwrap()],
34/// )
35/// .unwrap();
36/// let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
37/// assert_eq!(obi.update(book), Some(0.2)); // (3 − 2) / (3 + 2)
38/// ```
39#[derive(Debug, Clone)]
40pub struct OrderBookImbalanceTopN {
41    levels: usize,
42    has_emitted: bool,
43}
44
45impl OrderBookImbalanceTopN {
46    /// Construct a top-N imbalance indicator.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`Error::PeriodZero`] if `levels` is zero.
51    pub fn new(levels: usize) -> Result<Self> {
52        if levels == 0 {
53            return Err(Error::PeriodZero);
54        }
55        if levels > crate::error::MAX_PERIOD {
56            return Err(Error::InvalidPeriod {
57                message: crate::error::PERIOD_ABOVE_MAX,
58            });
59        }
60        Ok(Self {
61            levels,
62            has_emitted: false,
63        })
64    }
65
66    /// The configured number of levels summed per side.
67    pub fn levels(&self) -> usize {
68        self.levels
69    }
70}
71
72impl Indicator for OrderBookImbalanceTopN {
73    type Input = OrderBook;
74    type Output = f64;
75
76    #[inline]
77    fn update(&mut self, book: OrderBook) -> Option<f64> {
78        self.has_emitted = true;
79        let bid_depth: f64 = book.bids.iter().take(self.levels).map(|l| l.size).sum();
80        let ask_depth: f64 = book.asks.iter().take(self.levels).map(|l| l.size).sum();
81        let total = bid_depth + ask_depth;
82        if total <= 0.0 {
83            return Some(0.0);
84        }
85        Some((bid_depth - ask_depth) / total)
86    }
87
88    fn reset(&mut self) {
89        self.has_emitted = false;
90    }
91
92    #[inline]
93    fn warmup_period(&self) -> usize {
94        1
95    }
96
97    #[inline]
98    fn is_ready(&self) -> bool {
99        self.has_emitted
100    }
101
102    #[inline]
103    fn name(&self) -> &'static str {
104        "OrderBookImbalanceTopN"
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::microstructure::Level;
112    use crate::traits::BatchExt;
113
114    fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
115        let to_levels = |xs: &[(f64, f64)]| {
116            xs.iter()
117                .map(|&(p, s)| Level::new(p, s).unwrap())
118                .collect::<Vec<_>>()
119        };
120        OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
121    }
122
123    #[test]
124    fn rejects_zero_levels() {
125        assert!(matches!(
126            OrderBookImbalanceTopN::new(0),
127            Err(Error::PeriodZero)
128        ));
129    }
130
131    #[test]
132    fn accessors_and_metadata() {
133        let obi = OrderBookImbalanceTopN::new(3).unwrap();
134        assert_eq!(obi.name(), "OrderBookImbalanceTopN");
135        assert_eq!(obi.warmup_period(), 1);
136        assert_eq!(obi.levels(), 3);
137        assert!(!obi.is_ready());
138    }
139
140    #[test]
141    fn sums_top_two_levels() {
142        let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
143        let b = book(&[(100.0, 2.0), (99.0, 1.0)], &[(101.0, 1.0), (102.0, 1.0)]);
144        // bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2.
145        assert_eq!(obi.update(b), Some(0.2));
146        assert!(obi.is_ready());
147    }
148
149    #[test]
150    fn caps_at_available_depth() {
151        // Only one level per side, N = 5 -> uses what exists.
152        let mut obi = OrderBookImbalanceTopN::new(5).unwrap();
153        assert_eq!(
154            obi.update(book(&[(100.0, 3.0)], &[(101.0, 1.0)])),
155            Some(0.5)
156        );
157    }
158
159    #[test]
160    fn zero_size_is_zero() {
161        let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
162        assert_eq!(
163            obi.update(book(&[(100.0, 0.0)], &[(101.0, 0.0)])),
164            Some(0.0)
165        );
166    }
167
168    #[test]
169    fn batch_equals_streaming() {
170        let books: Vec<OrderBook> = (0..20)
171            .map(|i| {
172                let ask = 1.0 + f64::from(i % 4);
173                book(&[(100.0, 2.0), (99.0, 1.0)], &[(101.0, ask), (102.0, 1.0)])
174            })
175            .collect();
176        let mut a = OrderBookImbalanceTopN::new(2).unwrap();
177        let mut b = OrderBookImbalanceTopN::new(2).unwrap();
178        assert_eq!(
179            a.batch(&books),
180            books
181                .iter()
182                .map(|x| b.update(x.clone()))
183                .collect::<Vec<_>>()
184        );
185    }
186
187    #[test]
188    fn reset_clears_state() {
189        let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
190        obi.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
191        assert!(obi.is_ready());
192        obi.reset();
193        assert!(!obi.is_ready());
194    }
195}