nautilus_model/data/
quote.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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::GetTsInit;
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 struct was initialized.
61    pub ts_init: UnixNanos,
62}
63
64impl QuoteTick {
65    /// Creates a new [`QuoteTick`] instance with correctness checking.
66    ///
67    /// # Errors
68    ///
69    /// This function returns an error:
70    /// - If `bid_price.precision` does not equal `ask_price.precision`.
71    /// - If `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:
113    /// - If `bid_price.precision` does not equal `ask_price.precision`.
114    /// - If `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    #[must_use]
165    pub fn extract_price(&self, price_type: PriceType) -> Price {
166        match price_type {
167            PriceType::Bid => self.bid_price,
168            PriceType::Ask => self.ask_price,
169            PriceType::Mid => Price::from_raw(
170                (self.bid_price.raw + self.ask_price.raw) / 2,
171                cmp::min(self.bid_price.precision + 1, FIXED_PRECISION),
172            ),
173            _ => panic!("Cannot extract with price type {price_type}"),
174        }
175    }
176
177    /// Returns the [`Quantity`] for this quote depending on the given `price_type`.
178    #[must_use]
179    pub fn extract_size(&self, price_type: PriceType) -> Quantity {
180        match price_type {
181            PriceType::Bid => self.bid_size,
182            PriceType::Ask => self.ask_size,
183            PriceType::Mid => Quantity::from_raw(
184                (self.bid_size.raw + self.ask_size.raw) / 2,
185                cmp::min(self.bid_size.precision + 1, FIXED_PRECISION),
186            ),
187            _ => panic!("Cannot extract with price type {price_type}"),
188        }
189    }
190}
191
192impl Display for QuoteTick {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        write!(
195            f,
196            "{},{},{},{},{},{}",
197            self.instrument_id,
198            self.bid_price,
199            self.ask_price,
200            self.bid_size,
201            self.ask_size,
202            self.ts_event,
203        )
204    }
205}
206
207impl Serializable for QuoteTick {}
208
209impl GetTsInit for QuoteTick {
210    fn ts_init(&self) -> UnixNanos {
211        self.ts_init
212    }
213}
214
215////////////////////////////////////////////////////////////////////////////////
216// Tests
217////////////////////////////////////////////////////////////////////////////////
218#[cfg(test)]
219mod tests {
220    use nautilus_core::{UnixNanos, serialization::Serializable};
221    use rstest::rstest;
222
223    use crate::{
224        data::{QuoteTick, stubs::quote_ethusdt_binance},
225        enums::PriceType,
226        identifiers::InstrumentId,
227        types::{Price, Quantity},
228    };
229
230    #[rstest]
231    #[should_panic(
232        expected = "'bid_price.precision' u8 of 4 was not equal to 'ask_price.precision' u8 of 5"
233    )]
234    fn test_quote_tick_new_with_precision_mismatch_panics() {
235        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
236        let bid_price = Price::from("10000.0000"); // Precision: 4
237        let ask_price = Price::from("10000.00100"); // Precision: 5 (mismatch)
238        let bid_size = Quantity::from("1.000000");
239        let ask_size = Quantity::from("1.000000");
240        let ts_event = UnixNanos::from(0);
241        let ts_init = UnixNanos::from(1);
242
243        let _ = QuoteTick::new(
244            instrument_id,
245            bid_price,
246            ask_price,
247            bid_size,
248            ask_size,
249            ts_event,
250            ts_init,
251        );
252    }
253
254    #[rstest]
255    fn test_quote_tick_new_checked_with_precision_mismatch_error() {
256        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
257        let bid_price = Price::from("10000.0000");
258        let ask_price = Price::from("10000.0010");
259        let bid_size = Quantity::from("10.000000"); // Precision: 6
260        let ask_size = Quantity::from("10.0000000"); // Precision: 7 (mismatch)
261        let ts_event = UnixNanos::from(0);
262        let ts_init = UnixNanos::from(1);
263
264        let result = QuoteTick::new_checked(
265            instrument_id,
266            bid_price,
267            ask_price,
268            bid_size,
269            ask_size,
270            ts_event,
271            ts_init,
272        );
273
274        assert!(result.is_err());
275    }
276
277    #[rstest]
278    fn test_to_string(quote_ethusdt_binance: QuoteTick) {
279        let quote = quote_ethusdt_binance;
280        assert_eq!(
281            quote.to_string(),
282            "ETHUSDT-PERP.BINANCE,10000.0000,10001.0000,1.00000000,1.00000000,0"
283        );
284    }
285
286    #[rstest]
287    #[case(PriceType::Bid, Price::from("10000.0000"))]
288    #[case(PriceType::Ask, Price::from("10001.0000"))]
289    #[case(PriceType::Mid, Price::from("10000.5000"))]
290    fn test_extract_price(
291        #[case] input: PriceType,
292        #[case] expected: Price,
293        quote_ethusdt_binance: QuoteTick,
294    ) {
295        let quote = quote_ethusdt_binance;
296        let result = quote.extract_price(input);
297        assert_eq!(result, expected);
298    }
299
300    #[rstest]
301    fn test_json_serialization(quote_ethusdt_binance: QuoteTick) {
302        let quote = quote_ethusdt_binance;
303        let serialized = quote.as_json_bytes().unwrap();
304        let deserialized = QuoteTick::from_json_bytes(serialized.as_ref()).unwrap();
305        assert_eq!(deserialized, quote);
306    }
307
308    #[rstest]
309    fn test_msgpack_serialization(quote_ethusdt_binance: QuoteTick) {
310        let quote = quote_ethusdt_binance;
311        let serialized = quote.as_msgpack_bytes().unwrap();
312        let deserialized = QuoteTick::from_msgpack_bytes(serialized.as_ref()).unwrap();
313        assert_eq!(deserialized, quote);
314    }
315}