nautilus_model/data/
trade.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! A `TradeTick` data type representing a single trade in a market.
17
18use 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/// Represents a trade tick in a market.
33#[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    /// The trade instrument ID.
42    pub instrument_id: InstrumentId,
43    /// The traded price.
44    pub price: Price,
45    /// The traded size.
46    pub size: Quantity,
47    /// The trade aggressor side.
48    pub aggressor_side: AggressorSide,
49    /// The trade match ID (assigned by the venue).
50    pub trade_id: TradeId,
51    /// UNIX timestamp (nanoseconds) when the trade event occurred.
52    pub ts_event: UnixNanos,
53    /// UNIX timestamp (nanoseconds) when the instance was created.
54    pub ts_init: UnixNanos,
55}
56
57impl TradeTick {
58    /// Creates a new [`TradeTick`] instance with correctness checking.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if `size` is not positive (> 0).
63    ///
64    /// # Notes
65    ///
66    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
67    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    /// Creates a new [`TradeTick`] instance.
90    ///
91    /// # Panics
92    ///
93    /// Panics if `size` is not positive (> 0).
94    #[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    /// Returns the metadata for the type, for use with serialization formats.
117    #[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    /// Returns the field map for the type, for use with Arrow schemas.
131    #[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")] // TODO: Add 64-bit precision version of test
230    #[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}