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