1use ahash::AHashMap;
24use nautilus_core::UnixNanos;
25use nautilus_model::{
26 data::quote::QuoteTick,
27 identifiers::InstrumentId,
28 types::{Price, Quantity},
29};
30
31#[derive(Debug, Clone)]
43pub struct QuoteCache {
44 quotes: AHashMap<InstrumentId, QuoteTick>,
45}
46
47impl QuoteCache {
48 #[must_use]
50 pub fn new() -> Self {
51 Self {
52 quotes: AHashMap::new(),
53 }
54 }
55
56 #[must_use]
58 pub fn get(&self, instrument_id: &InstrumentId) -> Option<&QuoteTick> {
59 self.quotes.get(instrument_id)
60 }
61
62 pub fn insert(&mut self, instrument_id: InstrumentId, quote: QuoteTick) -> Option<QuoteTick> {
66 self.quotes.insert(instrument_id, quote)
67 }
68
69 pub fn remove(&mut self, instrument_id: &InstrumentId) -> Option<QuoteTick> {
73 self.quotes.remove(instrument_id)
74 }
75
76 #[must_use]
78 pub fn contains(&self, instrument_id: &InstrumentId) -> bool {
79 self.quotes.contains_key(instrument_id)
80 }
81
82 #[must_use]
84 pub fn len(&self) -> usize {
85 self.quotes.len()
86 }
87
88 #[must_use]
90 pub fn is_empty(&self) -> bool {
91 self.quotes.is_empty()
92 }
93
94 pub fn clear(&mut self) {
99 self.quotes.clear();
100 }
101
102 #[expect(clippy::too_many_arguments)]
114 pub fn process(
115 &mut self,
116 instrument_id: InstrumentId,
117 bid_price: Option<Price>,
118 ask_price: Option<Price>,
119 bid_size: Option<Quantity>,
120 ask_size: Option<Quantity>,
121 ts_event: UnixNanos,
122 ts_init: UnixNanos,
123 ) -> anyhow::Result<QuoteTick> {
124 let cached = self.quotes.get(&instrument_id);
125
126 let Some(bid_price) = bid_price.or_else(|| cached.map(|quote| quote.bid_price)) else {
128 anyhow::bail!(
129 "Cannot process partial quote for {instrument_id}: missing bid_price and no cached value"
130 );
131 };
132
133 let Some(ask_price) = ask_price.or_else(|| cached.map(|quote| quote.ask_price)) else {
134 anyhow::bail!(
135 "Cannot process partial quote for {instrument_id}: missing ask_price and no cached value"
136 );
137 };
138
139 let Some(bid_size) = bid_size.or_else(|| cached.map(|quote| quote.bid_size)) else {
140 anyhow::bail!(
141 "Cannot process partial quote for {instrument_id}: missing bid_size and no cached value"
142 );
143 };
144
145 let Some(ask_size) = ask_size.or_else(|| cached.map(|quote| quote.ask_size)) else {
146 anyhow::bail!(
147 "Cannot process partial quote for {instrument_id}: missing ask_size and no cached value"
148 );
149 };
150
151 let quote = QuoteTick::new(
152 instrument_id,
153 bid_price,
154 ask_price,
155 bid_size,
156 ask_size,
157 ts_event,
158 ts_init,
159 );
160
161 self.quotes.insert(instrument_id, quote);
162
163 Ok(quote)
164 }
165}
166
167impl Default for QuoteCache {
168 fn default() -> Self {
169 Self::new()
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use nautilus_core::UnixNanos;
176 use nautilus_model::types::{Price, Quantity};
177 use rstest::rstest;
178
179 use super::*;
180
181 fn make_quote(instrument_id: InstrumentId, bid: &str, ask: &str) -> QuoteTick {
182 QuoteTick::new(
183 instrument_id,
184 Price::from(bid),
185 Price::from(ask),
186 Quantity::from("10.0"),
187 Quantity::from("20.0"),
188 UnixNanos::default(),
189 UnixNanos::default(),
190 )
191 }
192
193 #[rstest]
194 fn test_new_cache_is_empty() {
195 let cache = QuoteCache::new();
196 assert!(cache.is_empty());
197 assert_eq!(cache.len(), 0);
198 }
199
200 #[rstest]
201 fn test_insert_and_get() {
202 let mut cache = QuoteCache::new();
203 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
204 let quote = make_quote(instrument_id, "100.0", "101.0");
205
206 assert_eq!(cache.insert(instrument_id, quote), None);
207 assert_eq!(cache.len(), 1);
208 assert!(cache.contains(&instrument_id));
209 assert_eq!(cache.get(&instrument_id), Some("e));
210 }
211
212 #[rstest]
213 fn test_insert_returns_previous_value() {
214 let mut cache = QuoteCache::new();
215 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
216 let quote1 = make_quote(instrument_id, "100.0", "101.0");
217 let quote2 = make_quote(instrument_id, "102.0", "103.0");
218
219 cache.insert(instrument_id, quote1);
220 let previous = cache.insert(instrument_id, quote2);
221
222 assert_eq!(previous, Some(quote1));
223 assert_eq!(cache.len(), 1);
224 assert_eq!(cache.get(&instrument_id), Some("e2));
225 }
226
227 #[rstest]
228 fn test_remove() {
229 let mut cache = QuoteCache::new();
230 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
231 let quote = make_quote(instrument_id, "100.0", "101.0");
232
233 cache.insert(instrument_id, quote);
234 assert_eq!(cache.remove(&instrument_id), Some(quote));
235 assert!(cache.is_empty());
236 assert!(!cache.contains(&instrument_id));
237 assert_eq!(cache.get(&instrument_id), None);
238 }
239
240 #[rstest]
241 fn test_remove_nonexistent() {
242 let mut cache = QuoteCache::new();
243 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
244
245 assert_eq!(cache.remove(&instrument_id), None);
246 }
247
248 #[rstest]
249 fn test_clear() {
250 let mut cache = QuoteCache::new();
251 let id1 = InstrumentId::from("BTCUSDT.BINANCE");
252 let id2 = InstrumentId::from("ETHUSDT.BINANCE");
253
254 cache.insert(id1, make_quote(id1, "100.0", "101.0"));
255 cache.insert(id2, make_quote(id2, "200.0", "201.0"));
256
257 assert_eq!(cache.len(), 2);
258
259 cache.clear();
260
261 assert!(cache.is_empty());
262 assert_eq!(cache.len(), 0);
263 assert!(!cache.contains(&id1));
264 assert!(!cache.contains(&id2));
265 }
266
267 #[rstest]
268 fn test_multiple_instruments() {
269 let mut cache = QuoteCache::new();
270 let id1 = InstrumentId::from("BTCUSDT.BINANCE");
271 let id2 = InstrumentId::from("ETHUSDT.BINANCE");
272 let id3 = InstrumentId::from("XRPUSDT.BINANCE");
273
274 let quote1 = make_quote(id1, "100.0", "101.0");
275 let quote2 = make_quote(id2, "200.0", "201.0");
276 let quote3 = make_quote(id3, "0.50", "0.51");
277
278 cache.insert(id1, quote1);
279 cache.insert(id2, quote2);
280 cache.insert(id3, quote3);
281
282 assert_eq!(cache.len(), 3);
283 assert_eq!(cache.get(&id1), Some("e1));
284 assert_eq!(cache.get(&id2), Some("e2));
285 assert_eq!(cache.get(&id3), Some("e3));
286 }
287
288 #[rstest]
289 fn test_default() {
290 let cache = QuoteCache::default();
291 assert!(cache.is_empty());
292 }
293
294 #[rstest]
295 fn test_clone() {
296 let mut cache = QuoteCache::new();
297 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
298 let quote = make_quote(instrument_id, "100.0", "101.0");
299
300 cache.insert(instrument_id, quote);
301
302 let cloned = cache.clone();
303 assert_eq!(cloned.len(), 1);
304 assert_eq!(cloned.get(&instrument_id), Some("e));
305 }
306
307 #[rstest]
308 fn test_process_complete_quote() {
309 let mut cache = QuoteCache::new();
310 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
311
312 let result = cache.process(
313 instrument_id,
314 Some(Price::from("100.5")),
315 Some(Price::from("101.0")),
316 Some(Quantity::from("10.0")),
317 Some(Quantity::from("20.0")),
318 UnixNanos::default(),
319 UnixNanos::default(),
320 );
321
322 assert!(result.is_ok());
323 let quote = result.unwrap();
324 assert_eq!(quote.instrument_id, instrument_id);
325 assert_eq!(quote.bid_price, Price::from("100.5"));
326 assert_eq!(quote.ask_price, Price::from("101.0"));
327 assert_eq!(quote.bid_size, Quantity::from("10.0"));
328 assert_eq!(quote.ask_size, Quantity::from("20.0"));
329
330 assert_eq!(cache.len(), 1);
332 assert_eq!(cache.get(&instrument_id), Some("e));
333 }
334
335 #[rstest]
336 #[case::bid_price("bid_price")]
337 #[case::ask_price("ask_price")]
338 #[case::bid_size("bid_size")]
339 #[case::ask_size("ask_size")]
340 fn test_process_partial_quote_without_cache(#[case] missing_field: &str) {
341 let mut cache = QuoteCache::new();
342 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
343 let mut bid_price = Some(Price::from("100.0"));
344 let mut ask_price = Some(Price::from("101.0"));
345 let mut bid_size = Some(Quantity::from("10.0"));
346 let mut ask_size = Some(Quantity::from("20.0"));
347
348 match missing_field {
349 "bid_price" => bid_price = None,
350 "ask_price" => ask_price = None,
351 "bid_size" => bid_size = None,
352 "ask_size" => ask_size = None,
353 _ => unreachable!(),
354 }
355
356 let error = cache
357 .process(
358 instrument_id,
359 bid_price,
360 ask_price,
361 bid_size,
362 ask_size,
363 UnixNanos::default(),
364 UnixNanos::default(),
365 )
366 .unwrap_err();
367
368 assert_eq!(
369 error.to_string(),
370 format!(
371 "Cannot process partial quote for {instrument_id}: missing {missing_field} and no cached value"
372 )
373 );
374 assert!(cache.is_empty());
375 }
376
377 #[rstest]
378 fn test_process_partial_quote_with_cache() {
379 let mut cache = QuoteCache::new();
380 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
381
382 let first_quote = cache
384 .process(
385 instrument_id,
386 Some(Price::from("100.0")),
387 Some(Price::from("101.0")),
388 Some(Quantity::from("10.0")),
389 Some(Quantity::from("20.0")),
390 UnixNanos::default(),
391 UnixNanos::default(),
392 )
393 .unwrap();
394
395 let result = cache.process(
397 instrument_id,
398 Some(Price::from("100.5")),
399 None, Some(Quantity::from("15.0")),
401 None, UnixNanos::default(),
403 UnixNanos::default(),
404 );
405
406 assert!(result.is_ok());
407 let quote = result.unwrap();
408
409 assert_eq!(quote.bid_price, Price::from("100.5"));
411 assert_eq!(quote.bid_size, Quantity::from("15.0"));
412
413 assert_eq!(quote.ask_price, first_quote.ask_price);
415 assert_eq!(quote.ask_size, first_quote.ask_size);
416
417 assert_eq!(cache.get(&instrument_id), Some("e));
419 }
420
421 #[rstest]
422 fn test_process_partial_quote_uses_cached_bid_side() {
423 let mut cache = QuoteCache::new();
424 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
425 let first_quote = cache
426 .process(
427 instrument_id,
428 Some(Price::from("100.0")),
429 Some(Price::from("101.0")),
430 Some(Quantity::from("10.0")),
431 Some(Quantity::from("20.0")),
432 UnixNanos::from(1),
433 UnixNanos::from(2),
434 )
435 .unwrap();
436
437 let quote = cache
438 .process(
439 instrument_id,
440 None,
441 Some(Price::from("101.5")),
442 None,
443 Some(Quantity::from("25.0")),
444 UnixNanos::from(3),
445 UnixNanos::from(4),
446 )
447 .unwrap();
448
449 assert_eq!(quote.bid_price, first_quote.bid_price);
450 assert_eq!(quote.ask_price, Price::from("101.5"));
451 assert_eq!(quote.bid_size, first_quote.bid_size);
452 assert_eq!(quote.ask_size, Quantity::from("25.0"));
453 assert_eq!(quote.ts_event, UnixNanos::from(3));
454 assert_eq!(quote.ts_init, UnixNanos::from(4));
455 assert_eq!(cache.get(&instrument_id), Some("e));
456 }
457
458 #[rstest]
459 fn test_process_updates_cache() {
460 let mut cache = QuoteCache::new();
461 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
462
463 cache
465 .process(
466 instrument_id,
467 Some(Price::from("100.0")),
468 Some(Price::from("101.0")),
469 Some(Quantity::from("10.0")),
470 Some(Quantity::from("20.0")),
471 UnixNanos::default(),
472 UnixNanos::default(),
473 )
474 .unwrap();
475
476 let quote2 = cache
478 .process(
479 instrument_id,
480 Some(Price::from("102.0")),
481 Some(Price::from("103.0")),
482 Some(Quantity::from("30.0")),
483 Some(Quantity::from("40.0")),
484 UnixNanos::default(),
485 UnixNanos::default(),
486 )
487 .unwrap();
488
489 assert_eq!(cache.get(&instrument_id), Some("e2));
490 assert_eq!(quote2.bid_price, Price::from("102.0"));
491 }
492
493 #[rstest]
494 fn test_process_multiple_instruments() {
495 let mut cache = QuoteCache::new();
496 let id1 = InstrumentId::from("BTCUSDT.BINANCE");
497 let id2 = InstrumentId::from("ETHUSDT.BINANCE");
498
499 let quote1 = cache
500 .process(
501 id1,
502 Some(Price::from("100.0")),
503 Some(Price::from("101.0")),
504 Some(Quantity::from("10.0")),
505 Some(Quantity::from("20.0")),
506 UnixNanos::default(),
507 UnixNanos::default(),
508 )
509 .unwrap();
510
511 let quote2 = cache
512 .process(
513 id2,
514 Some(Price::from("200.0")),
515 Some(Price::from("201.0")),
516 Some(Quantity::from("30.0")),
517 Some(Quantity::from("40.0")),
518 UnixNanos::default(),
519 UnixNanos::default(),
520 )
521 .unwrap();
522
523 assert_eq!(cache.len(), 2);
524 assert_eq!(cache.get(&id1), Some("e1));
525 assert_eq!(cache.get(&id2), Some("e2));
526 }
527
528 #[rstest]
529 fn test_process_clear_removes_cached_values() {
530 let mut cache = QuoteCache::new();
531 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
532
533 cache
535 .process(
536 instrument_id,
537 Some(Price::from("100.0")),
538 Some(Price::from("101.0")),
539 Some(Quantity::from("10.0")),
540 Some(Quantity::from("20.0")),
541 UnixNanos::default(),
542 UnixNanos::default(),
543 )
544 .unwrap();
545
546 assert_eq!(cache.len(), 1);
547
548 cache.clear();
550
551 let result = cache.process(
553 instrument_id,
554 Some(Price::from("100.5")),
555 None,
556 Some(Quantity::from("15.0")),
557 None,
558 UnixNanos::default(),
559 UnixNanos::default(),
560 );
561
562 assert!(result.is_err());
563 }
564}