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