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////////////////////////////////////////////////////////////////////////////////
168// Tests
169////////////////////////////////////////////////////////////////////////////////
170#[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")] // TODO: Add 64-bit precision version of test
233    #[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}