Skip to main content

nautilus_interactive_brokers/data/
cache.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Caches for accumulating Interactive Brokers tick updates.
17
18use ahash::AHashMap;
19use ibapi::contracts::{OptionComputation, tick_types::TickType};
20use nautilus_core::UnixNanos;
21use nautilus_model::{
22    data::{QuoteTick, greeks::OptionGreekValues, option_chain::OptionGreeks},
23    enums::GreeksConvention,
24    identifiers::InstrumentId,
25    types::{Price, Quantity},
26};
27
28/// Quote cache that accumulates IB tick updates to build complete quotes.
29///
30/// Interactive Brokers sends individual tick price and size updates (bid price,
31/// ask price, bid size, ask size). This cache accumulates these updates until
32/// we have a complete quote with both bid and ask sides.
33#[derive(Debug, Default)]
34pub struct QuoteCache {
35    /// Cached quote state per instrument.
36    quotes: AHashMap<InstrumentId, CachedQuote>,
37}
38
39/// Cached quote state for an instrument.
40#[derive(Debug, Clone)]
41struct CachedQuote {
42    /// Last bid price (tick type 1).
43    bid_price: Option<f64>,
44    /// Last ask price (tick type 2).
45    ask_price: Option<f64>,
46    /// Last bid size (tick type 0).
47    bid_size: Option<f64>,
48    /// Last ask size (tick type 3).
49    ask_size: Option<f64>,
50    /// Last emitted bid price (for filtering size-only updates).
51    last_emitted_bid_price: Option<f64>,
52    /// Last emitted ask price (for filtering size-only updates).
53    last_emitted_ask_price: Option<f64>,
54    /// Last complete quote tick (for fallback).
55    last_complete_quote: Option<QuoteTick>,
56}
57
58impl QuoteCache {
59    /// Create a new quote cache.
60    #[must_use]
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Update bid price and return a complete quote if available.
66    pub fn update_bid_price(
67        &mut self,
68        instrument_id: InstrumentId,
69        price: f64,
70        price_precision: u8,
71        size_precision: u8,
72        ts_event: UnixNanos,
73        ts_init: UnixNanos,
74    ) -> Option<QuoteTick> {
75        let cached = self
76            .quotes
77            .entry(instrument_id)
78            .or_insert_with(|| CachedQuote {
79                bid_price: None,
80                ask_price: None,
81                bid_size: None,
82                ask_size: None,
83                last_emitted_bid_price: None,
84                last_emitted_ask_price: None,
85                last_complete_quote: None,
86            });
87
88        cached.bid_price = Some(price);
89        self.try_build_quote(
90            instrument_id,
91            price_precision,
92            size_precision,
93            ts_event,
94            ts_init,
95        )
96    }
97
98    /// Update ask price and return a complete quote if available.
99    pub fn update_ask_price(
100        &mut self,
101        instrument_id: InstrumentId,
102        price: f64,
103        price_precision: u8,
104        size_precision: u8,
105        ts_event: UnixNanos,
106        ts_init: UnixNanos,
107    ) -> Option<QuoteTick> {
108        let cached = self
109            .quotes
110            .entry(instrument_id)
111            .or_insert_with(|| CachedQuote {
112                bid_price: None,
113                ask_price: None,
114                bid_size: None,
115                ask_size: None,
116                last_emitted_bid_price: None,
117                last_emitted_ask_price: None,
118                last_complete_quote: None,
119            });
120
121        cached.ask_price = Some(price);
122        self.try_build_quote(
123            instrument_id,
124            price_precision,
125            size_precision,
126            ts_event,
127            ts_init,
128        )
129    }
130
131    /// Update bid size and return a complete quote if available.
132    pub fn update_bid_size(
133        &mut self,
134        instrument_id: InstrumentId,
135        size: f64,
136        price_precision: u8,
137        size_precision: u8,
138        ts_event: UnixNanos,
139        ts_init: UnixNanos,
140    ) -> Option<QuoteTick> {
141        self.update_bid_size_with_filter(
142            instrument_id,
143            size,
144            price_precision,
145            size_precision,
146            ts_event,
147            ts_init,
148            false,
149        )
150    }
151
152    /// Update bid size and return a complete quote if available, with optional filtering.
153    #[allow(clippy::too_many_arguments)]
154    pub fn update_bid_size_with_filter(
155        &mut self,
156        instrument_id: InstrumentId,
157        size: f64,
158        price_precision: u8,
159        size_precision: u8,
160        ts_event: UnixNanos,
161        ts_init: UnixNanos,
162        ignore_size_only: bool,
163    ) -> Option<QuoteTick> {
164        let cached = self
165            .quotes
166            .entry(instrument_id)
167            .or_insert_with(|| CachedQuote {
168                bid_price: None,
169                ask_price: None,
170                bid_size: None,
171                ask_size: None,
172                last_emitted_bid_price: None,
173                last_emitted_ask_price: None,
174                last_complete_quote: None,
175            });
176
177        // If filtering and we have emitted prices, check if this is a size-only update
178        if ignore_size_only
179            && let Some(last_bid) = cached.last_emitted_bid_price
180            && let Some(current_bid) = cached.bid_price
181        {
182            // Prices are the same, this is a size-only update, skip it
183            if (last_bid - current_bid).abs() < f64::EPSILON {
184                cached.bid_size = Some(size);
185                return None;
186            }
187        }
188
189        cached.bid_size = Some(size);
190        self.try_build_quote(
191            instrument_id,
192            price_precision,
193            size_precision,
194            ts_event,
195            ts_init,
196        )
197    }
198
199    /// Update ask size and return a complete quote if available.
200    pub fn update_ask_size(
201        &mut self,
202        instrument_id: InstrumentId,
203        size: f64,
204        price_precision: u8,
205        size_precision: u8,
206        ts_event: UnixNanos,
207        ts_init: UnixNanos,
208    ) -> Option<QuoteTick> {
209        self.update_ask_size_with_filter(
210            instrument_id,
211            size,
212            price_precision,
213            size_precision,
214            ts_event,
215            ts_init,
216            false,
217        )
218    }
219
220    /// Update ask size and return a complete quote if available, with optional filtering.
221    #[allow(clippy::too_many_arguments)]
222    pub fn update_ask_size_with_filter(
223        &mut self,
224        instrument_id: InstrumentId,
225        size: f64,
226        price_precision: u8,
227        size_precision: u8,
228        ts_event: UnixNanos,
229        ts_init: UnixNanos,
230        ignore_size_only: bool,
231    ) -> Option<QuoteTick> {
232        let cached = self
233            .quotes
234            .entry(instrument_id)
235            .or_insert_with(|| CachedQuote {
236                bid_price: None,
237                ask_price: None,
238                bid_size: None,
239                ask_size: None,
240                last_emitted_bid_price: None,
241                last_emitted_ask_price: None,
242                last_complete_quote: None,
243            });
244
245        // If filtering and we have emitted prices, check if this is a size-only update
246        if ignore_size_only
247            && let Some(last_ask) = cached.last_emitted_ask_price
248            && let Some(current_ask) = cached.ask_price
249        {
250            // Prices are the same, this is a size-only update, skip it
251            if (last_ask - current_ask).abs() < f64::EPSILON {
252                cached.ask_size = Some(size);
253                return None;
254            }
255        }
256
257        cached.ask_size = Some(size);
258        self.try_build_quote(
259            instrument_id,
260            price_precision,
261            size_precision,
262            ts_event,
263            ts_init,
264        )
265    }
266
267    /// Try to build a complete quote from cached data.
268    fn try_build_quote(
269        &mut self,
270        instrument_id: InstrumentId,
271        price_precision: u8,
272        size_precision: u8,
273        ts_event: UnixNanos,
274        ts_init: UnixNanos,
275    ) -> Option<QuoteTick> {
276        let cached = self.quotes.get_mut(&instrument_id)?;
277
278        // Check if we have all required fields
279        let bid_price = cached.bid_price?;
280        let ask_price = cached.ask_price?;
281        let bid_size = cached.bid_size.unwrap_or(0.0);
282        let ask_size = cached.ask_size.unwrap_or(0.0);
283
284        // Build the quote
285        let quote = QuoteTick::new(
286            instrument_id,
287            Price::new(bid_price, price_precision),
288            Price::new(ask_price, price_precision),
289            Quantity::new(bid_size, size_precision),
290            Quantity::new(ask_size, size_precision),
291            ts_event,
292            ts_init,
293        );
294
295        // Cache the complete quote
296        cached.last_complete_quote = Some(quote);
297
298        // Track emitted prices for filtering size-only updates
299        cached.last_emitted_bid_price = Some(bid_price);
300        cached.last_emitted_ask_price = Some(ask_price);
301
302        Some(quote)
303    }
304
305    /// Clear all cached quotes.
306    pub fn clear(&mut self) {
307        self.quotes.clear();
308    }
309
310    /// Get the last complete quote for an instrument (if available).
311    #[must_use]
312    pub fn get_last_quote(&self, instrument_id: &InstrumentId) -> Option<&QuoteTick> {
313        self.quotes
314            .get(instrument_id)
315            .and_then(|cached| cached.last_complete_quote.as_ref())
316    }
317}
318
319/// Option greeks cache that merges IB option-computation and open-interest ticks.
320#[derive(Debug, Default)]
321pub struct OptionGreeksCache {
322    greeks: AHashMap<InstrumentId, CachedOptionGreeks>,
323}
324
325#[derive(Debug, Clone, Default)]
326struct CachedOptionGreeks {
327    greeks: Option<OptionGreekValues>,
328    mark_iv: Option<f64>,
329    bid_iv: Option<f64>,
330    ask_iv: Option<f64>,
331    underlying_price: Option<f64>,
332    open_interest: Option<f64>,
333}
334
335impl OptionGreeksCache {
336    /// Create a new option greeks cache.
337    #[must_use]
338    pub fn new() -> Self {
339        Self::default()
340    }
341
342    /// Updates cached state from an IB option computation tick.
343    pub fn update_from_computation(
344        &mut self,
345        instrument_id: InstrumentId,
346        computation: &OptionComputation,
347        ts_event: UnixNanos,
348        ts_init: UnixNanos,
349    ) -> Option<OptionGreeks> {
350        let cached = self.greeks.entry(instrument_id).or_default();
351
352        match computation.field {
353            TickType::ModelOption | TickType::DelayedModelOption => {
354                let mut greeks = cached.greeks.unwrap_or_default();
355                if let Some(delta) = computation.delta {
356                    greeks.delta = delta;
357                }
358
359                if let Some(gamma) = computation.gamma {
360                    greeks.gamma = gamma;
361                }
362
363                if let Some(vega) = computation.vega {
364                    greeks.vega = vega;
365                }
366
367                if let Some(theta) = computation.theta {
368                    greeks.theta = theta;
369                }
370                greeks.rho = 0.0; // IB does not publish rho in tickOptionComputation
371                cached.greeks = Some(greeks);
372
373                if let Some(mark_iv) = computation.implied_volatility {
374                    cached.mark_iv = Some(mark_iv);
375                }
376            }
377            TickType::BidOption | TickType::DelayedBidOption => {
378                if let Some(bid_iv) = computation.implied_volatility {
379                    cached.bid_iv = Some(bid_iv);
380                }
381            }
382            TickType::AskOption | TickType::DelayedAskOption => {
383                if let Some(ask_iv) = computation.implied_volatility {
384                    cached.ask_iv = Some(ask_iv);
385                }
386            }
387            TickType::LastOption
388            | TickType::DelayedLastOption
389            | TickType::CustOptionComputation => {}
390            _ => return None,
391        }
392
393        if let Some(underlying_price) = computation.underlying_price {
394            cached.underlying_price = Some(underlying_price);
395        }
396
397        self.try_build_greeks(instrument_id, ts_event, ts_init)
398    }
399
400    /// Updates cached state from an open-interest tick.
401    pub fn update_open_interest(
402        &mut self,
403        instrument_id: InstrumentId,
404        open_interest: f64,
405        ts_event: UnixNanos,
406        ts_init: UnixNanos,
407    ) -> Option<OptionGreeks> {
408        let cached = self.greeks.entry(instrument_id).or_default();
409        cached.open_interest = Some(open_interest);
410        self.try_build_greeks(instrument_id, ts_event, ts_init)
411    }
412
413    fn try_build_greeks(
414        &self,
415        instrument_id: InstrumentId,
416        ts_event: UnixNanos,
417        ts_init: UnixNanos,
418    ) -> Option<OptionGreeks> {
419        let cached = self.greeks.get(&instrument_id)?;
420        let greeks = cached.greeks?;
421
422        Some(OptionGreeks {
423            instrument_id,
424            greeks,
425            convention: GreeksConvention::BlackScholes,
426            mark_iv: cached.mark_iv,
427            bid_iv: cached.bid_iv,
428            ask_iv: cached.ask_iv,
429            underlying_price: cached.underlying_price,
430            open_interest: cached.open_interest,
431            ts_event,
432            ts_init,
433        })
434    }
435
436    /// Clear all cached greeks.
437    pub fn clear(&mut self) {
438        self.greeks.clear();
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use ibapi::contracts::{OptionComputation, tick_types::TickType};
445    use nautilus_core::UnixNanos;
446    use nautilus_model::identifiers::{InstrumentId, Symbol, Venue};
447    use rstest::rstest;
448
449    use super::{OptionGreeksCache, QuoteCache};
450
451    fn instrument_id() -> InstrumentId {
452        InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
453    }
454
455    #[rstest]
456    fn test_quote_cache_requires_both_prices() {
457        let mut cache = QuoteCache::new();
458        let instrument_id = instrument_id();
459
460        let quote = cache.update_bid_price(
461            instrument_id,
462            100.0,
463            2,
464            0,
465            UnixNanos::new(1),
466            UnixNanos::new(1),
467        );
468
469        assert!(quote.is_none());
470        assert!(cache.get_last_quote(&instrument_id).is_none());
471    }
472
473    #[rstest]
474    fn test_quote_cache_builds_complete_quote_with_default_sizes() {
475        let mut cache = QuoteCache::new();
476        let instrument_id = instrument_id();
477
478        cache.update_bid_price(
479            instrument_id,
480            100.0,
481            2,
482            0,
483            UnixNanos::new(1),
484            UnixNanos::new(1),
485        );
486        let quote = cache.update_ask_price(
487            instrument_id,
488            101.0,
489            2,
490            0,
491            UnixNanos::new(2),
492            UnixNanos::new(2),
493        );
494
495        assert!(quote.is_some());
496        let quote = quote.unwrap();
497        assert_eq!(quote.bid_price.as_f64(), 100.0);
498        assert_eq!(quote.ask_price.as_f64(), 101.0);
499        assert_eq!(quote.bid_size.as_f64(), 0.0);
500        assert_eq!(quote.ask_size.as_f64(), 0.0);
501        assert!(cache.get_last_quote(&instrument_id).is_some());
502    }
503
504    #[rstest]
505    fn test_quote_cache_filters_size_only_updates_when_enabled() {
506        let mut cache = QuoteCache::new();
507        let instrument_id = instrument_id();
508
509        cache.update_bid_price(
510            instrument_id,
511            100.0,
512            2,
513            0,
514            UnixNanos::new(1),
515            UnixNanos::new(1),
516        );
517        cache.update_ask_price(
518            instrument_id,
519            101.0,
520            2,
521            0,
522            UnixNanos::new(2),
523            UnixNanos::new(2),
524        );
525
526        let quote = cache.update_bid_size_with_filter(
527            instrument_id,
528            10.0,
529            2,
530            0,
531            UnixNanos::new(3),
532            UnixNanos::new(3),
533            true,
534        );
535
536        assert!(quote.is_none());
537        let last_quote = cache.get_last_quote(&instrument_id).unwrap();
538        assert_eq!(last_quote.bid_size.as_f64(), 0.0);
539    }
540
541    #[rstest]
542    fn test_quote_cache_emits_update_after_price_change() {
543        let mut cache = QuoteCache::new();
544        let instrument_id = instrument_id();
545
546        cache.update_bid_price(
547            instrument_id,
548            100.0,
549            2,
550            0,
551            UnixNanos::new(1),
552            UnixNanos::new(1),
553        );
554        cache.update_ask_price(
555            instrument_id,
556            101.0,
557            2,
558            0,
559            UnixNanos::new(2),
560            UnixNanos::new(2),
561        );
562        cache.update_bid_size_with_filter(
563            instrument_id,
564            10.0,
565            2,
566            0,
567            UnixNanos::new(3),
568            UnixNanos::new(3),
569            true,
570        );
571
572        let quote = cache.update_bid_price(
573            instrument_id,
574            100.5,
575            2,
576            0,
577            UnixNanos::new(4),
578            UnixNanos::new(4),
579        );
580
581        assert!(quote.is_some());
582        let quote = quote.unwrap();
583        assert_eq!(quote.bid_price.as_f64(), 100.5);
584        assert_eq!(quote.bid_size.as_f64(), 10.0);
585    }
586
587    #[rstest]
588    fn test_option_greeks_cache_waits_for_model_tick_before_emitting() {
589        let mut cache = OptionGreeksCache::new();
590        let instrument_id = instrument_id();
591
592        let bid_only = cache.update_from_computation(
593            instrument_id,
594            &OptionComputation {
595                field: TickType::BidOption,
596                implied_volatility: Some(0.24),
597                underlying_price: Some(155.0),
598                ..Default::default()
599            },
600            UnixNanos::new(1),
601            UnixNanos::new(1),
602        );
603
604        assert!(bid_only.is_none());
605
606        let model = cache.update_from_computation(
607            instrument_id,
608            &OptionComputation {
609                field: TickType::ModelOption,
610                implied_volatility: Some(0.25),
611                delta: Some(0.55),
612                gamma: Some(0.02),
613                vega: Some(0.15),
614                theta: Some(-0.05),
615                underlying_price: Some(155.0),
616                ..Default::default()
617            },
618            UnixNanos::new(2),
619            UnixNanos::new(2),
620        );
621
622        let greeks = model.unwrap();
623        assert_eq!(greeks.delta, 0.55);
624        assert_eq!(greeks.gamma, 0.02);
625        assert_eq!(greeks.vega, 0.15);
626        assert_eq!(greeks.theta, -0.05);
627        assert_eq!(greeks.rho, 0.0);
628        assert_eq!(greeks.mark_iv, Some(0.25));
629        assert_eq!(greeks.bid_iv, Some(0.24));
630        assert_eq!(greeks.ask_iv, None);
631        assert_eq!(greeks.underlying_price, Some(155.0));
632        assert_eq!(greeks.open_interest, None);
633    }
634
635    #[rstest]
636    fn test_option_greeks_cache_merges_open_interest_after_model_tick() {
637        let mut cache = OptionGreeksCache::new();
638        let instrument_id = instrument_id();
639
640        let _ = cache.update_from_computation(
641            instrument_id,
642            &OptionComputation {
643                field: TickType::ModelOption,
644                implied_volatility: Some(0.25),
645                delta: Some(0.55),
646                gamma: Some(0.02),
647                vega: Some(0.15),
648                theta: Some(-0.05),
649                underlying_price: Some(155.0),
650                ..Default::default()
651            },
652            UnixNanos::new(1),
653            UnixNanos::new(1),
654        );
655
656        let greeks = cache
657            .update_open_interest(instrument_id, 1000.0, UnixNanos::new(2), UnixNanos::new(2))
658            .unwrap();
659
660        assert_eq!(greeks.open_interest, Some(1000.0));
661        assert_eq!(greeks.mark_iv, Some(0.25));
662        assert_eq!(greeks.delta, 0.55);
663    }
664}