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)]
168mod tests {
169 use std::{
170 collections::hash_map::DefaultHasher,
171 hash::{Hash, Hasher},
172 };
173
174 use nautilus_core::UnixNanos;
175 use rstest::rstest;
176
177 use super::TradeTickBuilder;
178 use crate::{
179 data::{HasTsInit, TradeTick, stubs::stub_trade_ethusdt_buyer},
180 enums::AggressorSide,
181 identifiers::{InstrumentId, TradeId},
182 types::{Price, Quantity},
183 };
184
185 fn create_test_trade() -> TradeTick {
186 TradeTick::new(
187 InstrumentId::from("EURUSD.SIM"),
188 Price::from("1.0500"),
189 Quantity::from("100000"),
190 AggressorSide::Buyer,
191 TradeId::from("T-001"),
192 UnixNanos::from(1_000_000_000),
193 UnixNanos::from(2_000_000_000),
194 )
195 }
196
197 #[rstest]
198 fn test_trade_tick_new() {
199 let trade = create_test_trade();
200
201 assert_eq!(trade.instrument_id, InstrumentId::from("EURUSD.SIM"));
202 assert_eq!(trade.price, Price::from("1.0500"));
203 assert_eq!(trade.size, Quantity::from("100000"));
204 assert_eq!(trade.aggressor_side, AggressorSide::Buyer);
205 assert_eq!(trade.trade_id, TradeId::from("T-001"));
206 assert_eq!(trade.ts_event, UnixNanos::from(1_000_000_000));
207 assert_eq!(trade.ts_init, UnixNanos::from(2_000_000_000));
208 }
209
210 #[rstest]
211 fn test_trade_tick_new_checked_valid() {
212 let result = TradeTick::new_checked(
213 InstrumentId::from("GBPUSD.SIM"),
214 Price::from("1.2500"),
215 Quantity::from("50000"),
216 AggressorSide::Seller,
217 TradeId::from("T-002"),
218 UnixNanos::from(500_000_000),
219 UnixNanos::from(1_500_000_000),
220 );
221
222 assert!(result.is_ok());
223 let trade = result.unwrap();
224 assert_eq!(trade.instrument_id, InstrumentId::from("GBPUSD.SIM"));
225 assert_eq!(trade.price, Price::from("1.2500"));
226 assert_eq!(trade.aggressor_side, AggressorSide::Seller);
227 }
228
229 #[cfg(feature = "high-precision")] #[rstest]
231 #[should_panic(expected = "invalid `Quantity` for 'size' not positive, was 0")]
232 fn test_trade_tick_new_with_zero_size_panics() {
233 let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
234 let price = Price::from("10000.00");
235 let zero_size = Quantity::from(0);
236 let aggressor_side = AggressorSide::Buyer;
237 let trade_id = TradeId::from("123456789");
238 let ts_event = UnixNanos::from(0);
239 let ts_init = UnixNanos::from(1);
240
241 let _ = TradeTick::new(
242 instrument_id,
243 price,
244 zero_size,
245 aggressor_side,
246 trade_id,
247 ts_event,
248 ts_init,
249 );
250 }
251
252 #[rstest]
253 fn test_trade_tick_new_checked_with_zero_size_error() {
254 let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
255 let price = Price::from("10000.00");
256 let zero_size = Quantity::from(0);
257 let aggressor_side = AggressorSide::Buyer;
258 let trade_id = TradeId::from("123456789");
259 let ts_event = UnixNanos::from(0);
260 let ts_init = UnixNanos::from(1);
261
262 let result = TradeTick::new_checked(
263 instrument_id,
264 price,
265 zero_size,
266 aggressor_side,
267 trade_id,
268 ts_event,
269 ts_init,
270 );
271
272 assert!(result.is_err());
273 assert!(
274 result
275 .unwrap_err()
276 .to_string()
277 .contains("invalid `Quantity` for 'size' not positive")
278 );
279 }
280
281 #[rstest]
282 fn test_trade_tick_builder() {
283 let trade = TradeTickBuilder::default()
284 .instrument_id(InstrumentId::from("BTCUSD.CRYPTO"))
285 .price(Price::from("50000.00"))
286 .size(Quantity::from("0.50"))
287 .aggressor_side(AggressorSide::Seller)
288 .trade_id(TradeId::from("T-999"))
289 .ts_event(UnixNanos::from(3_000_000_000))
290 .ts_init(UnixNanos::from(4_000_000_000))
291 .build()
292 .unwrap();
293
294 assert_eq!(trade.instrument_id, InstrumentId::from("BTCUSD.CRYPTO"));
295 assert_eq!(trade.price, Price::from("50000.00"));
296 assert_eq!(trade.size, Quantity::from("0.50"));
297 assert_eq!(trade.aggressor_side, AggressorSide::Seller);
298 assert_eq!(trade.trade_id, TradeId::from("T-999"));
299 assert_eq!(trade.ts_event, UnixNanos::from(3_000_000_000));
300 assert_eq!(trade.ts_init, UnixNanos::from(4_000_000_000));
301 }
302
303 #[rstest]
304 fn test_get_metadata() {
305 let instrument_id = InstrumentId::from("EURUSD.SIM");
306 let metadata = TradeTick::get_metadata(&instrument_id, 5, 8);
307
308 assert_eq!(metadata.len(), 3);
309 assert_eq!(
310 metadata.get("instrument_id"),
311 Some(&"EURUSD.SIM".to_string())
312 );
313 assert_eq!(metadata.get("price_precision"), Some(&"5".to_string()));
314 assert_eq!(metadata.get("size_precision"), Some(&"8".to_string()));
315 }
316
317 #[rstest]
318 fn test_get_fields() {
319 let fields = TradeTick::get_fields();
320
321 assert_eq!(fields.len(), 6);
322
323 #[cfg(feature = "high-precision")]
324 {
325 assert_eq!(
326 fields.get("price"),
327 Some(&"FixedSizeBinary(16)".to_string())
328 );
329 assert_eq!(fields.get("size"), Some(&"FixedSizeBinary(16)".to_string()));
330 }
331 #[cfg(not(feature = "high-precision"))]
332 {
333 assert_eq!(fields.get("price"), Some(&"FixedSizeBinary(8)".to_string()));
334 assert_eq!(fields.get("size"), Some(&"FixedSizeBinary(8)".to_string()));
335 }
336
337 assert_eq!(fields.get("aggressor_side"), Some(&"UInt8".to_string()));
338 assert_eq!(fields.get("trade_id"), Some(&"Utf8".to_string()));
339 assert_eq!(fields.get("ts_event"), Some(&"UInt64".to_string()));
340 assert_eq!(fields.get("ts_init"), Some(&"UInt64".to_string()));
341 }
342
343 #[rstest]
344 #[case(AggressorSide::Buyer)]
345 #[case(AggressorSide::Seller)]
346 #[case(AggressorSide::NoAggressor)]
347 fn test_trade_tick_with_different_aggressor_sides(#[case] aggressor_side: AggressorSide) {
348 let trade = TradeTick::new(
349 InstrumentId::from("TEST.SIM"),
350 Price::from("100.00"),
351 Quantity::from("1000"),
352 aggressor_side,
353 TradeId::from("T-TEST"),
354 UnixNanos::from(1_000_000_000),
355 UnixNanos::from(2_000_000_000),
356 );
357
358 assert_eq!(trade.aggressor_side, aggressor_side);
359 }
360
361 #[rstest]
362 fn test_trade_tick_hash() {
363 let trade1 = create_test_trade();
364 let trade2 = create_test_trade();
365
366 let mut hasher1 = DefaultHasher::new();
367 let mut hasher2 = DefaultHasher::new();
368
369 trade1.hash(&mut hasher1);
370 trade2.hash(&mut hasher2);
371
372 assert_eq!(hasher1.finish(), hasher2.finish());
373 }
374
375 #[rstest]
376 fn test_trade_tick_hash_different_trades() {
377 let trade1 = create_test_trade();
378 let mut trade2 = create_test_trade();
379 trade2.price = Price::from("1.0501");
380
381 let mut hasher1 = DefaultHasher::new();
382 let mut hasher2 = DefaultHasher::new();
383
384 trade1.hash(&mut hasher1);
385 trade2.hash(&mut hasher2);
386
387 assert_ne!(hasher1.finish(), hasher2.finish());
388 }
389
390 #[rstest]
391 fn test_trade_tick_partial_eq() {
392 let trade1 = create_test_trade();
393 let trade2 = create_test_trade();
394 let mut trade3 = create_test_trade();
395 trade3.size = Quantity::from("80000");
396
397 assert_eq!(trade1, trade2);
398 assert_ne!(trade1, trade3);
399 }
400
401 #[rstest]
402 fn test_trade_tick_clone() {
403 let trade1 = create_test_trade();
404 let trade2 = trade1;
405
406 assert_eq!(trade1, trade2);
407 assert_eq!(trade1.instrument_id, trade2.instrument_id);
408 assert_eq!(trade1.price, trade2.price);
409 assert_eq!(trade1.size, trade2.size);
410 assert_eq!(trade1.aggressor_side, trade2.aggressor_side);
411 assert_eq!(trade1.trade_id, trade2.trade_id);
412 assert_eq!(trade1.ts_event, trade2.ts_event);
413 assert_eq!(trade1.ts_init, trade2.ts_init);
414 }
415
416 #[rstest]
417 fn test_trade_tick_debug() {
418 let trade = create_test_trade();
419 let debug_str = format!("{trade:?}");
420
421 assert!(debug_str.contains("TradeTick"));
422 assert!(debug_str.contains("EURUSD.SIM"));
423 assert!(debug_str.contains("1.0500"));
424 assert!(debug_str.contains("Buyer"));
425 assert!(debug_str.contains("T-001"));
426 }
427
428 #[rstest]
429 fn test_trade_tick_has_ts_init() {
430 let trade = create_test_trade();
431 assert_eq!(trade.ts_init(), UnixNanos::from(2_000_000_000));
432 }
433
434 #[rstest]
435 fn test_trade_tick_display() {
436 let trade = create_test_trade();
437 let display_str = format!("{trade}");
438
439 assert!(display_str.contains("EURUSD.SIM"));
440 assert!(display_str.contains("1.0500"));
441 assert!(display_str.contains("100000"));
442 assert!(display_str.contains("BUYER"));
443 assert!(display_str.contains("T-001"));
444 assert!(display_str.contains("1000000000"));
445 }
446
447 #[rstest]
448 fn test_trade_tick_serialization() {
449 let trade = create_test_trade();
450
451 let json = serde_json::to_string(&trade).unwrap();
452 let deserialized: TradeTick = serde_json::from_str(&json).unwrap();
453
454 assert_eq!(trade, deserialized);
455 }
456
457 #[rstest]
458 fn test_trade_tick_with_zero_price() {
459 let trade = TradeTick::new(
460 InstrumentId::from("TEST.SIM"),
461 Price::from("0.0000"),
462 Quantity::from("1000.0000"),
463 AggressorSide::Buyer,
464 TradeId::from("T-ZERO"),
465 UnixNanos::from(0),
466 UnixNanos::from(0),
467 );
468
469 assert!(trade.price.is_zero());
470 assert_eq!(trade.ts_event, UnixNanos::from(0));
471 assert_eq!(trade.ts_init, UnixNanos::from(0));
472 }
473
474 #[rstest]
475 fn test_trade_tick_with_max_values() {
476 let trade = TradeTick::new(
477 InstrumentId::from("TEST.SIM"),
478 Price::from("999999.9999"),
479 Quantity::from("999999999.9999"),
480 AggressorSide::Seller,
481 TradeId::from("T-MAX"),
482 UnixNanos::from(u64::MAX),
483 UnixNanos::from(u64::MAX),
484 );
485
486 assert_eq!(trade.ts_event, UnixNanos::from(u64::MAX));
487 assert_eq!(trade.ts_init, UnixNanos::from(u64::MAX));
488 }
489
490 #[rstest]
491 fn test_trade_tick_with_different_trade_ids() {
492 let trade1 = TradeTick::new(
493 InstrumentId::from("TEST.SIM"),
494 Price::from("100.00"),
495 Quantity::from("1000"),
496 AggressorSide::Buyer,
497 TradeId::from("TRADE-123"),
498 UnixNanos::from(1_000_000_000),
499 UnixNanos::from(2_000_000_000),
500 );
501
502 let trade2 = TradeTick::new(
503 InstrumentId::from("TEST.SIM"),
504 Price::from("100.00"),
505 Quantity::from("1000"),
506 AggressorSide::Buyer,
507 TradeId::from("TRADE-456"),
508 UnixNanos::from(1_000_000_000),
509 UnixNanos::from(2_000_000_000),
510 );
511
512 assert_ne!(trade1.trade_id, trade2.trade_id);
513 assert_ne!(trade1, trade2);
514 }
515
516 #[rstest]
517 fn test_to_string(stub_trade_ethusdt_buyer: TradeTick) {
518 let trade = stub_trade_ethusdt_buyer;
519 assert_eq!(
520 trade.to_string(),
521 "ETHUSDT-PERP.BINANCE,10000.0000,1.00000000,BUYER,123456789,0"
522 );
523 }
524
525 #[rstest]
526 fn test_deserialize_raw_string() {
527 let raw_string = r#"{
528 "type": "TradeTick",
529 "instrument_id": "ETHUSDT-PERP.BINANCE",
530 "price": "10000.0000",
531 "size": "1.00000000",
532 "aggressor_side": "BUYER",
533 "trade_id": "123456789",
534 "ts_event": 0,
535 "ts_init": 1
536 }"#;
537
538 let trade: TradeTick = serde_json::from_str(raw_string).unwrap();
539
540 assert_eq!(trade.aggressor_side, AggressorSide::Buyer);
541 assert_eq!(
542 trade.instrument_id,
543 InstrumentId::from("ETHUSDT-PERP.BINANCE")
544 );
545 assert_eq!(trade.price, Price::from("10000.0000"));
546 assert_eq!(trade.size, Quantity::from("1.00000000"));
547 assert_eq!(trade.trade_id, TradeId::from("123456789"));
548 }
549
550 #[cfg(feature = "python")]
551 #[rstest]
552 fn test_from_pyobject(stub_trade_ethusdt_buyer: TradeTick) {
553 use pyo3::{IntoPyObjectExt, Python};
554
555 let trade = stub_trade_ethusdt_buyer;
556
557 Python::initialize();
558 Python::attach(|py| {
559 let tick_pyobject = trade.into_py_any(py).unwrap();
560 let parsed_tick = TradeTick::from_pyobject(tick_pyobject.bind(py)).unwrap();
561 assert_eq!(parsed_tick, trade);
562 });
563 }
564}