Skip to main content

nautilus_common/cache/
quote.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Generic quote cache for maintaining the last known quote per instrument.
17//!
18//! This cache is commonly used by WebSocket adapters to handle partial quote updates
19//! where the exchange may send incomplete bid or ask information. By caching the last
20//! complete quote, adapters can merge partial updates with cached values to reconstruct
21//! a complete `QuoteTick`.
22
23use ahash::AHashMap;
24use nautilus_core::UnixNanos;
25use nautilus_model::{
26    data::quote::QuoteTick,
27    identifiers::InstrumentId,
28    types::{Price, Quantity},
29};
30
31/// A cache for storing the last known quote per instrument.
32///
33/// This is particularly useful for handling partial quote updates from exchange WebSocket feeds,
34/// where updates may only include one side of the market (bid or ask). The cache maintains
35/// the most recent complete quote for each instrument, allowing adapters to fill in missing
36/// information when processing partial updates.
37///
38/// # Thread Safety
39///
40/// This cache is not thread-safe. If shared across threads, wrap it in an appropriate
41/// synchronization primitive such as `Arc<RwLock<QuoteCache>>` or `Arc<Mutex<QuoteCache>>`.
42#[derive(Debug, Clone)]
43pub struct QuoteCache {
44    quotes: AHashMap<InstrumentId, QuoteTick>,
45}
46
47impl QuoteCache {
48    /// Creates a new empty [`QuoteCache`].
49    #[must_use]
50    pub fn new() -> Self {
51        Self {
52            quotes: AHashMap::new(),
53        }
54    }
55
56    /// Returns the cached quote for the given instrument, if available.
57    #[must_use]
58    pub fn get(&self, instrument_id: &InstrumentId) -> Option<&QuoteTick> {
59        self.quotes.get(instrument_id)
60    }
61
62    /// Inserts or updates a quote in the cache for the given instrument.
63    ///
64    /// Returns the previously cached quote if one existed.
65    pub fn insert(&mut self, instrument_id: InstrumentId, quote: QuoteTick) -> Option<QuoteTick> {
66        self.quotes.insert(instrument_id, quote)
67    }
68
69    /// Removes the cached quote for the given instrument.
70    ///
71    /// Returns the removed quote if one existed.
72    pub fn remove(&mut self, instrument_id: &InstrumentId) -> Option<QuoteTick> {
73        self.quotes.remove(instrument_id)
74    }
75
76    /// Returns `true` if the cache contains a quote for the given instrument.
77    #[must_use]
78    pub fn contains(&self, instrument_id: &InstrumentId) -> bool {
79        self.quotes.contains_key(instrument_id)
80    }
81
82    /// Returns the number of cached quotes.
83    #[must_use]
84    pub fn len(&self) -> usize {
85        self.quotes.len()
86    }
87
88    /// Returns `true` if the cache is empty.
89    #[must_use]
90    pub fn is_empty(&self) -> bool {
91        self.quotes.is_empty()
92    }
93
94    /// Clears all cached quotes.
95    ///
96    /// This is typically called after a reconnection to ensure stale quotes
97    /// from before the disconnect are not used.
98    pub fn clear(&mut self) {
99        self.quotes.clear();
100    }
101
102    /// Processes a partial quote update, merging with cached values when needed.
103    ///
104    /// This method handles partial quote updates where some fields may be missing.
105    /// If any field is `None`, it will use the corresponding field from the cached quote.
106    /// If there is no cached quote and any field is missing, an error is returned.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error if:
111    /// - Any required field is `None` and there is no cached quote.
112    /// - The first quote received is incomplete (no cached values to merge with).
113    #[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        // Resolve each field: use provided value or fall back to cache
127        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(&quote));
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(&quote2));
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(&quote1));
284        assert_eq!(cache.get(&id2), Some(&quote2));
285        assert_eq!(cache.get(&id3), Some(&quote3));
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(&quote));
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        // Should be cached
331        assert_eq!(cache.len(), 1);
332        assert_eq!(cache.get(&instrument_id), Some(&quote));
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        // First, process a complete quote
383        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        // Now process partial update with only bid side
396        let result = cache.process(
397            instrument_id,
398            Some(Price::from("100.5")),
399            None, // Use cached ask_price
400            Some(Quantity::from("15.0")),
401            None, // Use cached ask_size
402            UnixNanos::default(),
403            UnixNanos::default(),
404        );
405
406        assert!(result.is_ok());
407        let quote = result.unwrap();
408
409        // Bid side should be updated
410        assert_eq!(quote.bid_price, Price::from("100.5"));
411        assert_eq!(quote.bid_size, Quantity::from("15.0"));
412
413        // Ask side should be from cache
414        assert_eq!(quote.ask_price, first_quote.ask_price);
415        assert_eq!(quote.ask_size, first_quote.ask_size);
416
417        // Cache should be updated with new quote
418        assert_eq!(cache.get(&instrument_id), Some(&quote));
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(&quote));
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        // First quote
464        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        // Second complete quote should replace cached values
477        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(&quote2));
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(&quote1));
525        assert_eq!(cache.get(&id2), Some(&quote2));
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        // Add a quote
534        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        // Clear cache
549        cache.clear();
550
551        // Partial update should now fail (no cached values)
552        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}