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