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        Ok(Self {
56            levels,
57            has_emitted: false,
58        })
59    }
60
61    /// The configured number of levels summed per side.
62    pub fn levels(&self) -> usize {
63        self.levels
64    }
65}
66
67impl Indicator for OrderBookImbalanceTopN {
68    type Input = OrderBook;
69    type Output = f64;
70
71    fn update(&mut self, book: OrderBook) -> Option<f64> {
72        self.has_emitted = true;
73        let bid_depth: f64 = book.bids.iter().take(self.levels).map(|l| l.size).sum();
74        let ask_depth: f64 = book.asks.iter().take(self.levels).map(|l| l.size).sum();
75        let total = bid_depth + ask_depth;
76        if total <= 0.0 {
77            return Some(0.0);
78        }
79        Some((bid_depth - ask_depth) / total)
80    }
81
82    fn reset(&mut self) {
83        self.has_emitted = false;
84    }
85
86    fn warmup_period(&self) -> usize {
87        1
88    }
89
90    fn is_ready(&self) -> bool {
91        self.has_emitted
92    }
93
94    fn name(&self) -> &'static str {
95        "OrderBookImbalanceTopN"
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::microstructure::Level;
103    use crate::traits::BatchExt;
104
105    fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
106        let to_levels = |xs: &[(f64, f64)]| {
107            xs.iter()
108                .map(|&(p, s)| Level::new(p, s).unwrap())
109                .collect::<Vec<_>>()
110        };
111        OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
112    }
113
114    #[test]
115    fn rejects_zero_levels() {
116        assert!(matches!(
117            OrderBookImbalanceTopN::new(0),
118            Err(Error::PeriodZero)
119        ));
120    }
121
122    #[test]
123    fn accessors_and_metadata() {
124        let obi = OrderBookImbalanceTopN::new(3).unwrap();
125        assert_eq!(obi.name(), "OrderBookImbalanceTopN");
126        assert_eq!(obi.warmup_period(), 1);
127        assert_eq!(obi.levels(), 3);
128        assert!(!obi.is_ready());
129    }
130
131    #[test]
132    fn sums_top_two_levels() {
133        let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
134        let b = book(&[(100.0, 2.0), (99.0, 1.0)], &[(101.0, 1.0), (102.0, 1.0)]);
135        // bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2.
136        assert_eq!(obi.update(b), Some(0.2));
137        assert!(obi.is_ready());
138    }
139
140    #[test]
141    fn caps_at_available_depth() {
142        // Only one level per side, N = 5 -> uses what exists.
143        let mut obi = OrderBookImbalanceTopN::new(5).unwrap();
144        assert_eq!(
145            obi.update(book(&[(100.0, 3.0)], &[(101.0, 1.0)])),
146            Some(0.5)
147        );
148    }
149
150    #[test]
151    fn zero_size_is_zero() {
152        let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
153        assert_eq!(
154            obi.update(book(&[(100.0, 0.0)], &[(101.0, 0.0)])),
155            Some(0.0)
156        );
157    }
158
159    #[test]
160    fn batch_equals_streaming() {
161        let books: Vec<OrderBook> = (0..20)
162            .map(|i| {
163                let ask = 1.0 + f64::from(i % 4);
164                book(&[(100.0, 2.0), (99.0, 1.0)], &[(101.0, ask), (102.0, 1.0)])
165            })
166            .collect();
167        let mut a = OrderBookImbalanceTopN::new(2).unwrap();
168        let mut b = OrderBookImbalanceTopN::new(2).unwrap();
169        assert_eq!(
170            a.batch(&books),
171            books
172                .iter()
173                .map(|x| b.update(x.clone()))
174                .collect::<Vec<_>>()
175        );
176    }
177
178    #[test]
179    fn reset_clears_state() {
180        let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
181        obi.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
182        assert!(obi.is_ready());
183        obi.reset();
184        assert!(!obi.is_ready());
185    }
186}