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")
46)]
47pub struct QuoteTick {
48    /// The quotes instrument ID.
49    pub instrument_id: InstrumentId,
50    /// The top-of-book bid price.
51    pub bid_price: Price,
52    /// The top-of-book ask price.
53    pub ask_price: Price,
54    /// The top-of-book bid size.
55    pub bid_size: Quantity,
56    /// The top-of-book ask size.
57    pub ask_size: Quantity,
58    /// UNIX timestamp (nanoseconds) when the quote event occurred.
59    pub ts_event: UnixNanos,
60    /// UNIX timestamp (nanoseconds) when the instance was created.
61    pub ts_init: UnixNanos,
62}
63
64impl QuoteTick {
65    /// Creates a new [`QuoteTick`] instance with correctness checking.
66    ///
67    /// # Errors
68    ///
69    /// Returns an error if:
70    /// - `bid_price.precision` does not equal `ask_price.precision`.
71    /// - `bid_size.precision` does not equal `ask_size.precision`.
72    ///
73    /// # Notes
74    ///
75    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
76    pub fn new_checked(
77        instrument_id: InstrumentId,
78        bid_price: Price,
79        ask_price: Price,
80        bid_size: Quantity,
81        ask_size: Quantity,
82        ts_event: UnixNanos,
83        ts_init: UnixNanos,
84    ) -> anyhow::Result<Self> {
85        check_equal_u8(
86            bid_price.precision,
87            ask_price.precision,
88            "bid_price.precision",
89            "ask_price.precision",
90        )?;
91        check_equal_u8(
92            bid_size.precision,
93            ask_size.precision,
94            "bid_size.precision",
95            "ask_size.precision",
96        )?;
97        Ok(Self {
98            instrument_id,
99            bid_price,
100            ask_price,
101            bid_size,
102            ask_size,
103            ts_event,
104            ts_init,
105        })
106    }
107
108    /// Creates a new [`QuoteTick`] instance.
109    ///
110    /// # Panics
111    ///
112    /// This function panics if:
113    /// - `bid_price.precision` does not equal `ask_price.precision`.
114    /// - `bid_size.precision` does not equal `ask_size.precision`.
115    pub fn new(
116        instrument_id: InstrumentId,
117        bid_price: Price,
118        ask_price: Price,
119        bid_size: Quantity,
120        ask_size: Quantity,
121        ts_event: UnixNanos,
122        ts_init: UnixNanos,
123    ) -> Self {
124        Self::new_checked(
125            instrument_id,
126            bid_price,
127            ask_price,
128            bid_size,
129            ask_size,
130            ts_event,
131            ts_init,
132        )
133        .expect(FAILED)
134    }
135
136    /// Returns the metadata for the type, for use with serialization formats.
137    #[must_use]
138    pub fn get_metadata(
139        instrument_id: &InstrumentId,
140        price_precision: u8,
141        size_precision: u8,
142    ) -> HashMap<String, String> {
143        let mut metadata = HashMap::new();
144        metadata.insert("instrument_id".to_string(), instrument_id.to_string());
145        metadata.insert("price_precision".to_string(), price_precision.to_string());
146        metadata.insert("size_precision".to_string(), size_precision.to_string());
147        metadata
148    }
149
150    /// Returns the field map for the type, for use with Arrow schemas.
151    #[must_use]
152    pub fn get_fields() -> IndexMap<String, String> {
153        let mut metadata = IndexMap::new();
154        metadata.insert("bid_price".to_string(), FIXED_SIZE_BINARY.to_string());
155        metadata.insert("ask_price".to_string(), FIXED_SIZE_BINARY.to_string());
156        metadata.insert("bid_size".to_string(), FIXED_SIZE_BINARY.to_string());
157        metadata.insert("ask_size".to_string(), FIXED_SIZE_BINARY.to_string());
158        metadata.insert("ts_event".to_string(), "UInt64".to_string());
159        metadata.insert("ts_init".to_string(), "UInt64".to_string());
160        metadata
161    }
162
163    /// Returns the [`Price`] for this quote depending on the given `price_type`.
164    ///
165    /// # Panics
166    ///
167    /// Panics if an unsupported `price_type` is provided.
168    #[must_use]
169    pub fn extract_price(&self, price_type: PriceType) -> Price {
170        match price_type {
171            PriceType::Bid => self.bid_price,
172            PriceType::Ask => self.ask_price,
173            PriceType::Mid => {
174                // Calculate mid avoiding overflow
175                let a = self.bid_price.raw;
176                let b = self.ask_price.raw;
177                let mid_raw = (a / 2) + (b / 2) + ((a % 2 + b % 2) / 2);
178                Price::from_raw(
179                    mid_raw,
180                    cmp::min(self.bid_price.precision + 1, FIXED_PRECISION),
181                )
182            }
183            _ => panic!("Cannot extract with price type {price_type}"),
184        }
185    }
186
187    /// Returns the [`Quantity`] for this quote depending on the given `price_type`.
188    ///
189    /// # Panics
190    ///
191    /// Panics if an unsupported `price_type` is provided.
192    #[must_use]
193    pub fn extract_size(&self, price_type: PriceType) -> Quantity {
194        match price_type {
195            PriceType::Bid => self.bid_size,
196            PriceType::Ask => self.ask_size,
197            PriceType::Mid => {
198                // Calculate mid avoiding overflow
199                let a = self.bid_size.raw;
200                let b = self.ask_size.raw;
201                let mid_raw = (a / 2) + (b / 2) + ((a % 2 + b % 2) / 2);
202                Quantity::from_raw(
203                    mid_raw,
204                    cmp::min(self.bid_size.precision + 1, FIXED_PRECISION),
205                )
206            }
207            _ => panic!("Cannot extract with price type {price_type}"),
208        }
209    }
210}
211
212impl Display for QuoteTick {
213    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        write!(
215            f,
216            "{},{},{},{},{},{}",
217            self.instrument_id,
218            self.bid_price,
219            self.ask_price,
220            self.bid_size,
221            self.ask_size,
222            self.ts_event,
223        )
224    }
225}
226
227impl Serializable for QuoteTick {}
228
229impl HasTsInit for QuoteTick {
230    fn ts_init(&self) -> UnixNanos {
231        self.ts_init
232    }
233}
234
235#[cfg(test)]
236mod tests {
237
238    use nautilus_core::UnixNanos;
239    use rstest::rstest;
240
241    use super::QuoteTickBuilder;
242    use crate::{
243        data::{HasTsInit, QuoteTick, stubs::quote_ethusdt_binance},
244        enums::PriceType,
245        identifiers::InstrumentId,
246        types::{Price, Quantity},
247    };
248
249    fn create_test_quote() -> QuoteTick {
250        QuoteTick::new(
251            InstrumentId::from("EURUSD.SIM"),
252            Price::from("1.0500"),
253            Price::from("1.0505"),
254            Quantity::from("100000"),
255            Quantity::from("75000"),
256            UnixNanos::from(1_000_000_000),
257            UnixNanos::from(2_000_000_000),
258        )
259    }
260
261    #[rstest]
262    fn test_quote_tick_new() {
263        let quote = create_test_quote();
264
265        assert_eq!(quote.instrument_id, InstrumentId::from("EURUSD.SIM"));
266        assert_eq!(quote.bid_price, Price::from("1.0500"));
267        assert_eq!(quote.ask_price, Price::from("1.0505"));
268        assert_eq!(quote.bid_size, Quantity::from("100000"));
269        assert_eq!(quote.ask_size, Quantity::from("75000"));
270        assert_eq!(quote.ts_event, UnixNanos::from(1_000_000_000));
271        assert_eq!(quote.ts_init, UnixNanos::from(2_000_000_000));
272    }
273
274    #[rstest]
275    fn test_quote_tick_new_checked_valid() {
276        let result = QuoteTick::new_checked(
277            InstrumentId::from("GBPUSD.SIM"),
278            Price::from("1.2500"),
279            Price::from("1.2505"),
280            Quantity::from("50000"),
281            Quantity::from("60000"),
282            UnixNanos::from(500_000_000),
283            UnixNanos::from(1_500_000_000),
284        );
285
286        assert!(result.is_ok());
287        let quote = result.unwrap();
288        assert_eq!(quote.instrument_id, InstrumentId::from("GBPUSD.SIM"));
289        assert_eq!(quote.bid_price, Price::from("1.2500"));
290        assert_eq!(quote.ask_price, Price::from("1.2505"));
291    }
292
293    #[rstest]
294    #[should_panic(
295        expected = "'bid_price.precision' u8 of 4 was not equal to 'ask_price.precision' u8 of 5"
296    )]
297    fn test_quote_tick_new_with_precision_mismatch_panics() {
298        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
299        let bid_price = Price::from("10000.0000"); // Precision: 4
300        let ask_price = Price::from("10000.00100"); // Precision: 5 (mismatch)
301        let bid_size = Quantity::from("1.000000");
302        let ask_size = Quantity::from("1.000000");
303        let ts_event = UnixNanos::from(0);
304        let ts_init = UnixNanos::from(1);
305
306        let _ = QuoteTick::new(
307            instrument_id,
308            bid_price,
309            ask_price,
310            bid_size,
311            ask_size,
312            ts_event,
313            ts_init,
314        );
315    }
316
317    #[rstest]
318    fn test_quote_tick_new_checked_with_precision_mismatch_error() {
319        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
320        let bid_price = Price::from("10000.0000");
321        let ask_price = Price::from("10000.0010");
322        let bid_size = Quantity::from("10.000000"); // Precision: 6
323        let ask_size = Quantity::from("10.0000000"); // Precision: 7 (mismatch)
324        let ts_event = UnixNanos::from(0);
325        let ts_init = UnixNanos::from(1);
326
327        let result = QuoteTick::new_checked(
328            instrument_id,
329            bid_price,
330            ask_price,
331            bid_size,
332            ask_size,
333            ts_event,
334            ts_init,
335        );
336
337        assert!(result.is_err());
338        assert!(result.unwrap_err().to_string().contains(
339            "'bid_size.precision' u8 of 6 was not equal to 'ask_size.precision' u8 of 7"
340        ));
341    }
342
343    #[rstest]
344    fn test_quote_tick_builder() {
345        let quote = QuoteTickBuilder::default()
346            .instrument_id(InstrumentId::from("BTCUSD.CRYPTO"))
347            .bid_price(Price::from("50000.00"))
348            .ask_price(Price::from("50001.00"))
349            .bid_size(Quantity::from("0.50"))
350            .ask_size(Quantity::from("0.75"))
351            .ts_event(UnixNanos::from(3_000_000_000))
352            .ts_init(UnixNanos::from(4_000_000_000))
353            .build()
354            .unwrap();
355
356        assert_eq!(quote.instrument_id, InstrumentId::from("BTCUSD.CRYPTO"));
357        assert_eq!(quote.bid_price, Price::from("50000.00"));
358        assert_eq!(quote.ask_price, Price::from("50001.00"));
359        assert_eq!(quote.bid_size, Quantity::from("0.50"));
360        assert_eq!(quote.ask_size, Quantity::from("0.75"));
361        assert_eq!(quote.ts_event, UnixNanos::from(3_000_000_000));
362        assert_eq!(quote.ts_init, UnixNanos::from(4_000_000_000));
363    }
364
365    #[rstest]
366    fn test_get_metadata() {
367        let instrument_id = InstrumentId::from("EURUSD.SIM");
368        let metadata = QuoteTick::get_metadata(&instrument_id, 5, 8);
369
370        assert_eq!(metadata.len(), 3);
371        assert_eq!(
372            metadata.get("instrument_id"),
373            Some(&"EURUSD.SIM".to_string())
374        );
375        assert_eq!(metadata.get("price_precision"), Some(&"5".to_string()));
376        assert_eq!(metadata.get("size_precision"), Some(&"8".to_string()));
377    }
378
379    #[rstest]
380    fn test_get_fields() {
381        let fields = QuoteTick::get_fields();
382
383        assert_eq!(fields.len(), 6);
384
385        #[cfg(feature = "high-precision")]
386        {
387            assert_eq!(
388                fields.get("bid_price"),
389                Some(&"FixedSizeBinary(16)".to_string())
390            );
391            assert_eq!(
392                fields.get("ask_price"),
393                Some(&"FixedSizeBinary(16)".to_string())
394            );
395            assert_eq!(
396                fields.get("bid_size"),
397                Some(&"FixedSizeBinary(16)".to_string())
398            );
399            assert_eq!(
400                fields.get("ask_size"),
401                Some(&"FixedSizeBinary(16)".to_string())
402            );
403        }
404        #[cfg(not(feature = "high-precision"))]
405        {
406            assert_eq!(
407                fields.get("bid_price"),
408                Some(&"FixedSizeBinary(8)".to_string())
409            );
410            assert_eq!(
411                fields.get("ask_price"),
412                Some(&"FixedSizeBinary(8)".to_string())
413            );
414            assert_eq!(
415                fields.get("bid_size"),
416                Some(&"FixedSizeBinary(8)".to_string())
417            );
418            assert_eq!(
419                fields.get("ask_size"),
420                Some(&"FixedSizeBinary(8)".to_string())
421            );
422        }
423
424        assert_eq!(fields.get("ts_event"), Some(&"UInt64".to_string()));
425        assert_eq!(fields.get("ts_init"), Some(&"UInt64".to_string()));
426    }
427
428    #[rstest]
429    #[case(PriceType::Bid, Price::from("10000.0000"))]
430    #[case(PriceType::Ask, Price::from("10001.0000"))]
431    #[case(PriceType::Mid, Price::from("10000.5000"))]
432    fn test_extract_price(
433        #[case] input: PriceType,
434        #[case] expected: Price,
435        quote_ethusdt_binance: QuoteTick,
436    ) {
437        let quote = quote_ethusdt_binance;
438        let result = quote.extract_price(input);
439        assert_eq!(result, expected);
440    }
441
442    #[rstest]
443    #[case(PriceType::Bid, Quantity::from("1.00000000"))]
444    #[case(PriceType::Ask, Quantity::from("1.00000000"))]
445    #[case(PriceType::Mid, Quantity::from("1.00000000"))]
446    fn test_extract_size(
447        #[case] input: PriceType,
448        #[case] expected: Quantity,
449        quote_ethusdt_binance: QuoteTick,
450    ) {
451        let quote = quote_ethusdt_binance;
452        let result = quote.extract_size(input);
453        assert_eq!(result, expected);
454    }
455
456    #[rstest]
457    #[should_panic(expected = "Cannot extract with price type LAST")]
458    fn test_extract_price_invalid_type() {
459        let quote = create_test_quote();
460        let _ = quote.extract_price(PriceType::Last);
461    }
462
463    #[rstest]
464    #[should_panic(expected = "Cannot extract with price type LAST")]
465    fn test_extract_size_invalid_type() {
466        let quote = create_test_quote();
467        let _ = quote.extract_size(PriceType::Last);
468    }
469
470    #[rstest]
471    fn test_quote_tick_has_ts_init() {
472        let quote = create_test_quote();
473        assert_eq!(quote.ts_init(), UnixNanos::from(2_000_000_000));
474    }
475
476    #[rstest]
477    fn test_quote_tick_display() {
478        let quote = create_test_quote();
479        let display_str = format!("{quote}");
480
481        assert!(display_str.contains("EURUSD.SIM"));
482        assert!(display_str.contains("1.0500"));
483        assert!(display_str.contains("1.0505"));
484        assert!(display_str.contains("100000"));
485        assert!(display_str.contains("75000"));
486        assert!(display_str.contains("1000000000"));
487    }
488
489    #[rstest]
490    fn test_quote_tick_with_zero_prices() {
491        let quote = QuoteTick::new(
492            InstrumentId::from("TEST.SIM"),
493            Price::from("0.0000"),
494            Price::from("0.0000"),
495            Quantity::from("1000.0000"),
496            Quantity::from("1000.0000"),
497            UnixNanos::from(0),
498            UnixNanos::from(0),
499        );
500
501        assert!(quote.bid_price.is_zero());
502        assert!(quote.ask_price.is_zero());
503        assert_eq!(quote.ts_event, UnixNanos::from(0));
504        assert_eq!(quote.ts_init, UnixNanos::from(0));
505    }
506
507    #[rstest]
508    fn test_quote_tick_with_max_values() {
509        let quote = QuoteTick::new(
510            InstrumentId::from("TEST.SIM"),
511            Price::from("999999.9999"),
512            Price::from("999999.9999"),
513            Quantity::from("999999999.9999"),
514            Quantity::from("999999999.9999"),
515            UnixNanos::from(u64::MAX),
516            UnixNanos::from(u64::MAX),
517        );
518
519        assert_eq!(quote.ts_event, UnixNanos::from(u64::MAX));
520        assert_eq!(quote.ts_init, UnixNanos::from(u64::MAX));
521    }
522
523    #[rstest]
524    fn test_extract_mid_price_precision() {
525        let quote = QuoteTick::new(
526            InstrumentId::from("TEST.SIM"),
527            Price::from("1.00"),
528            Price::from("1.02"),
529            Quantity::from("100.00"),
530            Quantity::from("100.00"),
531            UnixNanos::from(1_000_000_000),
532            UnixNanos::from(2_000_000_000),
533        );
534
535        let mid_price = quote.extract_price(PriceType::Mid);
536        let mid_size = quote.extract_size(PriceType::Mid);
537
538        assert_eq!(mid_price, Price::from("1.010"));
539        assert_eq!(mid_size, Quantity::from("100.000"));
540    }
541
542    #[rstest]
543    fn test_to_string(quote_ethusdt_binance: QuoteTick) {
544        let quote = quote_ethusdt_binance;
545        assert_eq!(
546            quote.to_string(),
547            "ETHUSDT-PERP.BINANCE,10000.0000,10001.0000,1.00000000,1.00000000,0"
548        );
549    }
550}