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