Skip to main content

nautilus_model/data/
trade.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 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", 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    /// The trade instrument ID.
46    pub instrument_id: InstrumentId,
47    /// The traded price.
48    pub price: Price,
49    /// The traded size.
50    pub size: Quantity,
51    /// The trade aggressor side.
52    pub aggressor_side: AggressorSide,
53    /// The trade match ID (assigned by the venue).
54    pub trade_id: TradeId,
55    /// UNIX timestamp (nanoseconds) when the trade event occurred.
56    pub ts_event: UnixNanos,
57    /// UNIX timestamp (nanoseconds) when the instance was created.
58    pub ts_init: UnixNanos,
59}
60
61impl TradeTick {
62    /// Creates a new [`TradeTick`] instance with correctness checking.
63    ///
64    /// # Errors
65    ///
66    /// Returns an error if `size` is not positive (> 0).
67    ///
68    /// # Notes
69    ///
70    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
71    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    /// Creates a new [`TradeTick`] instance.
94    ///
95    /// # Panics
96    ///
97    /// Panics if `size` is not positive (> 0).
98    #[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    /// Returns the metadata for the type, for use with serialization formats.
121    #[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    /// Returns the field map for the type, for use with Arrow schemas.
135    #[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")] // TODO: Add 64-bit precision version of test
234    #[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}