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