use ahash::AHashMap;
use nautilus_core::UnixNanos;
use nautilus_model::{
data::quote::QuoteTick,
identifiers::InstrumentId,
types::{Price, Quantity},
};
#[derive(Debug, Clone)]
pub struct QuoteCache {
quotes: AHashMap<InstrumentId, QuoteTick>,
}
impl QuoteCache {
#[must_use]
pub fn new() -> Self {
Self {
quotes: AHashMap::new(),
}
}
#[must_use]
pub fn get(&self, instrument_id: &InstrumentId) -> Option<&QuoteTick> {
self.quotes.get(instrument_id)
}
pub fn insert(&mut self, instrument_id: InstrumentId, quote: QuoteTick) -> Option<QuoteTick> {
self.quotes.insert(instrument_id, quote)
}
pub fn remove(&mut self, instrument_id: &InstrumentId) -> Option<QuoteTick> {
self.quotes.remove(instrument_id)
}
#[must_use]
pub fn contains(&self, instrument_id: &InstrumentId) -> bool {
self.quotes.contains_key(instrument_id)
}
#[must_use]
pub fn len(&self) -> usize {
self.quotes.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.quotes.is_empty()
}
pub fn clear(&mut self) {
self.quotes.clear();
}
#[expect(clippy::too_many_arguments)]
pub fn process(
&mut self,
instrument_id: InstrumentId,
bid_price: Option<Price>,
ask_price: Option<Price>,
bid_size: Option<Quantity>,
ask_size: Option<Quantity>,
ts_event: UnixNanos,
ts_init: UnixNanos,
) -> anyhow::Result<QuoteTick> {
let cached = self.quotes.get(&instrument_id);
let bid_price = match (bid_price, cached) {
(Some(p), _) => p,
(None, Some(q)) => q.bid_price,
(None, None) => {
anyhow::bail!(
"Cannot process partial quote for {instrument_id}: missing bid_price and no cached value"
)
}
};
let ask_price = match (ask_price, cached) {
(Some(p), _) => p,
(None, Some(q)) => q.ask_price,
(None, None) => {
anyhow::bail!(
"Cannot process partial quote for {instrument_id}: missing ask_price and no cached value"
)
}
};
let bid_size = match (bid_size, cached) {
(Some(s), _) => s,
(None, Some(q)) => q.bid_size,
(None, None) => {
anyhow::bail!(
"Cannot process partial quote for {instrument_id}: missing bid_size and no cached value"
)
}
};
let ask_size = match (ask_size, cached) {
(Some(s), _) => s,
(None, Some(q)) => q.ask_size,
(None, None) => {
anyhow::bail!(
"Cannot process partial quote for {instrument_id}: missing ask_size and no cached value"
)
}
};
let quote = QuoteTick::new(
instrument_id,
bid_price,
ask_price,
bid_size,
ask_size,
ts_event,
ts_init,
);
self.quotes.insert(instrument_id, quote);
Ok(quote)
}
}
impl Default for QuoteCache {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use nautilus_core::UnixNanos;
use nautilus_model::types::{Price, Quantity};
use rstest::rstest;
use super::*;
fn make_quote(instrument_id: InstrumentId, _bid: f64, _ask: f64) -> QuoteTick {
QuoteTick::new(
instrument_id,
Price::from("100.0"),
Price::from("101.0"),
Quantity::from("10.0"),
Quantity::from("20.0"),
UnixNanos::default(),
UnixNanos::default(),
)
}
#[rstest]
fn test_new_cache_is_empty() {
let cache = QuoteCache::new();
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
}
#[rstest]
fn test_insert_and_get() {
let mut cache = QuoteCache::new();
let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
let quote = make_quote(instrument_id, 100.0, 101.0);
assert_eq!(cache.insert(instrument_id, quote), None);
assert_eq!(cache.len(), 1);
assert!(cache.contains(&instrument_id));
assert_eq!(cache.get(&instrument_id), Some("e));
}
#[rstest]
fn test_insert_returns_previous_value() {
let mut cache = QuoteCache::new();
let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
let quote1 = make_quote(instrument_id, 100.0, 101.0);
let quote2 = make_quote(instrument_id, 102.0, 103.0);
cache.insert(instrument_id, quote1);
let previous = cache.insert(instrument_id, quote2);
assert_eq!(previous, Some(quote1));
assert_eq!(cache.len(), 1);
assert_eq!(cache.get(&instrument_id), Some("e2));
}
#[rstest]
fn test_remove() {
let mut cache = QuoteCache::new();
let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
let quote = make_quote(instrument_id, 100.0, 101.0);
cache.insert(instrument_id, quote);
assert_eq!(cache.remove(&instrument_id), Some(quote));
assert!(cache.is_empty());
assert!(!cache.contains(&instrument_id));
assert_eq!(cache.get(&instrument_id), None);
}
#[rstest]
fn test_remove_nonexistent() {
let mut cache = QuoteCache::new();
let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
assert_eq!(cache.remove(&instrument_id), None);
}
#[rstest]
fn test_clear() {
let mut cache = QuoteCache::new();
let id1 = InstrumentId::from("BTCUSDT.BINANCE");
let id2 = InstrumentId::from("ETHUSDT.BINANCE");
cache.insert(id1, make_quote(id1, 100.0, 101.0));
cache.insert(id2, make_quote(id2, 200.0, 201.0));
assert_eq!(cache.len(), 2);
cache.clear();
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
assert!(!cache.contains(&id1));
assert!(!cache.contains(&id2));
}
#[rstest]
fn test_multiple_instruments() {
let mut cache = QuoteCache::new();
let id1 = InstrumentId::from("BTCUSDT.BINANCE");
let id2 = InstrumentId::from("ETHUSDT.BINANCE");
let id3 = InstrumentId::from("XRPUSDT.BINANCE");
let quote1 = make_quote(id1, 100.0, 101.0);
let quote2 = make_quote(id2, 200.0, 201.0);
let quote3 = make_quote(id3, 0.5, 0.51);
cache.insert(id1, quote1);
cache.insert(id2, quote2);
cache.insert(id3, quote3);
assert_eq!(cache.len(), 3);
assert_eq!(cache.get(&id1), Some("e1));
assert_eq!(cache.get(&id2), Some("e2));
assert_eq!(cache.get(&id3), Some("e3));
}
#[rstest]
fn test_default() {
let cache = QuoteCache::default();
assert!(cache.is_empty());
}
#[rstest]
fn test_clone() {
let mut cache = QuoteCache::new();
let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
let quote = make_quote(instrument_id, 100.0, 101.0);
cache.insert(instrument_id, quote);
let cloned = cache.clone();
assert_eq!(cloned.len(), 1);
assert_eq!(cloned.get(&instrument_id), Some("e));
}
#[rstest]
fn test_process_complete_quote() {
let mut cache = QuoteCache::new();
let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
let result = cache.process(
instrument_id,
Some(Price::from("100.5")),
Some(Price::from("101.0")),
Some(Quantity::from("10.0")),
Some(Quantity::from("20.0")),
UnixNanos::default(),
UnixNanos::default(),
);
assert!(result.is_ok());
let quote = result.unwrap();
assert_eq!(quote.instrument_id, instrument_id);
assert_eq!(quote.bid_price, Price::from("100.5"));
assert_eq!(quote.ask_price, Price::from("101.0"));
assert_eq!(quote.bid_size, Quantity::from("10.0"));
assert_eq!(quote.ask_size, Quantity::from("20.0"));
assert_eq!(cache.len(), 1);
assert_eq!(cache.get(&instrument_id), Some("e));
}
#[rstest]
fn test_process_partial_quote_without_cache() {
let mut cache = QuoteCache::new();
let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
let result = cache.process(
instrument_id,
None,
Some(Price::from("101.0")),
Some(Quantity::from("10.0")),
Some(Quantity::from("20.0")),
UnixNanos::default(),
UnixNanos::default(),
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("missing bid_price")
);
}
#[rstest]
fn test_process_partial_quote_with_cache() {
let mut cache = QuoteCache::new();
let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
let first_quote = cache
.process(
instrument_id,
Some(Price::from("100.0")),
Some(Price::from("101.0")),
Some(Quantity::from("10.0")),
Some(Quantity::from("20.0")),
UnixNanos::default(),
UnixNanos::default(),
)
.unwrap();
let result = cache.process(
instrument_id,
Some(Price::from("100.5")),
None, Some(Quantity::from("15.0")),
None, UnixNanos::default(),
UnixNanos::default(),
);
assert!(result.is_ok());
let quote = result.unwrap();
assert_eq!(quote.bid_price, Price::from("100.5"));
assert_eq!(quote.bid_size, Quantity::from("15.0"));
assert_eq!(quote.ask_price, first_quote.ask_price);
assert_eq!(quote.ask_size, first_quote.ask_size);
assert_eq!(cache.get(&instrument_id), Some("e));
}
#[rstest]
fn test_process_updates_cache() {
let mut cache = QuoteCache::new();
let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
cache
.process(
instrument_id,
Some(Price::from("100.0")),
Some(Price::from("101.0")),
Some(Quantity::from("10.0")),
Some(Quantity::from("20.0")),
UnixNanos::default(),
UnixNanos::default(),
)
.unwrap();
let quote2 = cache
.process(
instrument_id,
Some(Price::from("102.0")),
Some(Price::from("103.0")),
Some(Quantity::from("30.0")),
Some(Quantity::from("40.0")),
UnixNanos::default(),
UnixNanos::default(),
)
.unwrap();
assert_eq!(cache.get(&instrument_id), Some("e2));
assert_eq!(quote2.bid_price, Price::from("102.0"));
}
#[rstest]
fn test_process_multiple_instruments() {
let mut cache = QuoteCache::new();
let id1 = InstrumentId::from("BTCUSDT.BINANCE");
let id2 = InstrumentId::from("ETHUSDT.BINANCE");
let quote1 = cache
.process(
id1,
Some(Price::from("100.0")),
Some(Price::from("101.0")),
Some(Quantity::from("10.0")),
Some(Quantity::from("20.0")),
UnixNanos::default(),
UnixNanos::default(),
)
.unwrap();
let quote2 = cache
.process(
id2,
Some(Price::from("200.0")),
Some(Price::from("201.0")),
Some(Quantity::from("30.0")),
Some(Quantity::from("40.0")),
UnixNanos::default(),
UnixNanos::default(),
)
.unwrap();
assert_eq!(cache.len(), 2);
assert_eq!(cache.get(&id1), Some("e1));
assert_eq!(cache.get(&id2), Some("e2));
}
#[rstest]
fn test_process_clear_removes_cached_values() {
let mut cache = QuoteCache::new();
let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
cache
.process(
instrument_id,
Some(Price::from("100.0")),
Some(Price::from("101.0")),
Some(Quantity::from("10.0")),
Some(Quantity::from("20.0")),
UnixNanos::default(),
UnixNanos::default(),
)
.unwrap();
assert_eq!(cache.len(), 1);
cache.clear();
let result = cache.process(
instrument_id,
Some(Price::from("100.5")),
None,
Some(Quantity::from("15.0")),
None,
UnixNanos::default(),
UnixNanos::default(),
);
assert!(result.is_err());
}
}