Skip to main content

rustrade_data/exchange/ibkr/
depth.rs

1//! Depth aggregation for IB market depth updates.
2//!
3//! IB sends individual depth updates (insert, update, delete) for each price
4//! level. This aggregator maintains the order book state and emits
5//! [`OrderBookEvent`] updates.
6
7use crate::{
8    books::{Asks, Bids, Level, OrderBook, OrderBookSide, OrderBookTimes},
9    subscription::book::OrderBookEvent,
10};
11use chrono::{DateTime, Utc};
12use ibapi::market_data::realtime::{MarketDepth, MarketDepths};
13use rust_decimal::Decimal;
14
15use super::decimal_from_f64;
16
17/// IB API depth operation: delete level at price
18const IB_DEPTH_OP_DELETE: i32 = 2;
19
20/// Aggregates IB market depth updates into OrderBook snapshots.
21///
22/// Maintains local order book state and emits updates on each depth event.
23/// Uses pre-sorted [`OrderBookSide`]s internally to avoid per-tick sorting.
24#[derive(Debug, Default)]
25pub struct DepthAggregator {
26    bids: OrderBookSide<Bids>,
27    asks: OrderBookSide<Asks>,
28    sequence: u64,
29}
30
31impl DepthAggregator {
32    /// Create a new aggregator.
33    pub fn new() -> Self {
34        Self::default()
35    }
36
37    /// Process a depth update and emit an OrderBookEvent.
38    ///
39    /// Returns `Some(OrderBookEvent)` for actual depth updates, `None` for
40    /// MarketDepthL2 updates (which include market maker attribution that we
41    /// don't track — we aggregate into a simple anonymous book).
42    ///
43    /// [`MarketDepths::Reset`] (IB code 317) empties the book through
44    /// [`Self::on_venue_reset`] and returns the emptied snapshot, which the caller must
45    /// forward at once: see that method for why.
46    ///
47    /// `time_received` is the local ingestion wall-clock; it is threaded in from
48    /// the caller so the emitted book's `time_received` matches the
49    /// `MarketEvent`'s. IB market-depth carries no venue timestamp, so it is the
50    /// only liveness signal on IBKR (`time_engine`/`time_exchange` are `None`).
51    pub fn update(
52        &mut self,
53        depth: &MarketDepths,
54        time_received: DateTime<Utc>,
55    ) -> Option<OrderBookEvent> {
56        match depth {
57            MarketDepths::MarketDepth(d) => self.process_depth(d, time_received),
58            MarketDepths::MarketDepthL2(_) => {
59                // MarketDepthL2 includes market maker attribution - we aggregate
60                // into a simple book without tracking individual market makers
61                tracing::trace!("Discarding MarketDepthL2 event (market maker data not tracked)");
62                None
63            }
64            MarketDepths::Reset => Some(self.on_venue_reset(time_received)),
65        }
66    }
67
68    fn process_depth(
69        &mut self,
70        depth: &MarketDepth,
71        time_received: DateTime<Utc>,
72    ) -> Option<OrderBookEvent> {
73        // Skip levels with invalid price (e.g., NaN, Inf, DBL_MAX sentinel)
74        // Note: depth.position (IB's position-based addressing) is ignored;
75        // we use price-keyed book maintenance instead.
76        let price = decimal_from_f64(depth.price)?;
77
78        // For delete operations, size is irrelevant and may be NaN.
79        // Only validate size for insert/update operations.
80        let size = if depth.operation == IB_DEPTH_OP_DELETE {
81            Decimal::ZERO
82        } else {
83            decimal_from_f64(depth.size)?
84        };
85
86        // IB API: side 0=Ask, 1=Bid (per EWrapper.updateMktDepth documentation)
87        match depth.side {
88            0 => self.update_asks(price, size),
89            1 => self.update_bids(price, size),
90            other => {
91                // Unknown side from IB: skip to avoid corrupting book state.
92                // IB protocol is stable so this branch should never execute.
93                tracing::warn!(
94                    side = other,
95                    price = %price,
96                    "Unknown IB depth side, skipping"
97                );
98                return None;
99            }
100        }
101
102        self.sequence += 1;
103        Some(OrderBookEvent::Snapshot(self.to_order_book(time_received)))
104    }
105
106    fn update_bids(&mut self, price: Decimal, size: Decimal) {
107        // IB API: operation 0=Insert, 1=Update, 2=Delete
108        // Size is already Decimal::ZERO for deletes; upsert_single removes zero-amount levels
109        let level = Level {
110            price,
111            amount: size,
112        };
113        self.bids
114            .upsert_single(level, |existing| existing.price.cmp(&level.price).reverse());
115    }
116
117    fn update_asks(&mut self, price: Decimal, size: Decimal) {
118        let level = Level {
119            price,
120            amount: size,
121        };
122        self.asks
123            .upsert_single(level, |existing| existing.price.cmp(&level.price));
124    }
125
126    fn to_order_book(&self, time_received: DateTime<Utc>) -> OrderBook {
127        OrderBook::from_sides(
128            self.sequence,
129            // IB market-depth carries no venue timestamp: engine/exchange times are
130            // `None`; `time_received` is the only liveness signal on IBKR.
131            OrderBookTimes {
132                time_engine: None,
133                time_exchange: None,
134                time_received,
135            },
136            self.bids.clone(),
137            self.asks.clone(),
138        )
139    }
140
141    /// Drop every level in response to [`MarketDepths::Reset`] and emit the emptied book.
142    ///
143    /// TWS sends the reset (IB code 317, "Market depth data has been RESET") when it
144    /// discards the book on its side, and the subscription stays open. Every level held locally is stale from that moment, so continuing to
145    /// apply updates to them produces a book that looks live and is not — the one
146    /// failure mode worse than no book at all.
147    ///
148    /// Unlike [`Self::clear`] this **preserves the sequence counter**, which is
149    /// what makes it correct mid-stream: a consumer ordering by sequence would
150    /// read a snapshot renumbered to 0 as older than the stale book it replaces,
151    /// and keep the stale one. The counter is advanced rather than merely kept, so
152    /// the emptied book is strictly newer than everything before it.
153    ///
154    /// Returns the emptied snapshot so the caller can forward it. Emitting it
155    /// immediately, rather than waiting for the next depth row, is what closes the
156    /// window in which a downstream consumer still holds levels the venue has
157    /// already thrown away.
158    pub fn on_venue_reset(&mut self, time_received: DateTime<Utc>) -> OrderBookEvent {
159        self.bids = OrderBookSide::default();
160        self.asks = OrderBookSide::default();
161        self.sequence += 1;
162        OrderBookEvent::Snapshot(self.to_order_book(time_received))
163    }
164
165    /// Clear all book state.
166    ///
167    /// Useful for reconnection scenarios where stale book state should be
168    /// discarded before receiving fresh depth updates. Note: sequence resets to 0,
169    /// so this is for a **fresh** subscription, not a mid-stream venue reset — use
170    /// [`Self::on_venue_reset`] for the latter.
171    pub fn clear(&mut self) {
172        self.bids = OrderBookSide::default();
173        self.asks = OrderBookSide::default();
174        self.sequence = 0;
175    }
176}
177
178#[cfg(test)]
179// Test code may unwrap freely since panics indicate test failure
180#[allow(clippy::unwrap_used)]
181mod tests {
182    use super::*;
183    use rust_decimal_macros::dec;
184
185    fn depth(side: i32, operation: i32, price: f64, size: f64) -> MarketDepths {
186        MarketDepths::MarketDepth(MarketDepth {
187            position: 0,
188            operation,
189            side,
190            price,
191            size,
192        })
193    }
194
195    /// Fixed local-ingestion time for deterministic assertions.
196    fn tr() -> DateTime<Utc> {
197        DateTime::from_timestamp_millis(1_700_000_000_000).unwrap()
198    }
199
200    #[test]
201    fn order_book_has_only_time_received() {
202        // IB market-depth carries no venue timestamp: engine/exchange times must be
203        // `None`, and `time_received` is the (threaded) local ingestion wall-clock —
204        // the only liveness signal on IBKR.
205        let mut agg = DepthAggregator::new();
206
207        let event = agg.update(&depth(1, 0, 100.0, 10.0), tr()).unwrap();
208        match event {
209            OrderBookEvent::Snapshot(book) => {
210                assert_eq!(book.time_engine(), None);
211                assert_eq!(book.time_exchange(), None);
212                assert_eq!(book.time_received(), tr());
213            }
214            _ => panic!("Expected Snapshot"),
215        }
216    }
217
218    #[test]
219    fn insert_bid() {
220        let mut agg = DepthAggregator::new();
221
222        let event = agg.update(&depth(1, 0, 100.0, 10.0), tr()).unwrap();
223
224        match event {
225            OrderBookEvent::Snapshot(book) => {
226                assert_eq!(book.bids().levels().len(), 1);
227                assert_eq!(book.bids().levels()[0].price, dec!(100));
228                assert_eq!(book.bids().levels()[0].amount, dec!(10));
229                assert!(book.asks().levels().is_empty());
230            }
231            _ => panic!("Expected Snapshot"),
232        }
233    }
234
235    #[test]
236    fn insert_ask() {
237        let mut agg = DepthAggregator::new();
238
239        let event = agg.update(&depth(0, 0, 101.0, 5.0), tr()).unwrap();
240
241        match event {
242            OrderBookEvent::Snapshot(book) => {
243                assert_eq!(book.asks().levels().len(), 1);
244                assert_eq!(book.asks().levels()[0].price, dec!(101));
245                assert_eq!(book.asks().levels()[0].amount, dec!(5));
246                assert!(book.bids().levels().is_empty());
247            }
248            _ => panic!("Expected Snapshot"),
249        }
250    }
251
252    #[test]
253    fn update_level() {
254        let mut agg = DepthAggregator::new();
255
256        agg.update(&depth(1, 0, 100.0, 10.0), tr());
257        let event = agg.update(&depth(1, 1, 100.0, 15.0), tr()).unwrap();
258
259        match event {
260            OrderBookEvent::Snapshot(book) => {
261                assert_eq!(book.bids().levels().len(), 1);
262                assert_eq!(book.bids().levels()[0].amount, dec!(15));
263            }
264            _ => panic!("Expected Snapshot"),
265        }
266    }
267
268    #[test]
269    fn delete_level() {
270        let mut agg = DepthAggregator::new();
271
272        agg.update(&depth(1, 0, 100.0, 10.0), tr());
273        agg.update(&depth(1, 0, 99.0, 5.0), tr());
274        let event = agg.update(&depth(1, 2, 100.0, 0.0), tr()).unwrap();
275
276        match event {
277            OrderBookEvent::Snapshot(book) => {
278                assert_eq!(book.bids().levels().len(), 1);
279                assert_eq!(book.bids().levels()[0].price, dec!(99));
280            }
281            _ => panic!("Expected Snapshot"),
282        }
283    }
284
285    #[test]
286    fn multiple_levels() {
287        let mut agg = DepthAggregator::new();
288
289        agg.update(&depth(1, 0, 100.0, 10.0), tr());
290        agg.update(&depth(1, 0, 99.0, 20.0), tr());
291        agg.update(&depth(0, 0, 101.0, 5.0), tr());
292        let event = agg.update(&depth(0, 0, 102.0, 3.0), tr()).unwrap();
293
294        match event {
295            OrderBookEvent::Snapshot(book) => {
296                assert_eq!(book.bids().levels().len(), 2);
297                assert_eq!(book.asks().levels().len(), 2);
298
299                // Verify sort order: bids descending, asks ascending
300                let bids = book.bids().levels();
301                assert!(
302                    bids[0].price > bids[1].price,
303                    "bids should be sorted descending: {:?}",
304                    bids
305                );
306                let asks = book.asks().levels();
307                assert!(
308                    asks[0].price < asks[1].price,
309                    "asks should be sorted ascending: {:?}",
310                    asks
311                );
312            }
313            _ => panic!("Expected Snapshot"),
314        }
315    }
316
317    #[test]
318    fn invalid_price_skipped() {
319        let mut agg = DepthAggregator::new();
320
321        // NaN price should be skipped
322        let result = agg.update(&depth(1, 0, f64::NAN, 10.0), tr());
323        assert!(result.is_none());
324
325        // Infinity should be skipped
326        let result = agg.update(&depth(1, 0, f64::INFINITY, 10.0), tr());
327        assert!(result.is_none());
328
329        // Valid price should work
330        let result = agg.update(&depth(1, 0, 100.0, 10.0), tr());
331        assert!(result.is_some());
332    }
333
334    #[test]
335    fn delete_with_nan_size_removes_level() {
336        let mut agg = DepthAggregator::new();
337
338        // Insert a bid level
339        agg.update(&depth(1, 0, 100.0, 10.0), tr());
340
341        // Delete with NaN size should still remove the level (size irrelevant for deletes)
342        let event = agg.update(&depth(1, 2, 100.0, f64::NAN), tr()).unwrap();
343
344        match event {
345            OrderBookEvent::Snapshot(book) => {
346                assert!(
347                    book.bids().levels().is_empty(),
348                    "Delete with NaN size should remove level"
349                );
350            }
351            _ => panic!("Expected Snapshot"),
352        }
353    }
354
355    #[test]
356    fn venue_reset_empties_the_book_and_returns_the_emptied_snapshot() {
357        let mut agg = DepthAggregator::new();
358        agg.update(&depth(1, 0, 100.0, 10.0), tr());
359        agg.update(&depth(0, 0, 101.0, 8.0), tr());
360        assert!(!agg.bids.levels().is_empty() && !agg.asks.levels().is_empty());
361
362        let event = agg.on_venue_reset(tr());
363
364        assert!(agg.bids.levels().is_empty(), "bids must be dropped");
365        assert!(agg.asks.levels().is_empty(), "asks must be dropped");
366        match event {
367            OrderBookEvent::Snapshot(book) => {
368                assert!(
369                    book.bids().levels().is_empty() && book.asks().levels().is_empty(),
370                    "the emitted snapshot must carry the emptied book, so a consumer \
371                     replacing its state on a snapshot drops its own stale levels too"
372                );
373            }
374            _ => panic!("Expected Snapshot"),
375        }
376    }
377
378    /// The decisive difference from [`DepthAggregator::clear`], and the reason a
379    /// mid-stream reset cannot reuse it: a consumer ordering by sequence would read
380    /// a snapshot renumbered to 0 as *older* than the stale book it is meant to
381    /// replace, and would keep the stale one.
382    #[test]
383    fn venue_reset_advances_the_sequence_so_the_emptied_book_is_strictly_newer() {
384        let mut agg = DepthAggregator::new();
385        agg.update(&depth(1, 0, 100.0, 10.0), tr());
386        agg.update(&depth(1, 0, 99.0, 5.0), tr());
387        agg.update(&depth(0, 0, 101.0, 8.0), tr());
388        assert_eq!(agg.sequence, 3);
389
390        let event = agg.on_venue_reset(tr());
391
392        assert_eq!(agg.sequence, 4, "sequence must advance, never rewind");
393        match event {
394            OrderBookEvent::Snapshot(book) => assert_eq!(book.sequence(), 4),
395            _ => panic!("Expected Snapshot"),
396        }
397    }
398
399    #[test]
400    fn a_reset_update_empties_the_book_like_a_venue_reset() {
401        let mut agg = DepthAggregator::new();
402        agg.update(&depth(1, 0, 100.0, 10.0), tr());
403        agg.update(&depth(0, 0, 101.0, 8.0), tr());
404
405        let event = agg.update(&MarketDepths::Reset, tr());
406
407        assert!(agg.bids.levels().is_empty() && agg.asks.levels().is_empty());
408        match event {
409            Some(OrderBookEvent::Snapshot(book)) => {
410                assert!(book.bids().levels().is_empty() && book.asks().levels().is_empty());
411                assert_eq!(book.sequence(), 3, "the reset advances the sequence");
412            }
413            other => panic!("Expected the emptied Snapshot, got {other:?}"),
414        }
415    }
416
417    #[test]
418    fn updates_after_a_venue_reset_rebuild_from_empty() {
419        let mut agg = DepthAggregator::new();
420        agg.update(&depth(1, 0, 100.0, 10.0), tr());
421        agg.on_venue_reset(tr());
422
423        let event = agg.update(&depth(1, 0, 50.0, 1.0), tr()).unwrap();
424        match event {
425            OrderBookEvent::Snapshot(book) => {
426                assert_eq!(book.bids().levels().len(), 1, "only the post-reset level");
427                assert_eq!(book.bids().levels()[0].price, dec!(50.0));
428                // 1 update, then the reset, then this update.
429                assert_eq!(book.sequence(), 3);
430            }
431            _ => panic!("Expected Snapshot"),
432        }
433    }
434
435    #[test]
436    fn clear_resets_state() {
437        let mut agg = DepthAggregator::new();
438
439        // Build up some book state
440        agg.update(&depth(1, 0, 100.0, 10.0), tr());
441        agg.update(&depth(1, 0, 99.0, 5.0), tr());
442        agg.update(&depth(0, 0, 101.0, 8.0), tr());
443        assert_eq!(agg.sequence, 3);
444
445        // Clear should reset everything
446        agg.clear();
447
448        assert_eq!(agg.sequence, 0, "sequence should reset to 0");
449        assert!(agg.bids.levels().is_empty(), "bids should be empty");
450        assert!(agg.asks.levels().is_empty(), "asks should be empty");
451
452        // Next update should work normally with sequence 1
453        let event = agg.update(&depth(1, 0, 50.0, 1.0), tr()).unwrap();
454        match event {
455            OrderBookEvent::Snapshot(book) => {
456                assert_eq!(book.sequence(), 1);
457                assert_eq!(book.bids().levels().len(), 1);
458            }
459            _ => panic!("Expected Snapshot"),
460        }
461    }
462}