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
28use super::{convert::IbPriceConverter, parse::is_representable_size};
29
30pub(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#[derive(Debug, Default)]
77pub struct QuoteCache {
78 quotes: AHashMap<InstrumentId, CachedQuote>,
80}
81
82#[derive(Debug, Clone)]
84struct CachedQuote {
85 bid_price: Option<f64>,
87 ask_price: Option<f64>,
89 bid_size: Option<f64>,
91 ask_size: Option<f64>,
93 last_emitted_bid_price: Option<f64>,
95 last_emitted_ask_price: Option<f64>,
97 last_complete_quote: Option<QuoteTick>,
99}
100
101impl QuoteCache {
102 #[must_use]
104 pub fn new() -> Self {
105 Self::default()
106 }
107
108 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 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 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 #[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 ignore_size_only
240 && let Some(last_bid) = cached.last_emitted_bid_price
241 && let Some(current_bid) = cached.bid_price
242 {
243 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 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 #[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 ignore_size_only
312 && let Some(last_ask) = cached.last_emitted_ask_price
313 && let Some(current_ask) = cached.ask_price
314 {
315 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 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 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 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 cached.last_complete_quote = Some(quote);
359
360 cached.last_emitted_bid_price = Some(bid_price);
362 cached.last_emitted_ask_price = Some(ask_price);
363
364 Some(quote)
365 }
366
367 pub fn clear(&mut self) {
369 self.quotes.clear();
370 }
371
372 #[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#[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 #[must_use]
400 pub fn new() -> Self {
401 Self::default()
402 }
403
404 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; 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 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 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 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 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 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}