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 #[must_use]
174 pub fn extract_price(&self, price_type: PriceType) -> Price {
175 match price_type {
176 PriceType::Bid => self.bid_price,
177 PriceType::Ask => self.ask_price,
178 PriceType::Mid => {
179 let a = self.bid_price.raw;
181 let b = self.ask_price.raw;
182 let mid_raw = a.midpoint(b);
183 Price::from_raw(
184 mid_raw,
185 cmp::min(self.bid_price.precision + 1, FIXED_PRECISION),
186 )
187 }
188 _ => panic!("Cannot extract with price type {price_type}"),
189 }
190 }
191
192 #[must_use]
198 pub fn extract_size(&self, price_type: PriceType) -> Quantity {
199 match price_type {
200 PriceType::Bid => self.bid_size,
201 PriceType::Ask => self.ask_size,
202 PriceType::Mid => {
203 let a = self.bid_size.raw;
205 let b = self.ask_size.raw;
206 let mid_raw = a.midpoint(b);
207 Quantity::from_raw(
208 mid_raw,
209 cmp::min(self.bid_size.precision + 1, FIXED_PRECISION),
210 )
211 }
212 _ => panic!("Cannot extract with price type {price_type}"),
213 }
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);
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);
458 assert_eq!(result, expected);
459 }
460
461 #[rstest]
462 #[should_panic(expected = "Cannot extract with price type LAST")]
463 fn test_extract_price_invalid_type() {
464 let quote = create_test_quote();
465 let _ = quote.extract_price(PriceType::Last);
466 }
467
468 #[rstest]
469 #[should_panic(expected = "Cannot extract with price type LAST")]
470 fn test_extract_size_invalid_type() {
471 let quote = create_test_quote();
472 let _ = quote.extract_size(PriceType::Last);
473 }
474
475 #[rstest]
476 fn test_quote_tick_has_ts_init() {
477 let quote = create_test_quote();
478 assert_eq!(quote.ts_init(), UnixNanos::from(2_000_000_000));
479 }
480
481 #[rstest]
482 fn test_quote_tick_display() {
483 let quote = create_test_quote();
484 let display_str = format!("{quote}");
485
486 assert!(display_str.contains("EURUSD.SIM"));
487 assert!(display_str.contains("1.0500"));
488 assert!(display_str.contains("1.0505"));
489 assert!(display_str.contains("100000"));
490 assert!(display_str.contains("75000"));
491 assert!(display_str.contains("1000000000"));
492 }
493
494 #[rstest]
495 fn test_quote_tick_with_zero_prices() {
496 let quote = QuoteTick::new(
497 InstrumentId::from("TEST.SIM"),
498 Price::from("0.0000"),
499 Price::from("0.0000"),
500 Quantity::from("1000.0000"),
501 Quantity::from("1000.0000"),
502 UnixNanos::from(0),
503 UnixNanos::from(0),
504 );
505
506 assert!(quote.bid_price.is_zero());
507 assert!(quote.ask_price.is_zero());
508 assert_eq!(quote.ts_event, UnixNanos::from(0));
509 assert_eq!(quote.ts_init, UnixNanos::from(0));
510 }
511
512 #[rstest]
513 fn test_quote_tick_with_max_values() {
514 let quote = QuoteTick::new(
515 InstrumentId::from("TEST.SIM"),
516 Price::from("999999.9999"),
517 Price::from("999999.9999"),
518 Quantity::from("999999999.9999"),
519 Quantity::from("999999999.9999"),
520 UnixNanos::from(u64::MAX),
521 UnixNanos::from(u64::MAX),
522 );
523
524 assert_eq!(quote.ts_event, UnixNanos::from(u64::MAX));
525 assert_eq!(quote.ts_init, UnixNanos::from(u64::MAX));
526 }
527
528 #[rstest]
529 fn test_extract_mid_price_precision() {
530 let quote = QuoteTick::new(
531 InstrumentId::from("TEST.SIM"),
532 Price::from("1.00"),
533 Price::from("1.02"),
534 Quantity::from("100.00"),
535 Quantity::from("100.00"),
536 UnixNanos::from(1_000_000_000),
537 UnixNanos::from(2_000_000_000),
538 );
539
540 let mid_price = quote.extract_price(PriceType::Mid);
541 let mid_size = quote.extract_size(PriceType::Mid);
542
543 assert_eq!(mid_price, Price::from("1.010"));
544 assert_eq!(mid_size, Quantity::from("100.000"));
545 }
546
547 #[rstest]
548 fn test_extract_mid_price_uses_raw_midpoint_for_odd_negative_values() {
549 let quote = QuoteTick::new(
550 InstrumentId::from("TEST.SIM"),
551 Price::from_raw(-3, FIXED_PRECISION),
552 Price::from_raw(-2, FIXED_PRECISION),
553 Quantity::from("1"),
554 Quantity::from("1"),
555 UnixNanos::from(0),
556 UnixNanos::from(0),
557 );
558
559 let mid_price = quote.extract_price(PriceType::Mid);
560
561 assert_eq!(mid_price.raw, PriceRaw::midpoint(-3, -2));
562 assert_eq!(mid_price.precision, FIXED_PRECISION);
563 }
564
565 #[rstest]
566 fn test_extract_mid_size_uses_raw_midpoint_for_odd_values() {
567 let quote = QuoteTick::new(
568 InstrumentId::from("TEST.SIM"),
569 Price::from("1"),
570 Price::from("1"),
571 Quantity::from_raw(1, FIXED_PRECISION),
572 Quantity::from_raw(2, FIXED_PRECISION),
573 UnixNanos::from(0),
574 UnixNanos::from(0),
575 );
576
577 let mid_size = quote.extract_size(PriceType::Mid);
578
579 assert_eq!(mid_size.raw, QuantityRaw::midpoint(1, 2));
580 assert_eq!(mid_size.precision, FIXED_PRECISION);
581 }
582
583 #[rstest]
584 fn test_extract_mid_size_precision() {
585 let quote = QuoteTick::new(
586 InstrumentId::from("TEST.SIM"),
587 Price::from("1.00"),
588 Price::from("1.01"),
589 Quantity::from("100.00"),
590 Quantity::from("101.00"),
591 UnixNanos::from(1_000_000_000),
592 UnixNanos::from(2_000_000_000),
593 );
594
595 let mid_size = quote.extract_size(PriceType::Mid);
596
597 assert_eq!(mid_size, Quantity::from("100.500"));
598 }
599
600 #[rstest]
601 fn test_to_string(quote_ethusdt_binance: QuoteTick) {
602 let quote = quote_ethusdt_binance;
603 assert_eq!(
604 quote.to_string(),
605 "ETHUSDT-PERP.BINANCE,10000.0000,10001.0000,1.00000000,1.00000000,0"
606 );
607 }
608}