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