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