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