1use 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
28pub(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#[derive(Debug, Default)]
76pub struct QuoteCache {
77 quotes: AHashMap<InstrumentId, CachedQuote>,
79}
80
81#[derive(Debug, Clone)]
83struct CachedQuote {
84 bid_price: Option<f64>,
86 ask_price: Option<f64>,
88 bid_size: Option<f64>,
90 ask_size: Option<f64>,
92 last_emitted_bid_price: Option<f64>,
94 last_emitted_ask_price: Option<f64>,
96 last_complete_quote: Option<QuoteTick>,
98}
99
100impl QuoteCache {
101 #[must_use]
103 pub fn new() -> Self {
104 Self::default()
105 }
106
107 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 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 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 #[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 ignore_size_only
251 && let Some(last_bid) = cached.last_emitted_bid_price
252 && let Some(current_bid) = cached.bid_price
253 {
254 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 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 #[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 ignore_size_only
329 && let Some(last_ask) = cached.last_emitted_ask_price
330 && let Some(current_ask) = cached.ask_price
331 {
332 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 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 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 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 cached.last_complete_quote = Some(quote);
382
383 cached.last_emitted_bid_price = Some(bid_price);
385 cached.last_emitted_ask_price = Some(ask_price);
386
387 Some(quote)
388 }
389
390 pub fn clear(&mut self) {
392 self.quotes.clear();
393 }
394
395 #[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#[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 #[must_use]
423 pub fn new() -> Self {
424 Self::default()
425 }
426
427 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; 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 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 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}