nautilus_common/cache/
quote.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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    #[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        // Resolve each field: use provided value or fall back to cache
127        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(&quote));
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(&quote2));
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(&quote1));
300        assert_eq!(cache.get(&id2), Some(&quote2));
301        assert_eq!(cache.get(&id3), Some(&quote3));
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(&quote));
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        // Should be cached
347        assert_eq!(cache.len(), 1);
348        assert_eq!(cache.get(&instrument_id), Some(&quote));
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        // Missing bid_price on first update should fail
357        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        // First, process a complete quote
382        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        // Now process partial update with only bid side
395        let result = cache.process(
396            instrument_id,
397            Some(Price::from("100.5")),
398            None, // Use cached ask_price
399            Some(Quantity::from("15.0")),
400            None, // Use cached ask_size
401            UnixNanos::default(),
402            UnixNanos::default(),
403        );
404
405        assert!(result.is_ok());
406        let quote = result.unwrap();
407
408        // Bid side should be updated
409        assert_eq!(quote.bid_price, Price::from("100.5"));
410        assert_eq!(quote.bid_size, Quantity::from("15.0"));
411
412        // Ask side should be from cache
413        assert_eq!(quote.ask_price, first_quote.ask_price);
414        assert_eq!(quote.ask_size, first_quote.ask_size);
415
416        // Cache should be updated with new quote
417        assert_eq!(cache.get(&instrument_id), Some(&quote));
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        // First quote
426        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        // Second complete quote should replace cached values
439        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(&quote2));
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(&quote1));
487        assert_eq!(cache.get(&id2), Some(&quote2));
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        // Add a quote
496        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        // Clear cache
511        cache.clear();
512
513        // Partial update should now fail (no cached values)
514        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}