Skip to main content

nautilus_model/data/
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//! A `QuoteTick` data type representing a top-of-book state.
17
18use std::{cmp, collections::HashMap, fmt::Display, hash::Hash};
19
20use derive_builder::Builder;
21use indexmap::IndexMap;
22use nautilus_core::{
23    UnixNanos,
24    correctness::{FAILED, check_equal_u8},
25    serialization::Serializable,
26};
27use serde::{Deserialize, Serialize};
28
29use super::{ARROW_TIMESTAMP_NANOSECOND, HasTsInit};
30use crate::{
31    enums::PriceType,
32    identifiers::InstrumentId,
33    types::{
34        Price, Quantity,
35        fixed::{FIXED_DECIMAL, FIXED_PRECISION},
36    },
37};
38
39/// Represents a quote tick in a market.
40#[repr(C)]
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Builder)]
42#[serde(tag = "type")]
43#[cfg_attr(
44    feature = "python",
45    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
46)]
47#[cfg_attr(
48    feature = "python",
49    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
50)]
51pub struct QuoteTick {
52    /// The quotes instrument ID.
53    pub instrument_id: InstrumentId,
54    /// The top-of-book bid price.
55    pub bid_price: Price,
56    /// The top-of-book ask price.
57    pub ask_price: Price,
58    /// The top-of-book bid size.
59    pub bid_size: Quantity,
60    /// The top-of-book ask size.
61    pub ask_size: Quantity,
62    /// UNIX timestamp (nanoseconds) when the quote event occurred.
63    pub ts_event: UnixNanos,
64    /// UNIX timestamp (nanoseconds) when the instance was created.
65    pub ts_init: UnixNanos,
66}
67
68impl QuoteTick {
69    /// Creates a new [`QuoteTick`] instance with correctness checking.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if:
74    /// - `bid_price.precision` does not equal `ask_price.precision`.
75    /// - `bid_size.precision` does not equal `ask_size.precision`.
76    ///
77    /// # Notes
78    ///
79    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
80    pub fn new_checked(
81        instrument_id: InstrumentId,
82        bid_price: Price,
83        ask_price: Price,
84        bid_size: Quantity,
85        ask_size: Quantity,
86        ts_event: UnixNanos,
87        ts_init: UnixNanos,
88    ) -> anyhow::Result<Self> {
89        check_equal_u8(
90            bid_price.precision,
91            ask_price.precision,
92            "bid_price.precision",
93            "ask_price.precision",
94        )?;
95        check_equal_u8(
96            bid_size.precision,
97            ask_size.precision,
98            "bid_size.precision",
99            "ask_size.precision",
100        )?;
101        Ok(Self {
102            instrument_id,
103            bid_price,
104            ask_price,
105            bid_size,
106            ask_size,
107            ts_event,
108            ts_init,
109        })
110    }
111
112    /// Creates a new [`QuoteTick`] instance.
113    ///
114    /// # Panics
115    ///
116    /// Panics if:
117    /// - `bid_price.precision` does not equal `ask_price.precision`.
118    /// - `bid_size.precision` does not equal `ask_size.precision`.
119    #[must_use]
120    pub fn new(
121        instrument_id: InstrumentId,
122        bid_price: Price,
123        ask_price: Price,
124        bid_size: Quantity,
125        ask_size: Quantity,
126        ts_event: UnixNanos,
127        ts_init: UnixNanos,
128    ) -> Self {
129        Self::new_checked(
130            instrument_id,
131            bid_price,
132            ask_price,
133            bid_size,
134            ask_size,
135            ts_event,
136            ts_init,
137        )
138        .expect(FAILED)
139    }
140
141    /// Returns whether both quotes have equal bid/ask prices and sizes.
142    ///
143    /// Ignores instrument IDs and timestamps.
144    #[must_use]
145    #[inline]
146    pub fn has_same_bid_ask(&self, other: &Self) -> bool {
147        (self.bid_price, self.ask_price, self.bid_size, self.ask_size)
148            == (
149                other.bid_price,
150                other.ask_price,
151                other.bid_size,
152                other.ask_size,
153            )
154    }
155
156    /// Returns the metadata for the type, for use with serialization formats.
157    #[must_use]
158    pub fn get_metadata(
159        instrument_id: &InstrumentId,
160        price_precision: u8,
161        size_precision: u8,
162    ) -> HashMap<String, String> {
163        let mut metadata = HashMap::new();
164        metadata.insert("instrument_id".to_string(), instrument_id.to_string());
165        metadata.insert("price_precision".to_string(), price_precision.to_string());
166        metadata.insert("size_precision".to_string(), size_precision.to_string());
167        metadata
168    }
169
170    /// Returns the field map for the type, for use with Arrow schemas.
171    #[must_use]
172    pub fn get_fields() -> IndexMap<String, String> {
173        let mut metadata = IndexMap::new();
174        metadata.insert("bid_price".to_string(), FIXED_DECIMAL.to_string());
175        metadata.insert("ask_price".to_string(), FIXED_DECIMAL.to_string());
176        metadata.insert("bid_size".to_string(), FIXED_DECIMAL.to_string());
177        metadata.insert("ask_size".to_string(), FIXED_DECIMAL.to_string());
178        metadata.insert(
179            "ts_event".to_string(),
180            ARROW_TIMESTAMP_NANOSECOND.to_string(),
181        );
182        metadata.insert(
183            "ts_init".to_string(),
184            ARROW_TIMESTAMP_NANOSECOND.to_string(),
185        );
186        metadata
187    }
188
189    /// Returns the [`Price`] for this quote depending on the given `price_type`.
190    ///
191    /// # Errors
192    ///
193    /// Returns an error if `price_type` is not `Bid`, `Ask`, or `Mid` (a quote has no `Last` price).
194    pub fn extract_price(&self, price_type: PriceType) -> anyhow::Result<Price> {
195        let price = match price_type {
196            PriceType::Bid => self.bid_price,
197            PriceType::Ask => self.ask_price,
198            PriceType::Mid => {
199                // Calculate mid avoiding overflow
200                let a = self.bid_price.raw();
201                let b = self.ask_price.raw();
202                let mid_raw = a.midpoint(b);
203                Price::from_raw(
204                    mid_raw,
205                    cmp::min(self.bid_price.precision + 1, FIXED_PRECISION),
206                )
207            }
208            _ => anyhow::bail!("Cannot extract price from quote with price type {price_type}"),
209        };
210        Ok(price)
211    }
212
213    /// Returns the [`Quantity`] for this quote depending on the given `price_type`.
214    ///
215    /// # Errors
216    ///
217    /// Returns an error if `price_type` is not `Bid`, `Ask`, or `Mid` (a quote has no `Last` size).
218    pub fn extract_size(&self, price_type: PriceType) -> anyhow::Result<Quantity> {
219        let size = match price_type {
220            PriceType::Bid => self.bid_size,
221            PriceType::Ask => self.ask_size,
222            PriceType::Mid => {
223                // Calculate mid avoiding overflow
224                let a = self.bid_size.raw();
225                let b = self.ask_size.raw();
226                let mid_raw = a.midpoint(b);
227                Quantity::from_raw(
228                    mid_raw,
229                    cmp::min(self.bid_size.precision + 1, FIXED_PRECISION),
230                )
231            }
232            _ => anyhow::bail!("Cannot extract size from quote with price type {price_type}"),
233        };
234        Ok(size)
235    }
236}
237
238impl Display for QuoteTick {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        write!(
241            f,
242            "{},{},{},{},{},{}",
243            self.instrument_id,
244            self.bid_price,
245            self.ask_price,
246            self.bid_size,
247            self.ask_size,
248            self.ts_event,
249        )
250    }
251}
252
253impl Serializable for QuoteTick {}
254
255impl HasTsInit for QuoteTick {
256    fn ts_init(&self) -> UnixNanos {
257        self.ts_init
258    }
259}
260
261#[cfg(test)]
262mod tests {
263
264    use nautilus_core::UnixNanos;
265    use rstest::rstest;
266
267    use super::QuoteTickBuilder;
268    use crate::{
269        data::{ARROW_TIMESTAMP_NANOSECOND, HasTsInit, QuoteTick, stubs::quote_ethusdt_binance},
270        enums::PriceType,
271        identifiers::InstrumentId,
272        types::{
273            Price, Quantity,
274            fixed::{FIXED_DECIMAL, FIXED_PRECISION},
275            price::PriceRaw,
276            quantity::QuantityRaw,
277        },
278    };
279
280    fn create_test_quote() -> QuoteTick {
281        QuoteTick::new(
282            InstrumentId::from("EURUSD.SIM"),
283            Price::from("1.0500"),
284            Price::from("1.0505"),
285            Quantity::from("100000"),
286            Quantity::from("75000"),
287            UnixNanos::from(1_000_000_000),
288            UnixNanos::from(2_000_000_000),
289        )
290    }
291
292    #[rstest]
293    fn test_quote_tick_new() {
294        let quote = create_test_quote();
295
296        assert_eq!(quote.instrument_id, InstrumentId::from("EURUSD.SIM"));
297        assert_eq!(quote.bid_price, Price::from("1.0500"));
298        assert_eq!(quote.ask_price, Price::from("1.0505"));
299        assert_eq!(quote.bid_size, Quantity::from("100000"));
300        assert_eq!(quote.ask_size, Quantity::from("75000"));
301        assert_eq!(quote.ts_event, UnixNanos::from(1_000_000_000));
302        assert_eq!(quote.ts_init, UnixNanos::from(2_000_000_000));
303    }
304
305    #[rstest]
306    #[case::identical(create_test_quote(), true, true)]
307    #[case::bid_price(QuoteTick { bid_price: Price::from("1.0501"), ..create_test_quote() }, false, false)]
308    #[case::ask_price(QuoteTick { ask_price: Price::from("1.0506"), ..create_test_quote() }, false, false)]
309    #[case::bid_size(QuoteTick { bid_size: Quantity::from("100001"), ..create_test_quote() }, false, false)]
310    #[case::ask_size(QuoteTick { ask_size: Quantity::from("75001"), ..create_test_quote() }, false, false)]
311    #[case::instrument(QuoteTick { instrument_id: InstrumentId::from("GBPUSD.SIM"), ..create_test_quote() }, true, false)]
312    #[case::event_time(QuoteTick { ts_event: UnixNanos::from(3_000_000_000), ..create_test_quote() }, true, false)]
313    #[case::init_time(QuoteTick { ts_init: UnixNanos::from(4_000_000_000), ..create_test_quote() }, true, false)]
314    fn test_has_same_bid_ask(
315        #[case] other: QuoteTick,
316        #[case] equal_bid_ask: bool,
317        #[case] equal_quote: bool,
318    ) {
319        let quote = create_test_quote();
320
321        assert_eq!(quote.has_same_bid_ask(&other), equal_bid_ask);
322        assert_eq!(other.has_same_bid_ask(&quote), equal_bid_ask);
323        assert_eq!(quote == other, equal_quote);
324    }
325
326    #[rstest]
327    fn test_quote_tick_new_checked_valid() {
328        let result = QuoteTick::new_checked(
329            InstrumentId::from("GBPUSD.SIM"),
330            Price::from("1.2500"),
331            Price::from("1.2505"),
332            Quantity::from("50000"),
333            Quantity::from("60000"),
334            UnixNanos::from(500_000_000),
335            UnixNanos::from(1_500_000_000),
336        );
337
338        assert!(result.is_ok());
339        let quote = result.unwrap();
340        assert_eq!(quote.instrument_id, InstrumentId::from("GBPUSD.SIM"));
341        assert_eq!(quote.bid_price, Price::from("1.2500"));
342        assert_eq!(quote.ask_price, Price::from("1.2505"));
343    }
344
345    #[rstest]
346    #[should_panic(
347        expected = "'bid_price.precision' u8 of 4 was not equal to 'ask_price.precision' u8 of 5"
348    )]
349    fn test_quote_tick_new_with_precision_mismatch_panics() {
350        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
351        let bid_price = Price::from("10000.0000"); // Precision: 4
352        let ask_price = Price::from("10000.00100"); // Precision: 5 (mismatch)
353        let bid_size = Quantity::from("1.000000");
354        let ask_size = Quantity::from("1.000000");
355        let ts_event = UnixNanos::from(0);
356        let ts_init = UnixNanos::from(1);
357
358        let _ = QuoteTick::new(
359            instrument_id,
360            bid_price,
361            ask_price,
362            bid_size,
363            ask_size,
364            ts_event,
365            ts_init,
366        );
367    }
368
369    #[rstest]
370    fn test_quote_tick_new_checked_with_precision_mismatch_error() {
371        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
372        let bid_price = Price::from("10000.0000");
373        let ask_price = Price::from("10000.0010");
374        let bid_size = Quantity::from("10.000000"); // Precision: 6
375        let ask_size = Quantity::from("10.0000000"); // Precision: 7 (mismatch)
376        let ts_event = UnixNanos::from(0);
377        let ts_init = UnixNanos::from(1);
378
379        let result = QuoteTick::new_checked(
380            instrument_id,
381            bid_price,
382            ask_price,
383            bid_size,
384            ask_size,
385            ts_event,
386            ts_init,
387        );
388
389        assert!(result.is_err());
390        assert!(result.unwrap_err().to_string().contains(
391            "'bid_size.precision' u8 of 6 was not equal to 'ask_size.precision' u8 of 7"
392        ));
393    }
394
395    #[rstest]
396    fn test_quote_tick_builder() {
397        let quote = QuoteTickBuilder::default()
398            .instrument_id(InstrumentId::from("BTCUSD.CRYPTO"))
399            .bid_price(Price::from("50000.00"))
400            .ask_price(Price::from("50001.00"))
401            .bid_size(Quantity::from("0.50"))
402            .ask_size(Quantity::from("0.75"))
403            .ts_event(UnixNanos::from(3_000_000_000))
404            .ts_init(UnixNanos::from(4_000_000_000))
405            .build()
406            .unwrap();
407
408        assert_eq!(quote.instrument_id, InstrumentId::from("BTCUSD.CRYPTO"));
409        assert_eq!(quote.bid_price, Price::from("50000.00"));
410        assert_eq!(quote.ask_price, Price::from("50001.00"));
411        assert_eq!(quote.bid_size, Quantity::from("0.50"));
412        assert_eq!(quote.ask_size, Quantity::from("0.75"));
413        assert_eq!(quote.ts_event, UnixNanos::from(3_000_000_000));
414        assert_eq!(quote.ts_init, UnixNanos::from(4_000_000_000));
415    }
416
417    #[rstest]
418    fn test_get_metadata() {
419        let instrument_id = InstrumentId::from("EURUSD.SIM");
420        let metadata = QuoteTick::get_metadata(&instrument_id, 5, 8);
421
422        assert_eq!(metadata.len(), 3);
423        assert_eq!(
424            metadata.get("instrument_id"),
425            Some(&"EURUSD.SIM".to_string())
426        );
427        assert_eq!(metadata.get("price_precision"), Some(&"5".to_string()));
428        assert_eq!(metadata.get("size_precision"), Some(&"8".to_string()));
429    }
430
431    #[rstest]
432    fn test_get_fields() {
433        let fields = QuoteTick::get_fields();
434
435        assert_eq!(fields.len(), 6);
436
437        assert_eq!(fields.get("bid_price"), Some(&FIXED_DECIMAL.to_string()));
438        assert_eq!(fields.get("ask_price"), Some(&FIXED_DECIMAL.to_string()));
439        assert_eq!(fields.get("bid_size"), Some(&FIXED_DECIMAL.to_string()));
440        assert_eq!(fields.get("ask_size"), Some(&FIXED_DECIMAL.to_string()));
441
442        assert_eq!(
443            fields.get("ts_event"),
444            Some(&ARROW_TIMESTAMP_NANOSECOND.to_string())
445        );
446        assert_eq!(
447            fields.get("ts_init"),
448            Some(&ARROW_TIMESTAMP_NANOSECOND.to_string())
449        );
450        assert_eq!(fields.get("identifier"), None);
451    }
452
453    #[rstest]
454    #[case(PriceType::Bid, Price::from("10000.0000"))]
455    #[case(PriceType::Ask, Price::from("10001.0000"))]
456    #[case(PriceType::Mid, Price::from("10000.5000"))]
457    fn test_extract_price(
458        #[case] input: PriceType,
459        #[case] expected: Price,
460        quote_ethusdt_binance: QuoteTick,
461    ) {
462        let quote = quote_ethusdt_binance;
463        let result = quote.extract_price(input).unwrap();
464        assert_eq!(result, expected);
465    }
466
467    #[rstest]
468    #[case(PriceType::Bid, Quantity::from("1.00000000"))]
469    #[case(PriceType::Ask, Quantity::from("1.00000000"))]
470    #[case(PriceType::Mid, Quantity::from("1.00000000"))]
471    fn test_extract_size(
472        #[case] input: PriceType,
473        #[case] expected: Quantity,
474        quote_ethusdt_binance: QuoteTick,
475    ) {
476        let quote = quote_ethusdt_binance;
477        let result = quote.extract_size(input).unwrap();
478        assert_eq!(result, expected);
479    }
480
481    #[rstest]
482    fn test_extract_price_invalid_type() {
483        let quote = create_test_quote();
484        let error = quote.extract_price(PriceType::Last).unwrap_err();
485        assert_eq!(
486            error.to_string(),
487            "Cannot extract price from quote with price type LAST",
488        );
489    }
490
491    #[rstest]
492    fn test_extract_size_invalid_type() {
493        let quote = create_test_quote();
494        let error = quote.extract_size(PriceType::Last).unwrap_err();
495        assert_eq!(
496            error.to_string(),
497            "Cannot extract size from quote with price type LAST",
498        );
499    }
500
501    #[rstest]
502    fn test_quote_tick_has_ts_init() {
503        let quote = create_test_quote();
504        assert_eq!(quote.ts_init(), UnixNanos::from(2_000_000_000));
505    }
506
507    #[rstest]
508    fn test_quote_tick_display() {
509        let quote = create_test_quote();
510        let display_str = format!("{quote}");
511
512        assert!(display_str.contains("EURUSD.SIM"));
513        assert!(display_str.contains("1.0500"));
514        assert!(display_str.contains("1.0505"));
515        assert!(display_str.contains("100000"));
516        assert!(display_str.contains("75000"));
517        assert!(display_str.contains("1000000000"));
518    }
519
520    #[rstest]
521    fn test_quote_tick_with_zero_prices() {
522        let quote = QuoteTick::new(
523            InstrumentId::from("TEST.SIM"),
524            Price::from("0.0000"),
525            Price::from("0.0000"),
526            Quantity::from("1000.0000"),
527            Quantity::from("1000.0000"),
528            UnixNanos::from(0),
529            UnixNanos::from(0),
530        );
531
532        assert!(quote.bid_price.is_zero());
533        assert!(quote.ask_price.is_zero());
534        assert_eq!(quote.ts_event, UnixNanos::from(0));
535        assert_eq!(quote.ts_init, UnixNanos::from(0));
536    }
537
538    #[rstest]
539    fn test_quote_tick_with_max_values() {
540        let quote = QuoteTick::new(
541            InstrumentId::from("TEST.SIM"),
542            Price::from("999999.9999"),
543            Price::from("999999.9999"),
544            Quantity::from("999999999.9999"),
545            Quantity::from("999999999.9999"),
546            UnixNanos::from(u64::MAX),
547            UnixNanos::from(u64::MAX),
548        );
549
550        assert_eq!(quote.ts_event, UnixNanos::from(u64::MAX));
551        assert_eq!(quote.ts_init, UnixNanos::from(u64::MAX));
552    }
553
554    #[rstest]
555    fn test_extract_mid_price_precision() {
556        let quote = QuoteTick::new(
557            InstrumentId::from("TEST.SIM"),
558            Price::from("1.00"),
559            Price::from("1.02"),
560            Quantity::from("100.00"),
561            Quantity::from("100.00"),
562            UnixNanos::from(1_000_000_000),
563            UnixNanos::from(2_000_000_000),
564        );
565
566        let mid_price = quote.extract_price(PriceType::Mid).unwrap();
567        let mid_size = quote.extract_size(PriceType::Mid).unwrap();
568
569        assert_eq!(mid_price, Price::from("1.010"));
570        assert_eq!(mid_size, Quantity::from("100.000"));
571    }
572
573    #[rstest]
574    fn test_extract_mid_price_uses_raw_midpoint_for_odd_negative_values() {
575        let quote = QuoteTick::new(
576            InstrumentId::from("TEST.SIM"),
577            Price::from_raw(-3, FIXED_PRECISION),
578            Price::from_raw(-2, FIXED_PRECISION),
579            Quantity::from("1"),
580            Quantity::from("1"),
581            UnixNanos::from(0),
582            UnixNanos::from(0),
583        );
584
585        let mid_price = quote.extract_price(PriceType::Mid).unwrap();
586
587        assert_eq!(mid_price.raw(), PriceRaw::midpoint(-3, -2));
588        assert_eq!(mid_price.precision, FIXED_PRECISION);
589    }
590
591    #[rstest]
592    fn test_extract_mid_size_uses_raw_midpoint_for_odd_values() {
593        let quote = QuoteTick::new(
594            InstrumentId::from("TEST.SIM"),
595            Price::from("1"),
596            Price::from("1"),
597            Quantity::from_raw(1, FIXED_PRECISION),
598            Quantity::from_raw(2, FIXED_PRECISION),
599            UnixNanos::from(0),
600            UnixNanos::from(0),
601        );
602
603        let mid_size = quote.extract_size(PriceType::Mid).unwrap();
604
605        assert_eq!(mid_size.raw(), QuantityRaw::midpoint(1, 2));
606        assert_eq!(mid_size.precision, FIXED_PRECISION);
607    }
608
609    #[rstest]
610    fn test_extract_mid_size_precision() {
611        let quote = QuoteTick::new(
612            InstrumentId::from("TEST.SIM"),
613            Price::from("1.00"),
614            Price::from("1.01"),
615            Quantity::from("100.00"),
616            Quantity::from("101.00"),
617            UnixNanos::from(1_000_000_000),
618            UnixNanos::from(2_000_000_000),
619        );
620
621        let mid_size = quote.extract_size(PriceType::Mid).unwrap();
622
623        assert_eq!(mid_size, Quantity::from("100.500"));
624    }
625
626    #[rstest]
627    fn test_to_string(quote_ethusdt_binance: QuoteTick) {
628        let quote = quote_ethusdt_binance;
629        assert_eq!(
630            quote.to_string(),
631            "ETHUSDT-PERP.BINANCE,10000.0000,10001.0000,1.00000000,1.00000000,0"
632        );
633    }
634}