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)]
224mod tests {
225
226 use nautilus_core::UnixNanos;
227 use rstest::rstest;
228
229 use super::QuoteTickBuilder;
230 use crate::{
231 data::{HasTsInit, QuoteTick, stubs::quote_ethusdt_binance},
232 enums::PriceType,
233 identifiers::InstrumentId,
234 types::{Price, Quantity},
235 };
236
237 fn create_test_quote() -> QuoteTick {
238 QuoteTick::new(
239 InstrumentId::from("EURUSD.SIM"),
240 Price::from("1.0500"),
241 Price::from("1.0505"),
242 Quantity::from("100000"),
243 Quantity::from("75000"),
244 UnixNanos::from(1_000_000_000),
245 UnixNanos::from(2_000_000_000),
246 )
247 }
248
249 #[rstest]
250 fn test_quote_tick_new() {
251 let quote = create_test_quote();
252
253 assert_eq!(quote.instrument_id, InstrumentId::from("EURUSD.SIM"));
254 assert_eq!(quote.bid_price, Price::from("1.0500"));
255 assert_eq!(quote.ask_price, Price::from("1.0505"));
256 assert_eq!(quote.bid_size, Quantity::from("100000"));
257 assert_eq!(quote.ask_size, Quantity::from("75000"));
258 assert_eq!(quote.ts_event, UnixNanos::from(1_000_000_000));
259 assert_eq!(quote.ts_init, UnixNanos::from(2_000_000_000));
260 }
261
262 #[rstest]
263 fn test_quote_tick_new_checked_valid() {
264 let result = QuoteTick::new_checked(
265 InstrumentId::from("GBPUSD.SIM"),
266 Price::from("1.2500"),
267 Price::from("1.2505"),
268 Quantity::from("50000"),
269 Quantity::from("60000"),
270 UnixNanos::from(500_000_000),
271 UnixNanos::from(1_500_000_000),
272 );
273
274 assert!(result.is_ok());
275 let quote = result.unwrap();
276 assert_eq!(quote.instrument_id, InstrumentId::from("GBPUSD.SIM"));
277 assert_eq!(quote.bid_price, Price::from("1.2500"));
278 assert_eq!(quote.ask_price, Price::from("1.2505"));
279 }
280
281 #[rstest]
282 #[should_panic(
283 expected = "'bid_price.precision' u8 of 4 was not equal to 'ask_price.precision' u8 of 5"
284 )]
285 fn test_quote_tick_new_with_precision_mismatch_panics() {
286 let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
287 let bid_price = Price::from("10000.0000"); let ask_price = Price::from("10000.00100"); let bid_size = Quantity::from("1.000000");
290 let ask_size = Quantity::from("1.000000");
291 let ts_event = UnixNanos::from(0);
292 let ts_init = UnixNanos::from(1);
293
294 let _ = QuoteTick::new(
295 instrument_id,
296 bid_price,
297 ask_price,
298 bid_size,
299 ask_size,
300 ts_event,
301 ts_init,
302 );
303 }
304
305 #[rstest]
306 fn test_quote_tick_new_checked_with_precision_mismatch_error() {
307 let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
308 let bid_price = Price::from("10000.0000");
309 let ask_price = Price::from("10000.0010");
310 let bid_size = Quantity::from("10.000000"); let ask_size = Quantity::from("10.0000000"); let ts_event = UnixNanos::from(0);
313 let ts_init = UnixNanos::from(1);
314
315 let result = QuoteTick::new_checked(
316 instrument_id,
317 bid_price,
318 ask_price,
319 bid_size,
320 ask_size,
321 ts_event,
322 ts_init,
323 );
324
325 assert!(result.is_err());
326 assert!(result.unwrap_err().to_string().contains(
327 "'bid_size.precision' u8 of 6 was not equal to 'ask_size.precision' u8 of 7"
328 ));
329 }
330
331 #[rstest]
332 fn test_quote_tick_builder() {
333 let quote = QuoteTickBuilder::default()
334 .instrument_id(InstrumentId::from("BTCUSD.CRYPTO"))
335 .bid_price(Price::from("50000.00"))
336 .ask_price(Price::from("50001.00"))
337 .bid_size(Quantity::from("0.50"))
338 .ask_size(Quantity::from("0.75"))
339 .ts_event(UnixNanos::from(3_000_000_000))
340 .ts_init(UnixNanos::from(4_000_000_000))
341 .build()
342 .unwrap();
343
344 assert_eq!(quote.instrument_id, InstrumentId::from("BTCUSD.CRYPTO"));
345 assert_eq!(quote.bid_price, Price::from("50000.00"));
346 assert_eq!(quote.ask_price, Price::from("50001.00"));
347 assert_eq!(quote.bid_size, Quantity::from("0.50"));
348 assert_eq!(quote.ask_size, Quantity::from("0.75"));
349 assert_eq!(quote.ts_event, UnixNanos::from(3_000_000_000));
350 assert_eq!(quote.ts_init, UnixNanos::from(4_000_000_000));
351 }
352
353 #[rstest]
354 fn test_get_metadata() {
355 let instrument_id = InstrumentId::from("EURUSD.SIM");
356 let metadata = QuoteTick::get_metadata(&instrument_id, 5, 8);
357
358 assert_eq!(metadata.len(), 3);
359 assert_eq!(
360 metadata.get("instrument_id"),
361 Some(&"EURUSD.SIM".to_string())
362 );
363 assert_eq!(metadata.get("price_precision"), Some(&"5".to_string()));
364 assert_eq!(metadata.get("size_precision"), Some(&"8".to_string()));
365 }
366
367 #[rstest]
368 fn test_get_fields() {
369 let fields = QuoteTick::get_fields();
370
371 assert_eq!(fields.len(), 6);
372
373 #[cfg(feature = "high-precision")]
374 {
375 assert_eq!(
376 fields.get("bid_price"),
377 Some(&"FixedSizeBinary(16)".to_string())
378 );
379 assert_eq!(
380 fields.get("ask_price"),
381 Some(&"FixedSizeBinary(16)".to_string())
382 );
383 assert_eq!(
384 fields.get("bid_size"),
385 Some(&"FixedSizeBinary(16)".to_string())
386 );
387 assert_eq!(
388 fields.get("ask_size"),
389 Some(&"FixedSizeBinary(16)".to_string())
390 );
391 }
392 #[cfg(not(feature = "high-precision"))]
393 {
394 assert_eq!(
395 fields.get("bid_price"),
396 Some(&"FixedSizeBinary(8)".to_string())
397 );
398 assert_eq!(
399 fields.get("ask_price"),
400 Some(&"FixedSizeBinary(8)".to_string())
401 );
402 assert_eq!(
403 fields.get("bid_size"),
404 Some(&"FixedSizeBinary(8)".to_string())
405 );
406 assert_eq!(
407 fields.get("ask_size"),
408 Some(&"FixedSizeBinary(8)".to_string())
409 );
410 }
411
412 assert_eq!(fields.get("ts_event"), Some(&"UInt64".to_string()));
413 assert_eq!(fields.get("ts_init"), Some(&"UInt64".to_string()));
414 }
415
416 #[rstest]
417 #[case(PriceType::Bid, Price::from("10000.0000"))]
418 #[case(PriceType::Ask, Price::from("10001.0000"))]
419 #[case(PriceType::Mid, Price::from("10000.5000"))]
420 fn test_extract_price(
421 #[case] input: PriceType,
422 #[case] expected: Price,
423 quote_ethusdt_binance: QuoteTick,
424 ) {
425 let quote = quote_ethusdt_binance;
426 let result = quote.extract_price(input);
427 assert_eq!(result, expected);
428 }
429
430 #[rstest]
431 #[case(PriceType::Bid, Quantity::from("1.00000000"))]
432 #[case(PriceType::Ask, Quantity::from("1.00000000"))]
433 #[case(PriceType::Mid, Quantity::from("1.00000000"))]
434 fn test_extract_size(
435 #[case] input: PriceType,
436 #[case] expected: Quantity,
437 quote_ethusdt_binance: QuoteTick,
438 ) {
439 let quote = quote_ethusdt_binance;
440 let result = quote.extract_size(input);
441 assert_eq!(result, expected);
442 }
443
444 #[rstest]
445 #[should_panic(expected = "Cannot extract with price type LAST")]
446 fn test_extract_price_invalid_type() {
447 let quote = create_test_quote();
448 let _ = quote.extract_price(PriceType::Last);
449 }
450
451 #[rstest]
452 #[should_panic(expected = "Cannot extract with price type LAST")]
453 fn test_extract_size_invalid_type() {
454 let quote = create_test_quote();
455 let _ = quote.extract_size(PriceType::Last);
456 }
457
458 #[rstest]
459 fn test_quote_tick_has_ts_init() {
460 let quote = create_test_quote();
461 assert_eq!(quote.ts_init(), UnixNanos::from(2_000_000_000));
462 }
463
464 #[rstest]
465 fn test_quote_tick_display() {
466 let quote = create_test_quote();
467 let display_str = format!("{quote}");
468
469 assert!(display_str.contains("EURUSD.SIM"));
470 assert!(display_str.contains("1.0500"));
471 assert!(display_str.contains("1.0505"));
472 assert!(display_str.contains("100000"));
473 assert!(display_str.contains("75000"));
474 assert!(display_str.contains("1000000000"));
475 }
476
477 #[rstest]
478 fn test_quote_tick_with_zero_prices() {
479 let quote = QuoteTick::new(
480 InstrumentId::from("TEST.SIM"),
481 Price::from("0.0000"),
482 Price::from("0.0000"),
483 Quantity::from("1000.0000"),
484 Quantity::from("1000.0000"),
485 UnixNanos::from(0),
486 UnixNanos::from(0),
487 );
488
489 assert!(quote.bid_price.is_zero());
490 assert!(quote.ask_price.is_zero());
491 assert_eq!(quote.ts_event, UnixNanos::from(0));
492 assert_eq!(quote.ts_init, UnixNanos::from(0));
493 }
494
495 #[rstest]
496 fn test_quote_tick_with_max_values() {
497 let quote = QuoteTick::new(
498 InstrumentId::from("TEST.SIM"),
499 Price::from("999999.9999"),
500 Price::from("999999.9999"),
501 Quantity::from("999999999.9999"),
502 Quantity::from("999999999.9999"),
503 UnixNanos::from(u64::MAX),
504 UnixNanos::from(u64::MAX),
505 );
506
507 assert_eq!(quote.ts_event, UnixNanos::from(u64::MAX));
508 assert_eq!(quote.ts_init, UnixNanos::from(u64::MAX));
509 }
510
511 #[rstest]
512 fn test_extract_mid_price_precision() {
513 let quote = QuoteTick::new(
514 InstrumentId::from("TEST.SIM"),
515 Price::from("1.00"),
516 Price::from("1.02"),
517 Quantity::from("100.00"),
518 Quantity::from("100.00"),
519 UnixNanos::from(1_000_000_000),
520 UnixNanos::from(2_000_000_000),
521 );
522
523 let mid_price = quote.extract_price(PriceType::Mid);
524 let mid_size = quote.extract_size(PriceType::Mid);
525
526 assert_eq!(mid_price, Price::from("1.010"));
527 assert_eq!(mid_size, Quantity::from("100.000"));
528 }
529
530 #[rstest]
531 fn test_to_string(quote_ethusdt_binance: QuoteTick) {
532 let quote = quote_ethusdt_binance;
533 assert_eq!(
534 quote.to_string(),
535 "ETHUSDT-PERP.BINANCE,10000.0000,10001.0000,1.00000000,1.00000000,0"
536 );
537 }
538}