nautilus_model/identifiers/
trade_id.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//! Represents a valid trade match ID (assigned by a trading venue).
17
18use std::{
19    ffi::CStr,
20    fmt::{Debug, Display, Formatter},
21    hash::Hash,
22};
23
24use nautilus_core::correctness::{
25    FAILED, check_predicate_false, check_predicate_true, check_slice_not_empty,
26};
27use serde::{Deserialize, Deserializer, Serialize};
28
29/// The maximum length of ASCII characters for a `TradeId` string value (including null terminator).
30pub const TRADE_ID_LEN: usize = 37;
31
32/// Represents a valid trade match ID (assigned by a trading venue).
33///
34/// The unique ID assigned to the trade entity once it is received or matched by
35/// the venue or central counterparty.
36///
37/// Can correspond to the `TradeID <1003> field` of the FIX protocol.
38///
39/// Maximum length is 36 characters.
40#[repr(C)]
41#[derive(Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
42#[cfg_attr(
43    feature = "python",
44    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
45)]
46pub struct TradeId {
47    /// The trade match ID value as a fixed-length C string byte array (includes null terminator).
48    pub(crate) value: [u8; TRADE_ID_LEN],
49}
50
51impl TradeId {
52    /// Creates a new [`TradeId`] instance with correctness checking.
53    ///
54    /// Maximum length is 36 characters.
55    ///
56    /// # Errors
57    ///
58    /// Returns an error if:
59    /// - `value` is an invalid string (e.g., is empty or contains non-ASCII characters).
60    /// - `value` length exceeds 36 characters.
61    ///
62    /// # Notes
63    ///
64    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
65    pub fn new_checked<T: AsRef<str>>(value: T) -> anyhow::Result<Self> {
66        Self::from_bytes(value.as_ref().as_bytes())
67    }
68
69    /// Creates a new [`TradeId`] instance.
70    ///
71    /// Maximum length is 36 characters.
72    ///
73    /// # Panics
74    ///
75    /// This function panics if:
76    /// - `value` is an invalid string (e.g., is empty or contains non-ASCII characters).
77    /// - `value` length exceeds 36 characters.
78    pub fn new<T: AsRef<str>>(value: T) -> Self {
79        Self::new_checked(value).expect(FAILED)
80    }
81
82    /// Creates a new [`TradeId`] instance.
83    ///
84    /// Maximum length is 36 characters plus a null terminator byte.
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if `value` is empty, contains non-ASCII characters, or exceeds max length.
89    ///
90    /// # Panics
91    ///
92    /// This function panics if:
93    /// - `value` is empty or consists only of a single null byte.
94    /// - `value` exceeds 36 bytes and does not end with a null byte.
95    /// - `value` is exactly 37 bytes but the last byte is not null.
96    /// - `value` contains non-ASCII characters.
97    pub fn from_bytes(value: &[u8]) -> anyhow::Result<Self> {
98        check_slice_not_empty(value, "value")?;
99
100        // Check for non-ASCII characters and capture last byte in single pass
101        let mut last_byte = 0;
102        let all_ascii = value
103            .iter()
104            .inspect(|&&b| last_byte = b)
105            .all(|&b| b.is_ascii());
106
107        check_predicate_true(all_ascii, "'value' contains non-ASCII characters")?;
108        check_predicate_false(
109            value.len() == 1 && last_byte == 0,
110            "'value' was single null byte",
111        )?;
112        check_predicate_true(
113            value.len() <= 36 || (value.len() == 37 && last_byte == 0),
114            "'value' exceeds max length or invalid format",
115        )?;
116
117        let mut buf = [0; TRADE_ID_LEN];
118        buf[..value.len()].copy_from_slice(value);
119
120        Ok(Self { value: buf })
121    }
122
123    /// Returns a C string slice from the trade ID value.
124    ///
125    /// # Panics
126    ///
127    /// Panics if the stored byte array is not a valid C string up to the first NUL.
128    #[must_use]
129    pub fn as_cstr(&self) -> &CStr {
130        // SAFETY: Unwrap safe as we always store valid C strings
131        // We use until nul because the values array may be padded with nul bytes
132        CStr::from_bytes_until_nul(&self.value).unwrap()
133    }
134}
135
136impl Debug for TradeId {
137    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
138        write!(f, "{}('{}')", stringify!(TradeId), self)
139    }
140}
141
142impl Display for TradeId {
143    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
144        write!(f, "{}", self.as_cstr().to_str().unwrap())
145    }
146}
147
148impl Serialize for TradeId {
149    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
150    where
151        S: serde::Serializer,
152    {
153        serializer.serialize_str(&self.to_string())
154    }
155}
156
157impl<'de> Deserialize<'de> for TradeId {
158    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
159    where
160        D: Deserializer<'de>,
161    {
162        let value_str = String::deserialize(deserializer)?;
163        Ok(Self::new(&value_str))
164    }
165}
166
167////////////////////////////////////////////////////////////////////////////////
168// Tests
169////////////////////////////////////////////////////////////////////////////////
170#[cfg(test)]
171mod tests {
172    use rstest::rstest;
173
174    use crate::identifiers::{stubs::*, trade_id::TradeId};
175
176    #[rstest]
177    fn test_trade_id_new_valid() {
178        let trade_id = TradeId::new("TRADE12345");
179        assert_eq!(trade_id.to_string(), "TRADE12345");
180    }
181
182    #[rstest]
183    #[should_panic(expected = "Condition failed: 'value' exceeds max length or invalid format")]
184    fn test_trade_id_new_invalid_length() {
185        let _ = TradeId::new("A".repeat(37).as_str());
186    }
187
188    #[rstest]
189    #[case(b"1234567890", "1234567890")]
190    #[case(
191        b"ABCDEFGHIJKLMNOPQRSTUVWXYZ123456",
192        "ABCDEFGHIJKLMNOPQRSTUVWXYZ123456"
193    )]
194    #[case(b"1234567890\0", "1234567890")]
195    #[case(
196        b"ABCDEFGHIJKLMNOPQRSTUVWXYZ123456\0",
197        "ABCDEFGHIJKLMNOPQRSTUVWXYZ123456"
198    )]
199    fn test_trade_id_from_valid_bytes(#[case] input: &[u8], #[case] expected: &str) {
200        let trade_id = TradeId::from_bytes(input).unwrap();
201        assert_eq!(trade_id.to_string(), expected);
202    }
203
204    #[rstest]
205    #[should_panic(expected = "the 'value' slice `&[u8]` was empty")]
206    fn test_trade_id_from_bytes_empty() {
207        TradeId::from_bytes(&[] as &[u8]).unwrap();
208    }
209
210    #[rstest]
211    #[should_panic(expected = "'value' was single null byte")]
212    fn test_trade_id_single_null_byte() {
213        TradeId::from_bytes(&[0u8] as &[u8]).unwrap();
214    }
215
216    #[rstest]
217    #[case(b"ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789012")] // 37 bytes, no null terminator
218    #[case(b"ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789012\0")] // 38 bytes, with null terminator
219    #[should_panic(expected = "'value' exceeds max length or invalid format")]
220    fn test_trade_id_exceeds_max_length(#[case] input: &[u8]) {
221        TradeId::from_bytes(input).unwrap();
222    }
223
224    #[rstest]
225    fn test_trade_id_with_null_terminator_at_max_length() {
226        let input = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ123456\0" as &[u8];
227        let trade_id = TradeId::from_bytes(input).unwrap();
228        assert_eq!(trade_id.to_string(), "ABCDEFGHIJKLMNOPQRSTUVWXYZ123456");
229    }
230
231    #[rstest]
232    fn test_trade_id_as_cstr() {
233        let trade_id = TradeId::new("TRADE12345");
234        assert_eq!(trade_id.as_cstr().to_str().unwrap(), "TRADE12345");
235    }
236
237    #[rstest]
238    fn test_trade_id_equality() {
239        let trade_id1 = TradeId::new("TRADE12345");
240        let trade_id2 = TradeId::new("TRADE12345");
241        assert_eq!(trade_id1, trade_id2);
242    }
243
244    #[rstest]
245    fn test_string_reprs(trade_id: TradeId) {
246        assert_eq!(trade_id.to_string(), "1234567890");
247        assert_eq!(format!("{trade_id}"), "1234567890");
248        assert_eq!(format!("{trade_id:?}"), "TradeId('1234567890')");
249    }
250
251    #[rstest]
252    fn test_trade_id_ordering() {
253        let trade_id1 = TradeId::new("TRADE12345");
254        let trade_id2 = TradeId::new("TRADE12346");
255        assert!(trade_id1 < trade_id2);
256    }
257
258    #[rstest]
259    fn test_trade_id_serialization() {
260        let trade_id = TradeId::new("TRADE12345");
261        let json = serde_json::to_string(&trade_id).unwrap();
262        assert_eq!(json, "\"TRADE12345\"");
263
264        let deserialized: TradeId = serde_json::from_str(&json).unwrap();
265        assert_eq!(trade_id, deserialized);
266    }
267}