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 #[allow(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 bid_price = match (bid_price, cached) {
128 (Some(p), _) => p,
129 (None, Some(q)) => q.bid_price,
130 (None, None) => {
131 anyhow::bail!(
132 "Cannot process partial quote for {instrument_id}: missing bid_price and no cached value"
133 )
134 }
135 };
136
137 let ask_price = match (ask_price, cached) {
138 (Some(p), _) => p,
139 (None, Some(q)) => q.ask_price,
140 (None, None) => {
141 anyhow::bail!(
142 "Cannot process partial quote for {instrument_id}: missing ask_price and no cached value"
143 )
144 }
145 };
146
147 let bid_size = match (bid_size, cached) {
148 (Some(s), _) => s,
149 (None, Some(q)) => q.bid_size,
150 (None, None) => {
151 anyhow::bail!(
152 "Cannot process partial quote for {instrument_id}: missing bid_size and no cached value"
153 )
154 }
155 };
156
157 let ask_size = match (ask_size, cached) {
158 (Some(s), _) => s,
159 (None, Some(q)) => q.ask_size,
160 (None, None) => {
161 anyhow::bail!(
162 "Cannot process partial quote for {instrument_id}: missing ask_size and no cached value"
163 )
164 }
165 };
166
167 let quote = QuoteTick::new(
168 instrument_id,
169 bid_price,
170 ask_price,
171 bid_size,
172 ask_size,
173 ts_event,
174 ts_init,
175 );
176
177 self.quotes.insert(instrument_id, quote);
178
179 Ok(quote)
180 }
181}
182
183impl Default for QuoteCache {
184 fn default() -> Self {
185 Self::new()
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use nautilus_core::UnixNanos;
192 use nautilus_model::types::{Price, Quantity};
193 use rstest::rstest;
194
195 use super::*;
196
197 fn make_quote(instrument_id: InstrumentId, _bid: f64, _ask: f64) -> QuoteTick {
198 QuoteTick::new(
199 instrument_id,
200 Price::from("100.0"),
201 Price::from("101.0"),
202 Quantity::from("10.0"),
203 Quantity::from("20.0"),
204 UnixNanos::default(),
205 UnixNanos::default(),
206 )
207 }
208
209 #[rstest]
210 fn test_new_cache_is_empty() {
211 let cache = QuoteCache::new();
212 assert!(cache.is_empty());
213 assert_eq!(cache.len(), 0);
214 }
215
216 #[rstest]
217 fn test_insert_and_get() {
218 let mut cache = QuoteCache::new();
219 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
220 let quote = make_quote(instrument_id, 100.0, 101.0);
221
222 assert_eq!(cache.insert(instrument_id, quote), None);
223 assert_eq!(cache.len(), 1);
224 assert!(cache.contains(&instrument_id));
225 assert_eq!(cache.get(&instrument_id), Some("e));
226 }
227
228 #[rstest]
229 fn test_insert_returns_previous_value() {
230 let mut cache = QuoteCache::new();
231 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
232 let quote1 = make_quote(instrument_id, 100.0, 101.0);
233 let quote2 = make_quote(instrument_id, 102.0, 103.0);
234
235 cache.insert(instrument_id, quote1);
236 let previous = cache.insert(instrument_id, quote2);
237
238 assert_eq!(previous, Some(quote1));
239 assert_eq!(cache.len(), 1);
240 assert_eq!(cache.get(&instrument_id), Some("e2));
241 }
242
243 #[rstest]
244 fn test_remove() {
245 let mut cache = QuoteCache::new();
246 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
247 let quote = make_quote(instrument_id, 100.0, 101.0);
248
249 cache.insert(instrument_id, quote);
250 assert_eq!(cache.remove(&instrument_id), Some(quote));
251 assert!(cache.is_empty());
252 assert!(!cache.contains(&instrument_id));
253 assert_eq!(cache.get(&instrument_id), None);
254 }
255
256 #[rstest]
257 fn test_remove_nonexistent() {
258 let mut cache = QuoteCache::new();
259 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
260
261 assert_eq!(cache.remove(&instrument_id), None);
262 }
263
264 #[rstest]
265 fn test_clear() {
266 let mut cache = QuoteCache::new();
267 let id1 = InstrumentId::from("BTCUSDT.BINANCE");
268 let id2 = InstrumentId::from("ETHUSDT.BINANCE");
269
270 cache.insert(id1, make_quote(id1, 100.0, 101.0));
271 cache.insert(id2, make_quote(id2, 200.0, 201.0));
272
273 assert_eq!(cache.len(), 2);
274
275 cache.clear();
276
277 assert!(cache.is_empty());
278 assert_eq!(cache.len(), 0);
279 assert!(!cache.contains(&id1));
280 assert!(!cache.contains(&id2));
281 }
282
283 #[rstest]
284 fn test_multiple_instruments() {
285 let mut cache = QuoteCache::new();
286 let id1 = InstrumentId::from("BTCUSDT.BINANCE");
287 let id2 = InstrumentId::from("ETHUSDT.BINANCE");
288 let id3 = InstrumentId::from("XRPUSDT.BINANCE");
289
290 let quote1 = make_quote(id1, 100.0, 101.0);
291 let quote2 = make_quote(id2, 200.0, 201.0);
292 let quote3 = make_quote(id3, 0.5, 0.51);
293
294 cache.insert(id1, quote1);
295 cache.insert(id2, quote2);
296 cache.insert(id3, quote3);
297
298 assert_eq!(cache.len(), 3);
299 assert_eq!(cache.get(&id1), Some("e1));
300 assert_eq!(cache.get(&id2), Some("e2));
301 assert_eq!(cache.get(&id3), Some("e3));
302 }
303
304 #[rstest]
305 fn test_default() {
306 let cache = QuoteCache::default();
307 assert!(cache.is_empty());
308 }
309
310 #[rstest]
311 fn test_clone() {
312 let mut cache = QuoteCache::new();
313 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
314 let quote = make_quote(instrument_id, 100.0, 101.0);
315
316 cache.insert(instrument_id, quote);
317
318 let cloned = cache.clone();
319 assert_eq!(cloned.len(), 1);
320 assert_eq!(cloned.get(&instrument_id), Some("e));
321 }
322
323 #[rstest]
324 fn test_process_complete_quote() {
325 let mut cache = QuoteCache::new();
326 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
327
328 let result = cache.process(
329 instrument_id,
330 Some(Price::from("100.5")),
331 Some(Price::from("101.0")),
332 Some(Quantity::from("10.0")),
333 Some(Quantity::from("20.0")),
334 UnixNanos::default(),
335 UnixNanos::default(),
336 );
337
338 assert!(result.is_ok());
339 let quote = result.unwrap();
340 assert_eq!(quote.instrument_id, instrument_id);
341 assert_eq!(quote.bid_price, Price::from("100.5"));
342 assert_eq!(quote.ask_price, Price::from("101.0"));
343 assert_eq!(quote.bid_size, Quantity::from("10.0"));
344 assert_eq!(quote.ask_size, Quantity::from("20.0"));
345
346 assert_eq!(cache.len(), 1);
348 assert_eq!(cache.get(&instrument_id), Some("e));
349 }
350
351 #[rstest]
352 fn test_process_partial_quote_without_cache() {
353 let mut cache = QuoteCache::new();
354 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
355
356 let result = cache.process(
358 instrument_id,
359 None,
360 Some(Price::from("101.0")),
361 Some(Quantity::from("10.0")),
362 Some(Quantity::from("20.0")),
363 UnixNanos::default(),
364 UnixNanos::default(),
365 );
366
367 assert!(result.is_err());
368 assert!(
369 result
370 .unwrap_err()
371 .to_string()
372 .contains("missing bid_price")
373 );
374 }
375
376 #[rstest]
377 fn test_process_partial_quote_with_cache() {
378 let mut cache = QuoteCache::new();
379 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
380
381 let first_quote = cache
383 .process(
384 instrument_id,
385 Some(Price::from("100.0")),
386 Some(Price::from("101.0")),
387 Some(Quantity::from("10.0")),
388 Some(Quantity::from("20.0")),
389 UnixNanos::default(),
390 UnixNanos::default(),
391 )
392 .unwrap();
393
394 let result = cache.process(
396 instrument_id,
397 Some(Price::from("100.5")),
398 None, Some(Quantity::from("15.0")),
400 None, UnixNanos::default(),
402 UnixNanos::default(),
403 );
404
405 assert!(result.is_ok());
406 let quote = result.unwrap();
407
408 assert_eq!(quote.bid_price, Price::from("100.5"));
410 assert_eq!(quote.bid_size, Quantity::from("15.0"));
411
412 assert_eq!(quote.ask_price, first_quote.ask_price);
414 assert_eq!(quote.ask_size, first_quote.ask_size);
415
416 assert_eq!(cache.get(&instrument_id), Some("e));
418 }
419
420 #[rstest]
421 fn test_process_updates_cache() {
422 let mut cache = QuoteCache::new();
423 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
424
425 cache
427 .process(
428 instrument_id,
429 Some(Price::from("100.0")),
430 Some(Price::from("101.0")),
431 Some(Quantity::from("10.0")),
432 Some(Quantity::from("20.0")),
433 UnixNanos::default(),
434 UnixNanos::default(),
435 )
436 .unwrap();
437
438 let quote2 = cache
440 .process(
441 instrument_id,
442 Some(Price::from("102.0")),
443 Some(Price::from("103.0")),
444 Some(Quantity::from("30.0")),
445 Some(Quantity::from("40.0")),
446 UnixNanos::default(),
447 UnixNanos::default(),
448 )
449 .unwrap();
450
451 assert_eq!(cache.get(&instrument_id), Some("e2));
452 assert_eq!(quote2.bid_price, Price::from("102.0"));
453 }
454
455 #[rstest]
456 fn test_process_multiple_instruments() {
457 let mut cache = QuoteCache::new();
458 let id1 = InstrumentId::from("BTCUSDT.BINANCE");
459 let id2 = InstrumentId::from("ETHUSDT.BINANCE");
460
461 let quote1 = cache
462 .process(
463 id1,
464 Some(Price::from("100.0")),
465 Some(Price::from("101.0")),
466 Some(Quantity::from("10.0")),
467 Some(Quantity::from("20.0")),
468 UnixNanos::default(),
469 UnixNanos::default(),
470 )
471 .unwrap();
472
473 let quote2 = cache
474 .process(
475 id2,
476 Some(Price::from("200.0")),
477 Some(Price::from("201.0")),
478 Some(Quantity::from("30.0")),
479 Some(Quantity::from("40.0")),
480 UnixNanos::default(),
481 UnixNanos::default(),
482 )
483 .unwrap();
484
485 assert_eq!(cache.len(), 2);
486 assert_eq!(cache.get(&id1), Some("e1));
487 assert_eq!(cache.get(&id2), Some("e2));
488 }
489
490 #[rstest]
491 fn test_process_clear_removes_cached_values() {
492 let mut cache = QuoteCache::new();
493 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
494
495 cache
497 .process(
498 instrument_id,
499 Some(Price::from("100.0")),
500 Some(Price::from("101.0")),
501 Some(Quantity::from("10.0")),
502 Some(Quantity::from("20.0")),
503 UnixNanos::default(),
504 UnixNanos::default(),
505 )
506 .unwrap();
507
508 assert_eq!(cache.len(), 1);
509
510 cache.clear();
512
513 let result = cache.process(
515 instrument_id,
516 Some(Price::from("100.5")),
517 None,
518 Some(Quantity::from("15.0")),
519 None,
520 UnixNanos::default(),
521 UnixNanos::default(),
522 );
523
524 assert!(result.is_err());
525 }
526}