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::{
19    cmp,
20    collections::HashMap,
21    fmt::{Display, Formatter},
22    hash::Hash,
23};
24
25use derive_builder::Builder;
26use indexmap::IndexMap;
27use nautilus_core::{
28    correctness::{check_equal_u8, FAILED},
29    serialization::Serializable,
30    UnixNanos,
31};
32use serde::{Deserialize, Serialize};
33
34use super::GetTsInit;
35use crate::{
36    enums::PriceType,
37    identifiers::InstrumentId,
38    types::{fixed::FIXED_PRECISION, Price, Quantity},
39};
40
41/// Represents a single quote tick in a market.
42#[repr(C)]
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Builder)]
44#[serde(tag = "type")]
45#[cfg_attr(
46    feature = "python",
47    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
48)]
49pub struct QuoteTick {
50    /// The quotes instrument ID.
51    pub instrument_id: InstrumentId,
52    /// The top-of-book bid price.
53    pub bid_price: Price,
54    /// The top-of-book ask price.
55    pub ask_price: Price,
56    /// The top-of-book bid size.
57    pub bid_size: Quantity,
58    /// The top-of-book ask size.
59    pub ask_size: Quantity,
60    /// UNIX timestamp (nanoseconds) when the quote event occurred.
61    pub ts_event: UnixNanos,
62    /// UNIX timestamp (nanoseconds) when the struct was initialized.
63    pub ts_init: UnixNanos,
64}
65
66impl QuoteTick {
67    /// Creates a new [`QuoteTick`] instance with correctness checking.
68    ///
69    /// # Errors
70    ///
71    /// This function returns an error:
72    /// - If `bid_price.precision` does not equal `ask_price.precision`.
73    /// - If `bid_size.precision` does not equal `ask_size.precision`.
74    ///
75    /// # Notes
76    ///
77    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
78    pub fn new_checked(
79        instrument_id: InstrumentId,
80        bid_price: Price,
81        ask_price: Price,
82        bid_size: Quantity,
83        ask_size: Quantity,
84        ts_event: UnixNanos,
85        ts_init: UnixNanos,
86    ) -> anyhow::Result<Self> {
87        check_equal_u8(
88            bid_price.precision,
89            ask_price.precision,
90            "bid_price.precision",
91            "ask_price.precision",
92        )?;
93        check_equal_u8(
94            bid_size.precision,
95            ask_size.precision,
96            "bid_size.precision",
97            "ask_size.precision",
98        )?;
99        Ok(Self {
100            instrument_id,
101            bid_price,
102            ask_price,
103            bid_size,
104            ask_size,
105            ts_event,
106            ts_init,
107        })
108    }
109
110    /// Creates a new [`QuoteTick`] instance.
111    ///
112    /// # Panics
113    ///
114    /// This function panics:
115    /// - If `bid_price.precision` does not equal `ask_price.precision`.
116    /// - If `bid_size.precision` does not equal `ask_size.precision`.
117    pub fn new(
118        instrument_id: InstrumentId,
119        bid_price: Price,
120        ask_price: Price,
121        bid_size: Quantity,
122        ask_size: Quantity,
123        ts_event: UnixNanos,
124        ts_init: UnixNanos,
125    ) -> Self {
126        Self::new_checked(
127            instrument_id,
128            bid_price,
129            ask_price,
130            bid_size,
131            ask_size,
132            ts_event,
133            ts_init,
134        )
135        .expect(FAILED)
136    }
137
138    /// Returns the metadata for the type, for use with serialization formats.
139    #[must_use]
140    pub fn get_metadata(
141        instrument_id: &InstrumentId,
142        price_precision: u8,
143        size_precision: u8,
144    ) -> HashMap<String, String> {
145        let mut metadata = HashMap::new();
146        metadata.insert("instrument_id".to_string(), instrument_id.to_string());
147        metadata.insert("price_precision".to_string(), price_precision.to_string());
148        metadata.insert("size_precision".to_string(), size_precision.to_string());
149        metadata
150    }
151
152    /// Returns the field map for the type, for use with Arrow schemas.
153    #[must_use]
154    pub fn get_fields() -> IndexMap<String, String> {
155        let mut metadata = IndexMap::new();
156        metadata.insert("bid_price".to_string(), "Int64".to_string());
157        metadata.insert("ask_price".to_string(), "Int64".to_string());
158        metadata.insert("bid_size".to_string(), "UInt64".to_string());
159        metadata.insert("ask_size".to_string(), "UInt64".to_string());
160        metadata.insert("ts_event".to_string(), "UInt64".to_string());
161        metadata.insert("ts_init".to_string(), "UInt64".to_string());
162        metadata
163    }
164
165    /// Returns the [`Price`] for this quote depending on the given `price_type`.
166    #[must_use]
167    pub fn extract_price(&self, price_type: PriceType) -> Price {
168        match price_type {
169            PriceType::Bid => self.bid_price,
170            PriceType::Ask => self.ask_price,
171            PriceType::Mid => Price::from_raw(
172                (self.bid_price.raw + self.ask_price.raw) / 2,
173                cmp::min(self.bid_price.precision + 1, FIXED_PRECISION),
174            ),
175            _ => panic!("Cannot extract with price type {price_type}"),
176        }
177    }
178
179    /// Returns the [`Quantity`] for this quote depending on the given `price_type`.
180    #[must_use]
181    pub fn extract_size(&self, price_type: PriceType) -> Quantity {
182        match price_type {
183            PriceType::Bid => self.bid_size,
184            PriceType::Ask => self.ask_size,
185            PriceType::Mid => Quantity::from_raw(
186                (self.bid_size.raw + self.ask_size.raw) / 2,
187                cmp::min(self.bid_size.precision + 1, FIXED_PRECISION),
188            ),
189            _ => panic!("Cannot extract with price type {price_type}"),
190        }
191    }
192}
193
194impl Display for QuoteTick {
195    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
196        write!(
197            f,
198            "{},{},{},{},{},{}",
199            self.instrument_id,
200            self.bid_price,
201            self.ask_price,
202            self.bid_size,
203            self.ask_size,
204            self.ts_event,
205        )
206    }
207}
208
209impl Serializable for QuoteTick {}
210
211impl GetTsInit for QuoteTick {
212    fn ts_init(&self) -> UnixNanos {
213        self.ts_init
214    }
215}
216
217////////////////////////////////////////////////////////////////////////////////
218// Tests
219////////////////////////////////////////////////////////////////////////////////
220#[cfg(test)]
221mod tests {
222    use nautilus_core::{serialization::Serializable, UnixNanos};
223    use rstest::rstest;
224
225    use crate::{
226        data::{stubs::quote_ethusdt_binance, QuoteTick},
227        enums::PriceType,
228        identifiers::InstrumentId,
229        types::{Price, Quantity},
230    };
231
232    #[rstest]
233    #[should_panic(
234        expected = "'bid_price.precision' u8 of 4 was not equal to 'ask_price.precision' u8 of 5"
235    )]
236    fn test_quote_tick_new_with_precision_mismatch_panics() {
237        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
238        let bid_price = Price::from("10000.0000"); // Precision: 4
239        let ask_price = Price::from("10000.00100"); // Precision: 5 (mismatch)
240        let bid_size = Quantity::from("1.000000");
241        let ask_size = Quantity::from("1.000000");
242        let ts_event = UnixNanos::from(0);
243        let ts_init = UnixNanos::from(1);
244
245        let _ = QuoteTick::new(
246            instrument_id,
247            bid_price,
248            ask_price,
249            bid_size,
250            ask_size,
251            ts_event,
252            ts_init,
253        );
254    }
255
256    #[rstest]
257    fn test_quote_tick_new_checked_with_precision_mismatch_error() {
258        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
259        let bid_price = Price::from("10000.0000");
260        let ask_price = Price::from("10000.0010");
261        let bid_size = Quantity::from("10.000000"); // Precision: 6
262        let ask_size = Quantity::from("10.0000000"); // Precision: 7 (mismatch)
263        let ts_event = UnixNanos::from(0);
264        let ts_init = UnixNanos::from(1);
265
266        let result = QuoteTick::new_checked(
267            instrument_id,
268            bid_price,
269            ask_price,
270            bid_size,
271            ask_size,
272            ts_event,
273            ts_init,
274        );
275
276        assert!(result.is_err());
277    }
278
279    #[rstest]
280    fn test_to_string(quote_ethusdt_binance: QuoteTick) {
281        let quote = quote_ethusdt_binance;
282        assert_eq!(
283            quote.to_string(),
284            "ETHUSDT-PERP.BINANCE,10000.0000,10001.0000,1.00000000,1.00000000,0"
285        );
286    }
287
288    #[rstest]
289    #[case(PriceType::Bid, Price::from("10000.0000"))]
290    #[case(PriceType::Ask, Price::from("10001.0000"))]
291    #[case(PriceType::Mid, Price::from("10000.5000"))]
292    fn test_extract_price(
293        #[case] input: PriceType,
294        #[case] expected: Price,
295        quote_ethusdt_binance: QuoteTick,
296    ) {
297        let quote = quote_ethusdt_binance;
298        let result = quote.extract_price(input);
299        assert_eq!(result, expected);
300    }
301
302    #[rstest]
303    fn test_json_serialization(quote_ethusdt_binance: QuoteTick) {
304        let quote = quote_ethusdt_binance;
305        let serialized = quote.as_json_bytes().unwrap();
306        let deserialized = QuoteTick::from_json_bytes(serialized.as_ref()).unwrap();
307        assert_eq!(deserialized, quote);
308    }
309
310    #[rstest]
311    fn test_msgpack_serialization(quote_ethusdt_binance: QuoteTick) {
312        let quote = quote_ethusdt_binance;
313        let serialized = quote.as_msgpack_bytes().unwrap();
314        let deserialized = QuoteTick::from_msgpack_bytes(serialized.as_ref()).unwrap();
315        assert_eq!(deserialized, quote);
316    }
317}