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::HasTsInit;
30use crate::{
31    enums::PriceType,
32    identifiers::InstrumentId,
33    types::{
34        Price, Quantity,
35        fixed::{FIXED_PRECISION, FIXED_SIZE_BINARY},
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.core.nautilus_pyo3.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    /// This function 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 the metadata for the type, for use with serialization formats.
142    #[must_use]
143    pub fn get_metadata(
144        instrument_id: &InstrumentId,
145        price_precision: u8,
146        size_precision: u8,
147    ) -> HashMap<String, String> {
148        let mut metadata = HashMap::new();
149        metadata.insert("instrument_id".to_string(), instrument_id.to_string());
150        metadata.insert("price_precision".to_string(), price_precision.to_string());
151        metadata.insert("size_precision".to_string(), size_precision.to_string());
152        metadata
153    }
154
155    /// Returns the field map for the type, for use with Arrow schemas.
156    #[must_use]
157    pub fn get_fields() -> IndexMap<String, String> {
158        let mut metadata = IndexMap::new();
159        metadata.insert("bid_price".to_string(), FIXED_SIZE_BINARY.to_string());
160        metadata.insert("ask_price".to_string(), FIXED_SIZE_BINARY.to_string());
161        metadata.insert("bid_size".to_string(), FIXED_SIZE_BINARY.to_string());
162        metadata.insert("ask_size".to_string(), FIXED_SIZE_BINARY.to_string());
163        metadata.insert("ts_event".to_string(), "UInt64".to_string());
164        metadata.insert("ts_init".to_string(), "UInt64".to_string());
165        metadata
166    }
167
168    /// Returns the [`Price`] for this quote depending on the given `price_type`.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if `price_type` is not `Bid`, `Ask`, or `Mid` (a quote has no `Last` price).
173    pub fn extract_price(&self, price_type: PriceType) -> anyhow::Result<Price> {
174        let price = match price_type {
175            PriceType::Bid => self.bid_price,
176            PriceType::Ask => self.ask_price,
177            PriceType::Mid => {
178                // Calculate mid avoiding overflow
179                let a = self.bid_price.raw;
180                let b = self.ask_price.raw;
181                let mid_raw = a.midpoint(b);
182                Price::from_raw(
183                    mid_raw,
184                    cmp::min(self.bid_price.precision + 1, FIXED_PRECISION),
185                )
186            }
187            _ => anyhow::bail!("Cannot extract price from quote with price type {price_type}"),
188        };
189        Ok(price)
190    }
191
192    /// Returns the [`Quantity`] for this quote depending on the given `price_type`.
193    ///
194    /// # Errors
195    ///
196    /// Returns an error if `price_type` is not `Bid`, `Ask`, or `Mid` (a quote has no `Last` size).
197    pub fn extract_size(&self, price_type: PriceType) -> anyhow::Result<Quantity> {
198        let size = match price_type {
199            PriceType::Bid => self.bid_size,
200            PriceType::Ask => self.ask_size,
201            PriceType::Mid => {
202                // Calculate mid avoiding overflow
203                let a = self.bid_size.raw;
204                let b = self.ask_size.raw;
205                let mid_raw = a.midpoint(b);
206                Quantity::from_raw(
207                    mid_raw,
208                    cmp::min(self.bid_size.precision + 1, FIXED_PRECISION),
209                )
210            }
211            _ => anyhow::bail!("Cannot extract size from quote with price type {price_type}"),
212        };
213        Ok(size)
214    }
215}
216
217impl Display for QuoteTick {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        write!(
220            f,
221            "{},{},{},{},{},{}",
222            self.instrument_id,
223            self.bid_price,
224            self.ask_price,
225            self.bid_size,
226            self.ask_size,
227            self.ts_event,
228        )
229    }
230}
231
232impl Serializable for QuoteTick {}
233
234impl HasTsInit for QuoteTick {
235    fn ts_init(&self) -> UnixNanos {
236        self.ts_init
237    }
238}
239
240#[cfg(test)]
241mod tests {
242
243    use nautilus_core::UnixNanos;
244    use rstest::rstest;
245
246    use super::QuoteTickBuilder;
247    use crate::{
248        data::{HasTsInit, QuoteTick, stubs::quote_ethusdt_binance},
249        enums::PriceType,
250        identifiers::InstrumentId,
251        types::{Price, Quantity, fixed::FIXED_PRECISION, price::PriceRaw, quantity::QuantityRaw},
252    };
253
254    fn create_test_quote() -> QuoteTick {
255        QuoteTick::new(
256            InstrumentId::from("EURUSD.SIM"),
257            Price::from("1.0500"),
258            Price::from("1.0505"),
259            Quantity::from("100000"),
260            Quantity::from("75000"),
261            UnixNanos::from(1_000_000_000),
262            UnixNanos::from(2_000_000_000),
263        )
264    }
265
266    #[rstest]
267    fn test_quote_tick_new() {
268        let quote = create_test_quote();
269
270        assert_eq!(quote.instrument_id, InstrumentId::from("EURUSD.SIM"));
271        assert_eq!(quote.bid_price, Price::from("1.0500"));
272        assert_eq!(quote.ask_price, Price::from("1.0505"));
273        assert_eq!(quote.bid_size, Quantity::from("100000"));
274        assert_eq!(quote.ask_size, Quantity::from("75000"));
275        assert_eq!(quote.ts_event, UnixNanos::from(1_000_000_000));
276        assert_eq!(quote.ts_init, UnixNanos::from(2_000_000_000));
277    }
278
279    #[rstest]
280    fn test_quote_tick_new_checked_valid() {
281        let result = QuoteTick::new_checked(
282            InstrumentId::from("GBPUSD.SIM"),
283            Price::from("1.2500"),
284            Price::from("1.2505"),
285            Quantity::from("50000"),
286            Quantity::from("60000"),
287            UnixNanos::from(500_000_000),
288            UnixNanos::from(1_500_000_000),
289        );
290
291        assert!(result.is_ok());
292        let quote = result.unwrap();
293        assert_eq!(quote.instrument_id, InstrumentId::from("GBPUSD.SIM"));
294        assert_eq!(quote.bid_price, Price::from("1.2500"));
295        assert_eq!(quote.ask_price, Price::from("1.2505"));
296    }
297
298    #[rstest]
299    #[should_panic(
300        expected = "'bid_price.precision' u8 of 4 was not equal to 'ask_price.precision' u8 of 5"
301    )]
302    fn test_quote_tick_new_with_precision_mismatch_panics() {
303        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
304        let bid_price = Price::from("10000.0000"); // Precision: 4
305        let ask_price = Price::from("10000.00100"); // Precision: 5 (mismatch)
306        let bid_size = Quantity::from("1.000000");
307        let ask_size = Quantity::from("1.000000");
308        let ts_event = UnixNanos::from(0);
309        let ts_init = UnixNanos::from(1);
310
311        let _ = QuoteTick::new(
312            instrument_id,
313            bid_price,
314            ask_price,
315            bid_size,
316            ask_size,
317            ts_event,
318            ts_init,
319        );
320    }
321
322    #[rstest]
323    fn test_quote_tick_new_checked_with_precision_mismatch_error() {
324        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
325        let bid_price = Price::from("10000.0000");
326        let ask_price = Price::from("10000.0010");
327        let bid_size = Quantity::from("10.000000"); // Precision: 6
328        let ask_size = Quantity::from("10.0000000"); // Precision: 7 (mismatch)
329        let ts_event = UnixNanos::from(0);
330        let ts_init = UnixNanos::from(1);
331
332        let result = QuoteTick::new_checked(
333            instrument_id,
334            bid_price,
335            ask_price,
336            bid_size,
337            ask_size,
338            ts_event,
339            ts_init,
340        );
341
342        assert!(result.is_err());
343        assert!(result.unwrap_err().to_string().contains(
344            "'bid_size.precision' u8 of 6 was not equal to 'ask_size.precision' u8 of 7"
345        ));
346    }
347
348    #[rstest]
349    fn test_quote_tick_builder() {
350        let quote = QuoteTickBuilder::default()
351            .instrument_id(InstrumentId::from("BTCUSD.CRYPTO"))
352            .bid_price(Price::from("50000.00"))
353            .ask_price(Price::from("50001.00"))
354            .bid_size(Quantity::from("0.50"))
355            .ask_size(Quantity::from("0.75"))
356            .ts_event(UnixNanos::from(3_000_000_000))
357            .ts_init(UnixNanos::from(4_000_000_000))
358            .build()
359            .unwrap();
360
361        assert_eq!(quote.instrument_id, InstrumentId::from("BTCUSD.CRYPTO"));
362        assert_eq!(quote.bid_price, Price::from("50000.00"));
363        assert_eq!(quote.ask_price, Price::from("50001.00"));
364        assert_eq!(quote.bid_size, Quantity::from("0.50"));
365        assert_eq!(quote.ask_size, Quantity::from("0.75"));
366        assert_eq!(quote.ts_event, UnixNanos::from(3_000_000_000));
367        assert_eq!(quote.ts_init, UnixNanos::from(4_000_000_000));
368    }
369
370    #[rstest]
371    fn test_get_metadata() {
372        let instrument_id = InstrumentId::from("EURUSD.SIM");
373        let metadata = QuoteTick::get_metadata(&instrument_id, 5, 8);
374
375        assert_eq!(metadata.len(), 3);
376        assert_eq!(
377            metadata.get("instrument_id"),
378            Some(&"EURUSD.SIM".to_string())
379        );
380        assert_eq!(metadata.get("price_precision"), Some(&"5".to_string()));
381        assert_eq!(metadata.get("size_precision"), Some(&"8".to_string()));
382    }
383
384    #[rstest]
385    fn test_get_fields() {
386        let fields = QuoteTick::get_fields();
387
388        assert_eq!(fields.len(), 6);
389
390        #[cfg(feature = "high-precision")]
391        {
392            assert_eq!(
393                fields.get("bid_price"),
394                Some(&"FixedSizeBinary(16)".to_string())
395            );
396            assert_eq!(
397                fields.get("ask_price"),
398                Some(&"FixedSizeBinary(16)".to_string())
399            );
400            assert_eq!(
401                fields.get("bid_size"),
402                Some(&"FixedSizeBinary(16)".to_string())
403            );
404            assert_eq!(
405                fields.get("ask_size"),
406                Some(&"FixedSizeBinary(16)".to_string())
407            );
408        }
409        #[cfg(not(feature = "high-precision"))]
410        {
411            assert_eq!(
412                fields.get("bid_price"),
413                Some(&"FixedSizeBinary(8)".to_string())
414            );
415            assert_eq!(
416                fields.get("ask_price"),
417                Some(&"FixedSizeBinary(8)".to_string())
418            );
419            assert_eq!(
420                fields.get("bid_size"),
421                Some(&"FixedSizeBinary(8)".to_string())
422            );
423            assert_eq!(
424                fields.get("ask_size"),
425                Some(&"FixedSizeBinary(8)".to_string())
426            );
427        }
428
429        assert_eq!(fields.get("ts_event"), Some(&"UInt64".to_string()));
430        assert_eq!(fields.get("ts_init"), Some(&"UInt64".to_string()));
431    }
432
433    #[rstest]
434    #[case(PriceType::Bid, Price::from("10000.0000"))]
435    #[case(PriceType::Ask, Price::from("10001.0000"))]
436    #[case(PriceType::Mid, Price::from("10000.5000"))]
437    fn test_extract_price(
438        #[case] input: PriceType,
439        #[case] expected: Price,
440        quote_ethusdt_binance: QuoteTick,
441    ) {
442        let quote = quote_ethusdt_binance;
443        let result = quote.extract_price(input).unwrap();
444        assert_eq!(result, expected);
445    }
446
447    #[rstest]
448    #[case(PriceType::Bid, Quantity::from("1.00000000"))]
449    #[case(PriceType::Ask, Quantity::from("1.00000000"))]
450    #[case(PriceType::Mid, Quantity::from("1.00000000"))]
451    fn test_extract_size(
452        #[case] input: PriceType,
453        #[case] expected: Quantity,
454        quote_ethusdt_binance: QuoteTick,
455    ) {
456        let quote = quote_ethusdt_binance;
457        let result = quote.extract_size(input).unwrap();
458        assert_eq!(result, expected);
459    }
460
461    #[rstest]
462    fn test_extract_price_invalid_type() {
463        let quote = create_test_quote();
464        let error = quote.extract_price(PriceType::Last).unwrap_err();
465        assert_eq!(
466            error.to_string(),
467            "Cannot extract price from quote with price type LAST",
468        );
469    }
470
471    #[rstest]
472    fn test_extract_size_invalid_type() {
473        let quote = create_test_quote();
474        let error = quote.extract_size(PriceType::Last).unwrap_err();
475        assert_eq!(
476            error.to_string(),
477            "Cannot extract size from quote with price type LAST",
478        );
479    }
480
481    #[rstest]
482    fn test_quote_tick_has_ts_init() {
483        let quote = create_test_quote();
484        assert_eq!(quote.ts_init(), UnixNanos::from(2_000_000_000));
485    }
486
487    #[rstest]
488    fn test_quote_tick_display() {
489        let quote = create_test_quote();
490        let display_str = format!("{quote}");
491
492        assert!(display_str.contains("EURUSD.SIM"));
493        assert!(display_str.contains("1.0500"));
494        assert!(display_str.contains("1.0505"));
495        assert!(display_str.contains("100000"));
496        assert!(display_str.contains("75000"));
497        assert!(display_str.contains("1000000000"));
498    }
499
500    #[rstest]
501    fn test_quote_tick_with_zero_prices() {
502        let quote = QuoteTick::new(
503            InstrumentId::from("TEST.SIM"),
504            Price::from("0.0000"),
505            Price::from("0.0000"),
506            Quantity::from("1000.0000"),
507            Quantity::from("1000.0000"),
508            UnixNanos::from(0),
509            UnixNanos::from(0),
510        );
511
512        assert!(quote.bid_price.is_zero());
513        assert!(quote.ask_price.is_zero());
514        assert_eq!(quote.ts_event, UnixNanos::from(0));
515        assert_eq!(quote.ts_init, UnixNanos::from(0));
516    }
517
518    #[rstest]
519    fn test_quote_tick_with_max_values() {
520        let quote = QuoteTick::new(
521            InstrumentId::from("TEST.SIM"),
522            Price::from("999999.9999"),
523            Price::from("999999.9999"),
524            Quantity::from("999999999.9999"),
525            Quantity::from("999999999.9999"),
526            UnixNanos::from(u64::MAX),
527            UnixNanos::from(u64::MAX),
528        );
529
530        assert_eq!(quote.ts_event, UnixNanos::from(u64::MAX));
531        assert_eq!(quote.ts_init, UnixNanos::from(u64::MAX));
532    }
533
534    #[rstest]
535    fn test_extract_mid_price_precision() {
536        let quote = QuoteTick::new(
537            InstrumentId::from("TEST.SIM"),
538            Price::from("1.00"),
539            Price::from("1.02"),
540            Quantity::from("100.00"),
541            Quantity::from("100.00"),
542            UnixNanos::from(1_000_000_000),
543            UnixNanos::from(2_000_000_000),
544        );
545
546        let mid_price = quote.extract_price(PriceType::Mid).unwrap();
547        let mid_size = quote.extract_size(PriceType::Mid).unwrap();
548
549        assert_eq!(mid_price, Price::from("1.010"));
550        assert_eq!(mid_size, Quantity::from("100.000"));
551    }
552
553    #[rstest]
554    fn test_extract_mid_price_uses_raw_midpoint_for_odd_negative_values() {
555        let quote = QuoteTick::new(
556            InstrumentId::from("TEST.SIM"),
557            Price::from_raw(-3, FIXED_PRECISION),
558            Price::from_raw(-2, FIXED_PRECISION),
559            Quantity::from("1"),
560            Quantity::from("1"),
561            UnixNanos::from(0),
562            UnixNanos::from(0),
563        );
564
565        let mid_price = quote.extract_price(PriceType::Mid).unwrap();
566
567        assert_eq!(mid_price.raw, PriceRaw::midpoint(-3, -2));
568        assert_eq!(mid_price.precision, FIXED_PRECISION);
569    }
570
571    #[rstest]
572    fn test_extract_mid_size_uses_raw_midpoint_for_odd_values() {
573        let quote = QuoteTick::new(
574            InstrumentId::from("TEST.SIM"),
575            Price::from("1"),
576            Price::from("1"),
577            Quantity::from_raw(1, FIXED_PRECISION),
578            Quantity::from_raw(2, FIXED_PRECISION),
579            UnixNanos::from(0),
580            UnixNanos::from(0),
581        );
582
583        let mid_size = quote.extract_size(PriceType::Mid).unwrap();
584
585        assert_eq!(mid_size.raw, QuantityRaw::midpoint(1, 2));
586        assert_eq!(mid_size.precision, FIXED_PRECISION);
587    }
588
589    #[rstest]
590    fn test_extract_mid_size_precision() {
591        let quote = QuoteTick::new(
592            InstrumentId::from("TEST.SIM"),
593            Price::from("1.00"),
594            Price::from("1.01"),
595            Quantity::from("100.00"),
596            Quantity::from("101.00"),
597            UnixNanos::from(1_000_000_000),
598            UnixNanos::from(2_000_000_000),
599        );
600
601        let mid_size = quote.extract_size(PriceType::Mid).unwrap();
602
603        assert_eq!(mid_size, Quantity::from("100.500"));
604    }
605
606    #[rstest]
607    fn test_to_string(quote_ethusdt_binance: QuoteTick) {
608        let quote = quote_ethusdt_binance;
609        assert_eq!(
610            quote.to_string(),
611            "ETHUSDT-PERP.BINANCE,10000.0000,10001.0000,1.00000000,1.00000000,0"
612        );
613    }
614}