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