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