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