1use std::{collections::HashMap, fmt::Display, hash::Hash};
19
20use derive_builder::Builder;
21use indexmap::IndexMap;
22use nautilus_core::{UnixNanos, correctness::FAILED, serialization::Serializable};
23use serde::{Deserialize, Serialize};
24
25use super::HasTsInit;
26use crate::{
27 enums::AggressorSide,
28 identifiers::{InstrumentId, TradeId},
29 types::{Price, Quantity, fixed::FIXED_SIZE_BINARY, quantity::check_positive_quantity},
30};
31
32#[repr(C)]
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Builder)]
35#[serde(tag = "type")]
36#[cfg_attr(
37 feature = "python",
38 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
39)]
40pub struct TradeTick {
41 pub instrument_id: InstrumentId,
43 pub price: Price,
45 pub size: Quantity,
47 pub aggressor_side: AggressorSide,
49 pub trade_id: TradeId,
51 pub ts_event: UnixNanos,
53 pub ts_init: UnixNanos,
55}
56
57impl TradeTick {
58 pub fn new_checked(
68 instrument_id: InstrumentId,
69 price: Price,
70 size: Quantity,
71 aggressor_side: AggressorSide,
72 trade_id: TradeId,
73 ts_event: UnixNanos,
74 ts_init: UnixNanos,
75 ) -> anyhow::Result<Self> {
76 check_positive_quantity(size, stringify!(size))?;
77
78 Ok(Self {
79 instrument_id,
80 price,
81 size,
82 aggressor_side,
83 trade_id,
84 ts_event,
85 ts_init,
86 })
87 }
88
89 #[must_use]
95 pub fn new(
96 instrument_id: InstrumentId,
97 price: Price,
98 size: Quantity,
99 aggressor_side: AggressorSide,
100 trade_id: TradeId,
101 ts_event: UnixNanos,
102 ts_init: UnixNanos,
103 ) -> Self {
104 Self::new_checked(
105 instrument_id,
106 price,
107 size,
108 aggressor_side,
109 trade_id,
110 ts_event,
111 ts_init,
112 )
113 .expect(FAILED)
114 }
115
116 #[must_use]
118 pub fn get_metadata(
119 instrument_id: &InstrumentId,
120 price_precision: u8,
121 size_precision: u8,
122 ) -> HashMap<String, String> {
123 let mut metadata = HashMap::new();
124 metadata.insert("instrument_id".to_string(), instrument_id.to_string());
125 metadata.insert("price_precision".to_string(), price_precision.to_string());
126 metadata.insert("size_precision".to_string(), size_precision.to_string());
127 metadata
128 }
129
130 #[must_use]
132 pub fn get_fields() -> IndexMap<String, String> {
133 let mut metadata = IndexMap::new();
134 metadata.insert("price".to_string(), FIXED_SIZE_BINARY.to_string());
135 metadata.insert("size".to_string(), FIXED_SIZE_BINARY.to_string());
136 metadata.insert("aggressor_side".to_string(), "UInt8".to_string());
137 metadata.insert("trade_id".to_string(), "Utf8".to_string());
138 metadata.insert("ts_event".to_string(), "UInt64".to_string());
139 metadata.insert("ts_init".to_string(), "UInt64".to_string());
140 metadata
141 }
142}
143
144impl Display for TradeTick {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 write!(
147 f,
148 "{},{},{},{},{},{}",
149 self.instrument_id,
150 self.price,
151 self.size,
152 self.aggressor_side,
153 self.trade_id,
154 self.ts_event,
155 )
156 }
157}
158
159impl Serializable for TradeTick {}
160
161impl HasTsInit for TradeTick {
162 fn ts_init(&self) -> UnixNanos {
163 self.ts_init
164 }
165}
166
167#[cfg(test)]
171mod tests {
172 use std::{
173 collections::hash_map::DefaultHasher,
174 hash::{Hash, Hasher},
175 };
176
177 use nautilus_core::UnixNanos;
178 use rstest::rstest;
179
180 use super::TradeTickBuilder;
181 use crate::{
182 data::{HasTsInit, TradeTick, stubs::stub_trade_ethusdt_buyer},
183 enums::AggressorSide,
184 identifiers::{InstrumentId, TradeId},
185 types::{Price, Quantity},
186 };
187
188 fn create_test_trade() -> TradeTick {
189 TradeTick::new(
190 InstrumentId::from("EURUSD.SIM"),
191 Price::from("1.0500"),
192 Quantity::from("100000"),
193 AggressorSide::Buyer,
194 TradeId::from("T-001"),
195 UnixNanos::from(1_000_000_000),
196 UnixNanos::from(2_000_000_000),
197 )
198 }
199
200 #[rstest]
201 fn test_trade_tick_new() {
202 let trade = create_test_trade();
203
204 assert_eq!(trade.instrument_id, InstrumentId::from("EURUSD.SIM"));
205 assert_eq!(trade.price, Price::from("1.0500"));
206 assert_eq!(trade.size, Quantity::from("100000"));
207 assert_eq!(trade.aggressor_side, AggressorSide::Buyer);
208 assert_eq!(trade.trade_id, TradeId::from("T-001"));
209 assert_eq!(trade.ts_event, UnixNanos::from(1_000_000_000));
210 assert_eq!(trade.ts_init, UnixNanos::from(2_000_000_000));
211 }
212
213 #[rstest]
214 fn test_trade_tick_new_checked_valid() {
215 let result = TradeTick::new_checked(
216 InstrumentId::from("GBPUSD.SIM"),
217 Price::from("1.2500"),
218 Quantity::from("50000"),
219 AggressorSide::Seller,
220 TradeId::from("T-002"),
221 UnixNanos::from(500_000_000),
222 UnixNanos::from(1_500_000_000),
223 );
224
225 assert!(result.is_ok());
226 let trade = result.unwrap();
227 assert_eq!(trade.instrument_id, InstrumentId::from("GBPUSD.SIM"));
228 assert_eq!(trade.price, Price::from("1.2500"));
229 assert_eq!(trade.aggressor_side, AggressorSide::Seller);
230 }
231
232 #[cfg(feature = "high-precision")] #[rstest]
234 #[should_panic(expected = "invalid `Quantity` for 'size' not positive, was 0")]
235 fn test_trade_tick_new_with_zero_size_panics() {
236 let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
237 let price = Price::from("10000.00");
238 let zero_size = Quantity::from(0);
239 let aggressor_side = AggressorSide::Buyer;
240 let trade_id = TradeId::from("123456789");
241 let ts_event = UnixNanos::from(0);
242 let ts_init = UnixNanos::from(1);
243
244 let _ = TradeTick::new(
245 instrument_id,
246 price,
247 zero_size,
248 aggressor_side,
249 trade_id,
250 ts_event,
251 ts_init,
252 );
253 }
254
255 #[rstest]
256 fn test_trade_tick_new_checked_with_zero_size_error() {
257 let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
258 let price = Price::from("10000.00");
259 let zero_size = Quantity::from(0);
260 let aggressor_side = AggressorSide::Buyer;
261 let trade_id = TradeId::from("123456789");
262 let ts_event = UnixNanos::from(0);
263 let ts_init = UnixNanos::from(1);
264
265 let result = TradeTick::new_checked(
266 instrument_id,
267 price,
268 zero_size,
269 aggressor_side,
270 trade_id,
271 ts_event,
272 ts_init,
273 );
274
275 assert!(result.is_err());
276 assert!(
277 result
278 .unwrap_err()
279 .to_string()
280 .contains("invalid `Quantity` for 'size' not positive")
281 );
282 }
283
284 #[rstest]
285 fn test_trade_tick_builder() {
286 let trade = TradeTickBuilder::default()
287 .instrument_id(InstrumentId::from("BTCUSD.CRYPTO"))
288 .price(Price::from("50000.00"))
289 .size(Quantity::from("0.50"))
290 .aggressor_side(AggressorSide::Seller)
291 .trade_id(TradeId::from("T-999"))
292 .ts_event(UnixNanos::from(3_000_000_000))
293 .ts_init(UnixNanos::from(4_000_000_000))
294 .build()
295 .unwrap();
296
297 assert_eq!(trade.instrument_id, InstrumentId::from("BTCUSD.CRYPTO"));
298 assert_eq!(trade.price, Price::from("50000.00"));
299 assert_eq!(trade.size, Quantity::from("0.50"));
300 assert_eq!(trade.aggressor_side, AggressorSide::Seller);
301 assert_eq!(trade.trade_id, TradeId::from("T-999"));
302 assert_eq!(trade.ts_event, UnixNanos::from(3_000_000_000));
303 assert_eq!(trade.ts_init, UnixNanos::from(4_000_000_000));
304 }
305
306 #[rstest]
307 fn test_get_metadata() {
308 let instrument_id = InstrumentId::from("EURUSD.SIM");
309 let metadata = TradeTick::get_metadata(&instrument_id, 5, 8);
310
311 assert_eq!(metadata.len(), 3);
312 assert_eq!(
313 metadata.get("instrument_id"),
314 Some(&"EURUSD.SIM".to_string())
315 );
316 assert_eq!(metadata.get("price_precision"), Some(&"5".to_string()));
317 assert_eq!(metadata.get("size_precision"), Some(&"8".to_string()));
318 }
319
320 #[rstest]
321 fn test_get_fields() {
322 let fields = TradeTick::get_fields();
323
324 assert_eq!(fields.len(), 6);
325
326 #[cfg(feature = "high-precision")]
327 {
328 assert_eq!(
329 fields.get("price"),
330 Some(&"FixedSizeBinary(16)".to_string())
331 );
332 assert_eq!(fields.get("size"), Some(&"FixedSizeBinary(16)".to_string()));
333 }
334 #[cfg(not(feature = "high-precision"))]
335 {
336 assert_eq!(fields.get("price"), Some(&"FixedSizeBinary(8)".to_string()));
337 assert_eq!(fields.get("size"), Some(&"FixedSizeBinary(8)".to_string()));
338 }
339
340 assert_eq!(fields.get("aggressor_side"), Some(&"UInt8".to_string()));
341 assert_eq!(fields.get("trade_id"), Some(&"Utf8".to_string()));
342 assert_eq!(fields.get("ts_event"), Some(&"UInt64".to_string()));
343 assert_eq!(fields.get("ts_init"), Some(&"UInt64".to_string()));
344 }
345
346 #[rstest]
347 #[case(AggressorSide::Buyer)]
348 #[case(AggressorSide::Seller)]
349 #[case(AggressorSide::NoAggressor)]
350 fn test_trade_tick_with_different_aggressor_sides(#[case] aggressor_side: AggressorSide) {
351 let trade = TradeTick::new(
352 InstrumentId::from("TEST.SIM"),
353 Price::from("100.00"),
354 Quantity::from("1000"),
355 aggressor_side,
356 TradeId::from("T-TEST"),
357 UnixNanos::from(1_000_000_000),
358 UnixNanos::from(2_000_000_000),
359 );
360
361 assert_eq!(trade.aggressor_side, aggressor_side);
362 }
363
364 #[rstest]
365 fn test_trade_tick_hash() {
366 let trade1 = create_test_trade();
367 let trade2 = create_test_trade();
368
369 let mut hasher1 = DefaultHasher::new();
370 let mut hasher2 = DefaultHasher::new();
371
372 trade1.hash(&mut hasher1);
373 trade2.hash(&mut hasher2);
374
375 assert_eq!(hasher1.finish(), hasher2.finish());
376 }
377
378 #[rstest]
379 fn test_trade_tick_hash_different_trades() {
380 let trade1 = create_test_trade();
381 let mut trade2 = create_test_trade();
382 trade2.price = Price::from("1.0501");
383
384 let mut hasher1 = DefaultHasher::new();
385 let mut hasher2 = DefaultHasher::new();
386
387 trade1.hash(&mut hasher1);
388 trade2.hash(&mut hasher2);
389
390 assert_ne!(hasher1.finish(), hasher2.finish());
391 }
392
393 #[rstest]
394 fn test_trade_tick_partial_eq() {
395 let trade1 = create_test_trade();
396 let trade2 = create_test_trade();
397 let mut trade3 = create_test_trade();
398 trade3.size = Quantity::from("80000");
399
400 assert_eq!(trade1, trade2);
401 assert_ne!(trade1, trade3);
402 }
403
404 #[rstest]
405 fn test_trade_tick_clone() {
406 let trade1 = create_test_trade();
407 let trade2 = trade1;
408
409 assert_eq!(trade1, trade2);
410 assert_eq!(trade1.instrument_id, trade2.instrument_id);
411 assert_eq!(trade1.price, trade2.price);
412 assert_eq!(trade1.size, trade2.size);
413 assert_eq!(trade1.aggressor_side, trade2.aggressor_side);
414 assert_eq!(trade1.trade_id, trade2.trade_id);
415 assert_eq!(trade1.ts_event, trade2.ts_event);
416 assert_eq!(trade1.ts_init, trade2.ts_init);
417 }
418
419 #[rstest]
420 fn test_trade_tick_debug() {
421 let trade = create_test_trade();
422 let debug_str = format!("{trade:?}");
423
424 assert!(debug_str.contains("TradeTick"));
425 assert!(debug_str.contains("EURUSD.SIM"));
426 assert!(debug_str.contains("1.0500"));
427 assert!(debug_str.contains("Buyer"));
428 assert!(debug_str.contains("T-001"));
429 }
430
431 #[rstest]
432 fn test_trade_tick_has_ts_init() {
433 let trade = create_test_trade();
434 assert_eq!(trade.ts_init(), UnixNanos::from(2_000_000_000));
435 }
436
437 #[rstest]
438 fn test_trade_tick_display() {
439 let trade = create_test_trade();
440 let display_str = format!("{trade}");
441
442 assert!(display_str.contains("EURUSD.SIM"));
443 assert!(display_str.contains("1.0500"));
444 assert!(display_str.contains("100000"));
445 assert!(display_str.contains("BUYER"));
446 assert!(display_str.contains("T-001"));
447 assert!(display_str.contains("1000000000"));
448 }
449
450 #[rstest]
451 fn test_trade_tick_serialization() {
452 let trade = create_test_trade();
453
454 let json = serde_json::to_string(&trade).unwrap();
455 let deserialized: TradeTick = serde_json::from_str(&json).unwrap();
456
457 assert_eq!(trade, deserialized);
458 }
459
460 #[rstest]
461 fn test_trade_tick_with_zero_price() {
462 let trade = TradeTick::new(
463 InstrumentId::from("TEST.SIM"),
464 Price::from("0.0000"),
465 Quantity::from("1000.0000"),
466 AggressorSide::Buyer,
467 TradeId::from("T-ZERO"),
468 UnixNanos::from(0),
469 UnixNanos::from(0),
470 );
471
472 assert!(trade.price.is_zero());
473 assert_eq!(trade.ts_event, UnixNanos::from(0));
474 assert_eq!(trade.ts_init, UnixNanos::from(0));
475 }
476
477 #[rstest]
478 fn test_trade_tick_with_max_values() {
479 let trade = TradeTick::new(
480 InstrumentId::from("TEST.SIM"),
481 Price::from("999999.9999"),
482 Quantity::from("999999999.9999"),
483 AggressorSide::Seller,
484 TradeId::from("T-MAX"),
485 UnixNanos::from(u64::MAX),
486 UnixNanos::from(u64::MAX),
487 );
488
489 assert_eq!(trade.ts_event, UnixNanos::from(u64::MAX));
490 assert_eq!(trade.ts_init, UnixNanos::from(u64::MAX));
491 }
492
493 #[rstest]
494 fn test_trade_tick_with_different_trade_ids() {
495 let trade1 = TradeTick::new(
496 InstrumentId::from("TEST.SIM"),
497 Price::from("100.00"),
498 Quantity::from("1000"),
499 AggressorSide::Buyer,
500 TradeId::from("TRADE-123"),
501 UnixNanos::from(1_000_000_000),
502 UnixNanos::from(2_000_000_000),
503 );
504
505 let trade2 = TradeTick::new(
506 InstrumentId::from("TEST.SIM"),
507 Price::from("100.00"),
508 Quantity::from("1000"),
509 AggressorSide::Buyer,
510 TradeId::from("TRADE-456"),
511 UnixNanos::from(1_000_000_000),
512 UnixNanos::from(2_000_000_000),
513 );
514
515 assert_ne!(trade1.trade_id, trade2.trade_id);
516 assert_ne!(trade1, trade2);
517 }
518
519 #[rstest]
520 fn test_to_string(stub_trade_ethusdt_buyer: TradeTick) {
521 let trade = stub_trade_ethusdt_buyer;
522 assert_eq!(
523 trade.to_string(),
524 "ETHUSDT-PERP.BINANCE,10000.0000,1.00000000,BUYER,123456789,0"
525 );
526 }
527
528 #[rstest]
529 fn test_deserialize_raw_string() {
530 let raw_string = r#"{
531 "type": "TradeTick",
532 "instrument_id": "ETHUSDT-PERP.BINANCE",
533 "price": "10000.0000",
534 "size": "1.00000000",
535 "aggressor_side": "BUYER",
536 "trade_id": "123456789",
537 "ts_event": 0,
538 "ts_init": 1
539 }"#;
540
541 let trade: TradeTick = serde_json::from_str(raw_string).unwrap();
542
543 assert_eq!(trade.aggressor_side, AggressorSide::Buyer);
544 assert_eq!(
545 trade.instrument_id,
546 InstrumentId::from("ETHUSDT-PERP.BINANCE")
547 );
548 assert_eq!(trade.price, Price::from("10000.0000"));
549 assert_eq!(trade.size, Quantity::from("1.00000000"));
550 assert_eq!(trade.trade_id, TradeId::from("123456789"));
551 }
552
553 #[cfg(feature = "python")]
554 #[rstest]
555 fn test_from_pyobject(stub_trade_ethusdt_buyer: TradeTick) {
556 use pyo3::{IntoPyObjectExt, Python};
557
558 pyo3::prepare_freethreaded_python();
559 let trade = stub_trade_ethusdt_buyer;
560
561 Python::with_gil(|py| {
562 let tick_pyobject = trade.into_py_any(py).unwrap();
563 let parsed_tick = TradeTick::from_pyobject(tick_pyobject.bind(py)).unwrap();
564 assert_eq!(parsed_tick, trade);
565 });
566 }
567}