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