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
28use super::{convert::IbPriceConverter, parse::is_representable_size};
29
30/// IB publishes price -1 when a side has no quote; a zero or negative price
31/// with genuine size is a legitimate value for spreads. The paired zero-size
32/// sentinel is handled by the size updates, which clear the side.
33// IB sends -1 without a size for a side that has no quote; -1 with a size is a real
34// price, such as a spread's bid
35pub(crate) fn is_sentinel_price(price: f64, size: Option<f64>) -> bool {
36    !price.is_finite()
37        || ((price + 1.0).abs() < f64::EPSILON && !size.is_some_and(|size| size > 0.0))
38}
39
40fn checked_quantity(size: f64, precision: u8) -> Option<Quantity> {
41    let quantity = match Quantity::new_checked(size, precision) {
42        Ok(quantity) => quantity,
43        Err(e) => {
44            tracing::warn!("Ignoring invalid IB quote size {size}: {e}");
45            return None;
46        }
47    };
48
49    if !is_representable_size(quantity, size, precision) {
50        tracing::warn!(
51            "Ignoring unrepresentable IB quote size {} with precision {}",
52            size,
53            precision
54        );
55        return None;
56    }
57
58    Some(quantity)
59}
60
61fn checked_price(price: anyhow::Result<Price>, ib_price: f64) -> Option<Price> {
62    match price {
63        Ok(price) => Some(price),
64        Err(e) => {
65            tracing::warn!("Ignoring invalid IB quote price {ib_price}: {e}");
66            None
67        }
68    }
69}
70
71/// Quote cache that accumulates IB tick updates to build complete quotes.
72///
73/// Interactive Brokers sends individual tick price and size updates (bid price,
74/// ask price, bid size, ask size). This cache accumulates these updates until
75/// we have a complete quote with both bid and ask sides.
76#[derive(Debug, Default)]
77pub struct QuoteCache {
78    /// Cached quote state per instrument.
79    quotes: AHashMap<InstrumentId, CachedQuote>,
80}
81
82/// Cached quote state for an instrument.
83#[derive(Debug, Clone)]
84struct CachedQuote {
85    /// Last bid price (tick type 1).
86    bid_price: Option<f64>,
87    /// Last ask price (tick type 2).
88    ask_price: Option<f64>,
89    /// Last bid size (tick type 0).
90    bid_size: Option<f64>,
91    /// Last ask size (tick type 3).
92    ask_size: Option<f64>,
93    /// Last emitted bid price (for filtering size-only updates).
94    last_emitted_bid_price: Option<f64>,
95    /// Last emitted ask price (for filtering size-only updates).
96    last_emitted_ask_price: Option<f64>,
97    /// Last complete quote tick (for fallback).
98    last_complete_quote: Option<QuoteTick>,
99}
100
101impl QuoteCache {
102    /// Create a new quote cache.
103    #[must_use]
104    pub fn new() -> Self {
105        Self::default()
106    }
107
108    /// Update bid price and return a complete quote if available.
109    pub fn update_bid_price(
110        &mut self,
111        instrument_id: InstrumentId,
112        price: f64,
113        size: Option<f64>,
114        prices: IbPriceConverter,
115        size_precision: u8,
116        ts_event: UnixNanos,
117        ts_init: UnixNanos,
118    ) -> Option<QuoteTick> {
119        if prices.is_sentinel(price, size) {
120            if let Some(cached) = self.quotes.get_mut(&instrument_id) {
121                cached.bid_price = None;
122                cached.bid_size = None;
123            }
124
125            return None;
126        }
127
128        let cached = self
129            .quotes
130            .entry(instrument_id)
131            .or_insert_with(|| CachedQuote {
132                bid_price: None,
133                ask_price: None,
134                bid_size: None,
135                ask_size: None,
136                last_emitted_bid_price: None,
137                last_emitted_ask_price: None,
138                last_complete_quote: None,
139            });
140
141        cached.bid_price = Some(price);
142        self.try_build_quote(instrument_id, prices, size_precision, ts_event, ts_init)
143    }
144
145    /// Update ask price and return a complete quote if available.
146    pub fn update_ask_price(
147        &mut self,
148        instrument_id: InstrumentId,
149        price: f64,
150        size: Option<f64>,
151        prices: IbPriceConverter,
152        size_precision: u8,
153        ts_event: UnixNanos,
154        ts_init: UnixNanos,
155    ) -> Option<QuoteTick> {
156        if prices.is_sentinel(price, size) {
157            if let Some(cached) = self.quotes.get_mut(&instrument_id) {
158                cached.ask_price = None;
159                cached.ask_size = None;
160            }
161
162            return None;
163        }
164
165        let cached = self
166            .quotes
167            .entry(instrument_id)
168            .or_insert_with(|| CachedQuote {
169                bid_price: None,
170                ask_price: None,
171                bid_size: None,
172                ask_size: None,
173                last_emitted_bid_price: None,
174                last_emitted_ask_price: None,
175                last_complete_quote: None,
176            });
177
178        cached.ask_price = Some(price);
179        self.try_build_quote(instrument_id, prices, size_precision, ts_event, ts_init)
180    }
181
182    /// Update bid size and return a complete quote if available.
183    pub fn update_bid_size(
184        &mut self,
185        instrument_id: InstrumentId,
186        size: f64,
187        prices: IbPriceConverter,
188        size_precision: u8,
189        ts_event: UnixNanos,
190        ts_init: UnixNanos,
191    ) -> Option<QuoteTick> {
192        self.update_bid_size_with_filter(
193            instrument_id,
194            size,
195            prices,
196            size_precision,
197            ts_event,
198            ts_init,
199            false,
200        )
201    }
202
203    /// Update bid size and return a complete quote if available, with optional filtering.
204    #[allow(clippy::too_many_arguments)]
205    pub fn update_bid_size_with_filter(
206        &mut self,
207        instrument_id: InstrumentId,
208        size: f64,
209        prices: IbPriceConverter,
210        size_precision: u8,
211        ts_event: UnixNanos,
212        ts_init: UnixNanos,
213        ignore_size_only: bool,
214    ) -> Option<QuoteTick> {
215        if !size.is_finite() || size <= 0.0 {
216            if let Some(cached) = self.quotes.get_mut(&instrument_id) {
217                cached.bid_size = None;
218            }
219
220            return None;
221        }
222
223        checked_quantity(size, size_precision)?;
224
225        let cached = self
226            .quotes
227            .entry(instrument_id)
228            .or_insert_with(|| CachedQuote {
229                bid_price: None,
230                ask_price: None,
231                bid_size: None,
232                ask_size: None,
233                last_emitted_bid_price: None,
234                last_emitted_ask_price: None,
235                last_complete_quote: None,
236            });
237
238        // If filtering and we have emitted prices, check if this is a size-only update
239        if ignore_size_only
240            && let Some(last_bid) = cached.last_emitted_bid_price
241            && let Some(current_bid) = cached.bid_price
242        {
243            // Prices are the same, this is a size-only update, skip it
244            if (last_bid - current_bid).abs() < f64::EPSILON {
245                cached.bid_size = Some(size);
246                return None;
247            }
248        }
249
250        cached.bid_size = Some(size);
251        self.try_build_quote(instrument_id, prices, size_precision, ts_event, ts_init)
252    }
253
254    /// Update ask size and return a complete quote if available.
255    pub fn update_ask_size(
256        &mut self,
257        instrument_id: InstrumentId,
258        size: f64,
259        prices: IbPriceConverter,
260        size_precision: u8,
261        ts_event: UnixNanos,
262        ts_init: UnixNanos,
263    ) -> Option<QuoteTick> {
264        self.update_ask_size_with_filter(
265            instrument_id,
266            size,
267            prices,
268            size_precision,
269            ts_event,
270            ts_init,
271            false,
272        )
273    }
274
275    /// Update ask size and return a complete quote if available, with optional filtering.
276    #[allow(clippy::too_many_arguments)]
277    pub fn update_ask_size_with_filter(
278        &mut self,
279        instrument_id: InstrumentId,
280        size: f64,
281        prices: IbPriceConverter,
282        size_precision: u8,
283        ts_event: UnixNanos,
284        ts_init: UnixNanos,
285        ignore_size_only: bool,
286    ) -> Option<QuoteTick> {
287        if !size.is_finite() || size <= 0.0 {
288            if let Some(cached) = self.quotes.get_mut(&instrument_id) {
289                cached.ask_size = None;
290            }
291
292            return None;
293        }
294
295        checked_quantity(size, size_precision)?;
296
297        let cached = self
298            .quotes
299            .entry(instrument_id)
300            .or_insert_with(|| CachedQuote {
301                bid_price: None,
302                ask_price: None,
303                bid_size: None,
304                ask_size: None,
305                last_emitted_bid_price: None,
306                last_emitted_ask_price: None,
307                last_complete_quote: None,
308            });
309
310        // If filtering and we have emitted prices, check if this is a size-only update
311        if ignore_size_only
312            && let Some(last_ask) = cached.last_emitted_ask_price
313            && let Some(current_ask) = cached.ask_price
314        {
315            // Prices are the same, this is a size-only update, skip it
316            if (last_ask - current_ask).abs() < f64::EPSILON {
317                cached.ask_size = Some(size);
318                return None;
319            }
320        }
321
322        cached.ask_size = Some(size);
323        self.try_build_quote(instrument_id, prices, size_precision, ts_event, ts_init)
324    }
325
326    /// Try to build a complete quote from cached data.
327    fn try_build_quote(
328        &mut self,
329        instrument_id: InstrumentId,
330        prices: IbPriceConverter,
331        size_precision: u8,
332        ts_event: UnixNanos,
333        ts_init: UnixNanos,
334    ) -> Option<QuoteTick> {
335        let cached = self.quotes.get_mut(&instrument_id)?;
336
337        // Check if we have all required fields
338        let bid_price = cached.bid_price?;
339        let ask_price = cached.ask_price?;
340        let bid_size = cached.bid_size?;
341        let ask_size = cached.ask_size?;
342
343        let bid_qty = checked_quantity(bid_size, size_precision)?;
344        let ask_qty = checked_quantity(ask_size, size_precision)?;
345
346        // Build the quote
347        let quote = QuoteTick::new(
348            instrument_id,
349            checked_price(prices.bid_price(bid_price), bid_price)?,
350            checked_price(prices.ask_price(ask_price), ask_price)?,
351            bid_qty,
352            ask_qty,
353            ts_event,
354            ts_init,
355        );
356
357        // Cache the complete quote
358        cached.last_complete_quote = Some(quote);
359
360        // Track emitted prices for filtering size-only updates
361        cached.last_emitted_bid_price = Some(bid_price);
362        cached.last_emitted_ask_price = Some(ask_price);
363
364        Some(quote)
365    }
366
367    /// Clear all cached quotes.
368    pub fn clear(&mut self) {
369        self.quotes.clear();
370    }
371
372    /// Get the last complete quote for an instrument (if available).
373    #[must_use]
374    pub fn get_last_quote(&self, instrument_id: &InstrumentId) -> Option<&QuoteTick> {
375        self.quotes
376            .get(instrument_id)
377            .and_then(|cached| cached.last_complete_quote.as_ref())
378    }
379}
380
381/// Option greeks cache that merges IB option-computation and open-interest ticks.
382#[derive(Debug, Default)]
383pub struct OptionGreeksCache {
384    greeks: AHashMap<InstrumentId, CachedOptionGreeks>,
385}
386
387#[derive(Debug, Clone, Default)]
388struct CachedOptionGreeks {
389    greeks: Option<OptionGreekValues>,
390    mark_iv: Option<f64>,
391    bid_iv: Option<f64>,
392    ask_iv: Option<f64>,
393    underlying_price: Option<f64>,
394    open_interest: Option<f64>,
395}
396
397impl OptionGreeksCache {
398    /// Create a new option greeks cache.
399    #[must_use]
400    pub fn new() -> Self {
401        Self::default()
402    }
403
404    /// Updates cached state from an IB option computation tick.
405    ///
406    /// IB reports a magnified option's computation in the contract's currency, not in its market
407    /// data unit: for an OZC option quoted in cents, the underlying price is in dollars and the
408    /// greeks are per dollar. The computation is therefore already in the instrument's units and
409    /// no price magnifier applies.
410    pub fn update_from_computation(
411        &mut self,
412        instrument_id: InstrumentId,
413        computation: &OptionComputation,
414        ts_event: UnixNanos,
415        ts_init: UnixNanos,
416    ) -> Option<OptionGreeks> {
417        let cached = self.greeks.entry(instrument_id).or_default();
418
419        match computation.field {
420            TickType::ModelOption | TickType::DelayedModelOption => {
421                let mut greeks = cached.greeks.unwrap_or_default();
422                if let Some(delta) = computation.delta {
423                    greeks.delta = delta;
424                }
425
426                if let Some(gamma) = computation.gamma {
427                    greeks.gamma = gamma;
428                }
429
430                if let Some(vega) = computation.vega {
431                    greeks.vega = vega;
432                }
433
434                if let Some(theta) = computation.theta {
435                    greeks.theta = theta;
436                }
437
438                greeks.rho = 0.0; // IB does not publish rho in tickOptionComputation
439                cached.greeks = Some(greeks);
440                if let Some(mark_iv) = computation.implied_volatility {
441                    cached.mark_iv = Some(mark_iv);
442                }
443            }
444            TickType::BidOption | TickType::DelayedBidOption => {
445                if let Some(bid_iv) = computation.implied_volatility {
446                    cached.bid_iv = Some(bid_iv);
447                }
448            }
449            TickType::AskOption | TickType::DelayedAskOption => {
450                if let Some(ask_iv) = computation.implied_volatility {
451                    cached.ask_iv = Some(ask_iv);
452                }
453            }
454            TickType::LastOption
455            | TickType::DelayedLastOption
456            | TickType::CustOptionComputation => {}
457            _ => return None,
458        }
459
460        if let Some(underlying_price) = computation.underlying_price {
461            cached.underlying_price = Some(underlying_price);
462        }
463
464        self.try_build_greeks(instrument_id, ts_event, ts_init)
465    }
466
467    /// Updates cached state from an open-interest tick.
468    pub fn update_open_interest(
469        &mut self,
470        instrument_id: InstrumentId,
471        open_interest: f64,
472        ts_event: UnixNanos,
473        ts_init: UnixNanos,
474    ) -> Option<OptionGreeks> {
475        let cached = self.greeks.entry(instrument_id).or_default();
476        cached.open_interest = Some(open_interest);
477        self.try_build_greeks(instrument_id, ts_event, ts_init)
478    }
479
480    fn try_build_greeks(
481        &self,
482        instrument_id: InstrumentId,
483        ts_event: UnixNanos,
484        ts_init: UnixNanos,
485    ) -> Option<OptionGreeks> {
486        let cached = self.greeks.get(&instrument_id)?;
487        let greeks = cached.greeks?;
488
489        Some(OptionGreeks {
490            instrument_id,
491            greeks,
492            convention: GreeksConvention::BlackScholes,
493            mark_iv: cached.mark_iv,
494            bid_iv: cached.bid_iv,
495            ask_iv: cached.ask_iv,
496            underlying_price: cached.underlying_price,
497            open_interest: cached.open_interest,
498            ts_event,
499            ts_init,
500        })
501    }
502
503    /// Clear all cached greeks.
504    pub fn clear(&mut self) {
505        self.greeks.clear();
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use ibapi::contracts::{OptionComputation, tick_types::TickType};
512    use nautilus_core::UnixNanos;
513    use nautilus_model::{
514        identifiers::{InstrumentId, Symbol, Venue},
515        instruments::Instrument,
516        types::Quantity,
517    };
518    use rstest::rstest;
519
520    use super::{OptionGreeksCache, QuoteCache, is_sentinel_price};
521    use crate::{data::convert::IbPriceConverter, stubs::currency_pair};
522
523    fn instrument_id() -> InstrumentId {
524        InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
525    }
526
527    #[rstest]
528    #[case(-1.0, None, true)]
529    #[case(-1.0, Some(0.0), true)]
530    #[case(-1.0, Some(3.0), false)]
531    #[case(f64::NAN, Some(3.0), true)]
532    #[case(f64::INFINITY, None, true)]
533    #[case(-1.25, None, false)]
534    #[case(0.0, None, false)]
535    #[case(99.5, None, false)]
536    fn test_is_sentinel_price(
537        #[case] price: f64,
538        #[case] size: Option<f64>,
539        #[case] expected: bool,
540    ) {
541        assert_eq!(is_sentinel_price(price, size), expected);
542    }
543
544    #[rstest]
545    fn test_quote_cache_clears_side_on_magnified_sentinel() {
546        // IB sends a magnified option's missing bid as -100 (OZC, magnifier 100, delayed data)
547        let mut cache = QuoteCache::new();
548        let instrument_id = instrument_id();
549        let prices = IbPriceConverter::new(5).with_price_magnifier(100);
550
551        cache.update_bid_price(
552            instrument_id,
553            5.5,
554            None,
555            prices,
556            0,
557            UnixNanos::new(1),
558            UnixNanos::new(1),
559        );
560        cache.update_ask_price(
561            instrument_id,
562            5.875,
563            None,
564            prices,
565            0,
566            UnixNanos::new(1),
567            UnixNanos::new(1),
568        );
569        cache.update_bid_size(
570            instrument_id,
571            10.0,
572            prices,
573            0,
574            UnixNanos::new(1),
575            UnixNanos::new(1),
576        );
577        let quote = cache
578            .update_ask_size(
579                instrument_id,
580                12.0,
581                prices,
582                0,
583                UnixNanos::new(1),
584                UnixNanos::new(1),
585            )
586            .unwrap();
587        assert_eq!(quote.bid_price.as_f64(), 0.055);
588        assert_eq!(quote.ask_price.as_f64(), 0.05875);
589
590        let cleared = cache.update_bid_price(
591            instrument_id,
592            -100.0,
593            None,
594            prices,
595            0,
596            UnixNanos::new(2),
597            UnixNanos::new(2),
598        );
599        let after = cache.update_ask_size(
600            instrument_id,
601            12.0,
602            prices,
603            0,
604            UnixNanos::new(3),
605            UnixNanos::new(3),
606        );
607
608        assert!(cleared.is_none());
609        assert!(after.is_none());
610    }
611
612    #[rstest]
613    fn test_quote_cache_emits_negative_spread_prices() {
614        let mut cache = QuoteCache::new();
615        let instrument_id = instrument_id();
616
617        cache.update_bid_price(
618            instrument_id,
619            -3.25,
620            None,
621            IbPriceConverter::new(2),
622            0,
623            UnixNanos::new(1),
624            UnixNanos::new(1),
625        );
626        cache.update_ask_price(
627            instrument_id,
628            -2.75,
629            None,
630            IbPriceConverter::new(2),
631            0,
632            UnixNanos::new(2),
633            UnixNanos::new(2),
634        );
635        cache.update_bid_size(
636            instrument_id,
637            5.0,
638            IbPriceConverter::new(2),
639            0,
640            UnixNanos::new(3),
641            UnixNanos::new(3),
642        );
643        let quote = cache
644            .update_ask_size(
645                instrument_id,
646                7.0,
647                IbPriceConverter::new(2),
648                0,
649                UnixNanos::new(4),
650                UnixNanos::new(4),
651            )
652            .unwrap();
653
654        assert_eq!(quote.bid_price.as_f64(), -3.25);
655        assert_eq!(quote.ask_price.as_f64(), -2.75);
656        assert_eq!(quote.bid_size.as_f64(), 5.0);
657        assert_eq!(quote.ask_size.as_f64(), 7.0);
658    }
659
660    #[rstest]
661    fn test_quote_cache_requires_both_prices() {
662        let mut cache = QuoteCache::new();
663        let instrument_id = instrument_id();
664
665        let quote = cache.update_bid_price(
666            instrument_id,
667            100.0,
668            None,
669            IbPriceConverter::new(2),
670            0,
671            UnixNanos::new(1),
672            UnixNanos::new(1),
673        );
674
675        assert!(quote.is_none());
676        assert!(cache.get_last_quote(&instrument_id).is_none());
677    }
678
679    #[rstest]
680    fn test_quote_cache_requires_both_sizes() {
681        let mut cache = QuoteCache::new();
682        let instrument_id = instrument_id();
683
684        cache.update_bid_price(
685            instrument_id,
686            100.0,
687            None,
688            IbPriceConverter::new(2),
689            0,
690            UnixNanos::new(1),
691            UnixNanos::new(1),
692        );
693        let quote = cache.update_ask_price(
694            instrument_id,
695            101.0,
696            None,
697            IbPriceConverter::new(2),
698            0,
699            UnixNanos::new(2),
700            UnixNanos::new(2),
701        );
702
703        assert!(quote.is_none());
704        cache.update_bid_size(
705            instrument_id,
706            10.0,
707            IbPriceConverter::new(2),
708            0,
709            UnixNanos::new(3),
710            UnixNanos::new(3),
711        );
712        let quote = cache
713            .update_ask_size(
714                instrument_id,
715                11.0,
716                IbPriceConverter::new(2),
717                0,
718                UnixNanos::new(4),
719                UnixNanos::new(4),
720            )
721            .unwrap();
722        assert_eq!(quote.bid_price.as_f64(), 100.0);
723        assert_eq!(quote.ask_price.as_f64(), 101.0);
724        assert_eq!(quote.bid_size.as_f64(), 10.0);
725        assert_eq!(quote.ask_size.as_f64(), 11.0);
726        assert!(cache.get_last_quote(&instrument_id).is_some());
727    }
728
729    #[rstest]
730    fn test_quote_cache_filters_size_only_updates_when_enabled() {
731        let mut cache = QuoteCache::new();
732        let instrument_id = instrument_id();
733
734        cache.update_bid_price(
735            instrument_id,
736            100.0,
737            None,
738            IbPriceConverter::new(2),
739            0,
740            UnixNanos::new(1),
741            UnixNanos::new(1),
742        );
743        cache.update_ask_price(
744            instrument_id,
745            101.0,
746            None,
747            IbPriceConverter::new(2),
748            0,
749            UnixNanos::new(2),
750            UnixNanos::new(2),
751        );
752        cache.update_bid_size(
753            instrument_id,
754            10.0,
755            IbPriceConverter::new(2),
756            0,
757            UnixNanos::new(3),
758            UnixNanos::new(3),
759        );
760        cache.update_ask_size(
761            instrument_id,
762            11.0,
763            IbPriceConverter::new(2),
764            0,
765            UnixNanos::new(4),
766            UnixNanos::new(4),
767        );
768
769        let quote = cache.update_bid_size_with_filter(
770            instrument_id,
771            10.0,
772            IbPriceConverter::new(2),
773            0,
774            UnixNanos::new(5),
775            UnixNanos::new(5),
776            true,
777        );
778
779        assert!(quote.is_none());
780        let last_quote = cache.get_last_quote(&instrument_id).unwrap();
781        assert_eq!(last_quote.bid_size.as_f64(), 10.0);
782    }
783
784    #[rstest]
785    fn test_quote_cache_drops_unrepresentable_size() {
786        let mut cache = QuoteCache::new();
787        let instrument_id = instrument_id();
788
789        cache.update_bid_price(
790            instrument_id,
791            100.0,
792            None,
793            IbPriceConverter::new(2),
794            0,
795            UnixNanos::new(1),
796            UnixNanos::new(1),
797        );
798        cache.update_ask_price(
799            instrument_id,
800            101.0,
801            None,
802            IbPriceConverter::new(2),
803            0,
804            UnixNanos::new(2),
805            UnixNanos::new(2),
806        );
807        cache.update_bid_size(
808            instrument_id,
809            10.0,
810            IbPriceConverter::new(2),
811            0,
812            UnixNanos::new(3),
813            UnixNanos::new(3),
814        );
815        cache.update_ask_size(
816            instrument_id,
817            11.0,
818            IbPriceConverter::new(2),
819            0,
820            UnixNanos::new(4),
821            UnixNanos::new(4),
822        );
823
824        let quote = cache.update_bid_size(
825            instrument_id,
826            0.5,
827            IbPriceConverter::new(2),
828            0,
829            UnixNanos::new(5),
830            UnixNanos::new(5),
831        );
832
833        assert!(quote.is_none());
834        let last_quote = cache.get_last_quote(&instrument_id).unwrap();
835        assert_eq!(last_quote.bid_size.as_f64(), 10.0);
836    }
837
838    #[rstest]
839    fn test_quote_cache_accepts_large_size_at_size_precision() {
840        let mut cache = QuoteCache::new();
841        let instrument_id = instrument_id();
842
843        cache.update_bid_price(
844            instrument_id,
845            1.16375,
846            None,
847            IbPriceConverter::new(5),
848            2,
849            UnixNanos::new(1),
850            UnixNanos::new(1),
851        );
852        cache.update_ask_price(
853            instrument_id,
854            1.1638,
855            None,
856            IbPriceConverter::new(5),
857            2,
858            UnixNanos::new(2),
859            UnixNanos::new(2),
860        );
861        cache.update_bid_size(
862            instrument_id,
863            2_500_000.0,
864            IbPriceConverter::new(5),
865            2,
866            UnixNanos::new(3),
867            UnixNanos::new(3),
868        );
869        let quote = cache
870            .update_ask_size(
871                instrument_id,
872                1_975_139.0,
873                IbPriceConverter::new(5),
874                2,
875                UnixNanos::new(4),
876                UnixNanos::new(4),
877            )
878            .unwrap();
879
880        assert_eq!(quote.ask_size, Quantity::new(1_975_139.0, 2));
881    }
882
883    #[rstest]
884    fn test_quote_cache_emits_update_after_price_change() {
885        let mut cache = QuoteCache::new();
886        let instrument_id = instrument_id();
887
888        cache.update_bid_price(
889            instrument_id,
890            100.0,
891            None,
892            IbPriceConverter::new(2),
893            0,
894            UnixNanos::new(1),
895            UnixNanos::new(1),
896        );
897        cache.update_ask_price(
898            instrument_id,
899            101.0,
900            None,
901            IbPriceConverter::new(2),
902            0,
903            UnixNanos::new(2),
904            UnixNanos::new(2),
905        );
906        cache.update_bid_size(
907            instrument_id,
908            10.0,
909            IbPriceConverter::new(2),
910            0,
911            UnixNanos::new(3),
912            UnixNanos::new(3),
913        );
914        cache.update_ask_size(
915            instrument_id,
916            11.0,
917            IbPriceConverter::new(2),
918            0,
919            UnixNanos::new(4),
920            UnixNanos::new(4),
921        );
922
923        let quote = cache.update_bid_price(
924            instrument_id,
925            100.5,
926            None,
927            IbPriceConverter::new(2),
928            0,
929            UnixNanos::new(5),
930            UnixNanos::new(5),
931        );
932
933        assert!(quote.is_some());
934        let quote = quote.unwrap();
935        assert_eq!(quote.bid_price.as_f64(), 100.5);
936        assert_eq!(quote.bid_size.as_f64(), 10.0);
937    }
938
939    #[rstest]
940    fn test_option_greeks_cache_waits_for_model_tick_before_emitting() {
941        let mut cache = OptionGreeksCache::new();
942        let instrument_id = instrument_id();
943
944        let bid_only = cache.update_from_computation(
945            instrument_id,
946            &OptionComputation {
947                field: TickType::BidOption,
948                implied_volatility: Some(0.24),
949                underlying_price: Some(155.0),
950                ..Default::default()
951            },
952            UnixNanos::new(1),
953            UnixNanos::new(1),
954        );
955
956        assert!(bid_only.is_none());
957
958        let model = cache.update_from_computation(
959            instrument_id,
960            &OptionComputation {
961                field: TickType::ModelOption,
962                implied_volatility: Some(0.25),
963                delta: Some(0.55),
964                gamma: Some(0.02),
965                vega: Some(0.15),
966                theta: Some(-0.05),
967                underlying_price: Some(155.0),
968                ..Default::default()
969            },
970            UnixNanos::new(2),
971            UnixNanos::new(2),
972        );
973
974        let greeks = model.unwrap();
975        assert_eq!(greeks.delta, 0.55);
976        assert_eq!(greeks.gamma, 0.02);
977        assert_eq!(greeks.vega, 0.15);
978        assert_eq!(greeks.theta, -0.05);
979        assert_eq!(greeks.rho, 0.0);
980        assert_eq!(greeks.mark_iv, Some(0.25));
981        assert_eq!(greeks.bid_iv, Some(0.24));
982        assert_eq!(greeks.ask_iv, None);
983        assert_eq!(greeks.underlying_price, Some(155.0));
984        assert_eq!(greeks.open_interest, None);
985    }
986
987    #[rstest]
988    fn test_option_greeks_cache_keeps_magnified_option_computation_units() {
989        // OZCX6 C0480 (priceMagnifier 100) on the IB paper Gateway: quotes in cents (bid 5.5),
990        // while the model computation is in dollars (option price 0.056, underlying 4.81)
991        let mut cache = OptionGreeksCache::new();
992
993        let computation = OptionComputation {
994            field: TickType::DelayedModelOption,
995            implied_volatility: Some(0.168_589_714_471_248_57),
996            delta: Some(0.536_139_125_076_405),
997            option_price: Some(0.055_970_557_886_452_07),
998            gamma: Some(3.126_512_157_892_507_5),
999            vega: Some(0.002_999_584_948_674_465),
1000            theta: Some(-0.002_886_804_069_145_994),
1001            underlying_price: Some(4.810_008_049_011_230_5),
1002            ..Default::default()
1003        };
1004
1005        let greeks = cache
1006            .update_from_computation(
1007                instrument_id(),
1008                &computation,
1009                UnixNanos::new(1),
1010                UnixNanos::new(1),
1011            )
1012            .unwrap();
1013
1014        assert_eq!(greeks.underlying_price, computation.underlying_price);
1015        assert_eq!(Some(greeks.delta), computation.delta);
1016        assert_eq!(Some(greeks.gamma), computation.gamma);
1017        assert_eq!(Some(greeks.vega), computation.vega);
1018        assert_eq!(Some(greeks.theta), computation.theta);
1019        assert_eq!(greeks.mark_iv, computation.implied_volatility);
1020    }
1021
1022    #[rstest]
1023    fn test_option_greeks_cache_merges_open_interest_after_model_tick() {
1024        let mut cache = OptionGreeksCache::new();
1025        let instrument_id = instrument_id();
1026
1027        let _ = cache.update_from_computation(
1028            instrument_id,
1029            &OptionComputation {
1030                field: TickType::ModelOption,
1031                implied_volatility: Some(0.25),
1032                delta: Some(0.55),
1033                gamma: Some(0.02),
1034                vega: Some(0.15),
1035                theta: Some(-0.05),
1036                underlying_price: Some(155.0),
1037                ..Default::default()
1038            },
1039            UnixNanos::new(1),
1040            UnixNanos::new(1),
1041        );
1042
1043        let greeks = cache
1044            .update_open_interest(instrument_id, 1000.0, UnixNanos::new(2), UnixNanos::new(2))
1045            .unwrap();
1046
1047        assert_eq!(greeks.open_interest, Some(1000.0));
1048        assert_eq!(greeks.mark_iv, Some(0.25));
1049        assert_eq!(greeks.delta, 0.55);
1050    }
1051
1052    #[rstest]
1053    fn test_quote_cache_rounds_fx_quote_to_increment() {
1054        let mut cache = QuoteCache::new();
1055        let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1056        let prices = IbPriceConverter::for_instrument(&instrument, 1);
1057
1058        cache.update_bid_price(
1059            instrument.id(),
1060            1.12097,
1061            Some(1_000_000.0),
1062            prices,
1063            0,
1064            UnixNanos::new(1),
1065            UnixNanos::new(1),
1066        );
1067        cache.update_ask_price(
1068            instrument.id(),
1069            1.12098,
1070            Some(1_000_000.0),
1071            prices,
1072            0,
1073            UnixNanos::new(1),
1074            UnixNanos::new(1),
1075        );
1076        cache.update_bid_size(
1077            instrument.id(),
1078            1_000_000.0,
1079            prices,
1080            0,
1081            UnixNanos::new(1),
1082            UnixNanos::new(1),
1083        );
1084        let quote = cache
1085            .update_ask_size(
1086                instrument.id(),
1087                1_000_000.0,
1088                prices,
1089                0,
1090                UnixNanos::new(1),
1091                UnixNanos::new(1),
1092            )
1093            .unwrap();
1094
1095        assert_eq!(quote.bid_price.as_f64(), 1.12095);
1096        assert_eq!(quote.ask_price.as_f64(), 1.12100);
1097    }
1098
1099    #[rstest]
1100    fn test_quote_cache_does_not_lock_tight_fx_quote() {
1101        let mut cache = QuoteCache::new();
1102        let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1103        let prices = IbPriceConverter::for_instrument(&instrument, 1);
1104        let (ts_event, ts_init) = (UnixNanos::new(1), UnixNanos::new(1));
1105
1106        cache.update_bid_price(
1107            instrument.id(),
1108            1.12098,
1109            Some(1_000_000.0),
1110            prices,
1111            0,
1112            ts_event,
1113            ts_init,
1114        );
1115        cache.update_ask_price(
1116            instrument.id(),
1117            1.12099,
1118            Some(1_000_000.0),
1119            prices,
1120            0,
1121            ts_event,
1122            ts_init,
1123        );
1124        cache.update_bid_size(instrument.id(), 1_000_000.0, prices, 0, ts_event, ts_init);
1125        let quote = cache
1126            .update_ask_size(instrument.id(), 1_000_000.0, prices, 0, ts_event, ts_init)
1127            .unwrap();
1128
1129        assert_eq!(quote.bid_price.as_f64(), 1.12095);
1130        assert_eq!(quote.ask_price.as_f64(), 1.12100);
1131    }
1132
1133    #[rstest]
1134    fn test_quote_cache_divides_magnified_quote() {
1135        let mut cache = QuoteCache::new();
1136        let instrument_id = instrument_id();
1137        let prices = IbPriceConverter::new(4).with_price_magnifier(100);
1138
1139        cache.update_bid_price(
1140            instrument_id,
1141            425.25,
1142            Some(10.0),
1143            prices,
1144            0,
1145            UnixNanos::new(1),
1146            UnixNanos::new(1),
1147        );
1148        cache.update_ask_price(
1149            instrument_id,
1150            425.5,
1151            Some(20.0),
1152            prices,
1153            0,
1154            UnixNanos::new(1),
1155            UnixNanos::new(1),
1156        );
1157        cache.update_bid_size(
1158            instrument_id,
1159            10.0,
1160            prices,
1161            0,
1162            UnixNanos::new(1),
1163            UnixNanos::new(1),
1164        );
1165        let quote = cache
1166            .update_ask_size(
1167                instrument_id,
1168                20.0,
1169                prices,
1170                0,
1171                UnixNanos::new(1),
1172                UnixNanos::new(1),
1173            )
1174            .unwrap();
1175
1176        assert_eq!(quote.bid_price.as_f64(), 4.2525);
1177        assert_eq!(quote.ask_price.as_f64(), 4.255);
1178    }
1179
1180    #[rstest]
1181    fn test_quote_cache_clears_side_on_sentinel_for_magnified_contract() {
1182        let mut cache = QuoteCache::new();
1183        let instrument_id = instrument_id();
1184        let prices = IbPriceConverter::new(4).with_price_magnifier(100);
1185        let (ts_event, ts_init) = (UnixNanos::new(1), UnixNanos::new(1));
1186
1187        cache.update_bid_price(
1188            instrument_id,
1189            425.25,
1190            Some(10.0),
1191            prices,
1192            0,
1193            ts_event,
1194            ts_init,
1195        );
1196        cache.update_ask_price(
1197            instrument_id,
1198            425.5,
1199            Some(20.0),
1200            prices,
1201            0,
1202            ts_event,
1203            ts_init,
1204        );
1205        cache.update_bid_size(instrument_id, 10.0, prices, 0, ts_event, ts_init);
1206        assert!(
1207            cache
1208                .update_ask_size(instrument_id, 20.0, prices, 0, ts_event, ts_init)
1209                .is_some()
1210        );
1211
1212        // IB's no-quote sentinel arrives as the raw -1, before any conversion, and clears the
1213        // side, so a later size update has no bid to complete a quote with
1214        let cleared =
1215            cache.update_bid_price(instrument_id, -1.0, None, prices, 0, ts_event, ts_init);
1216        let quote = cache.update_ask_size(instrument_id, 30.0, prices, 0, ts_event, ts_init);
1217
1218        assert!(cleared.is_none());
1219        assert!(quote.is_none());
1220    }
1221}