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::{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/// 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.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_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}