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