Skip to main content

nautilus_model/identifiers/
instrument_id.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//! Represents a valid instrument ID.
17
18use std::{
19    fmt::{Debug, Display},
20    hash::Hash,
21    str::FromStr,
22};
23
24use nautilus_core::correctness::{CorrectnessError, FAILED};
25use serde::{Deserialize, Deserializer, Serialize};
26use thiserror::Error;
27
28#[cfg(feature = "defi")]
29use crate::defi::{Blockchain, validation::validate_address};
30use crate::{
31    enums::InstrumentClass,
32    identifiers::{Symbol, Venue},
33};
34
35/// Represents a valid instrument ID.
36///
37/// The symbol and venue combination should uniquely identify the instrument.
38#[repr(C)]
39#[derive(Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
40#[cfg_attr(
41    feature = "python",
42    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
43)]
44#[cfg_attr(
45    feature = "python",
46    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
47)]
48pub struct InstrumentId {
49    /// The instruments ticker symbol.
50    pub symbol: Symbol,
51    /// The instruments trading venue.
52    pub venue: Venue,
53}
54
55/// Error returned when a value is not a valid [`InstrumentId`].
56#[derive(Clone, Debug, Error, Eq, PartialEq)]
57pub enum InstrumentIdError {
58    /// The value does not contain the required separator.
59    #[error(
60        "invalid `InstrumentId` value '{value}': missing '.' separator between symbol and venue components"
61    )]
62    MissingSeparator {
63        /// The invalid identifier value.
64        value: String,
65    },
66    /// The symbol component is invalid.
67    #[error("invalid `InstrumentId` value '{value}': invalid symbol: {source}")]
68    InvalidSymbol {
69        /// The invalid identifier value.
70        value: String,
71        /// The symbol validation failure.
72        source: Box<CorrectnessError>,
73    },
74    /// The venue component is invalid.
75    #[error("invalid `InstrumentId` value '{value}': invalid venue: {source}")]
76    InvalidVenue {
77        /// The invalid identifier value.
78        value: String,
79        /// The venue validation failure.
80        source: Box<CorrectnessError>,
81    },
82    /// The blockchain address component is invalid.
83    #[error("invalid `InstrumentId` value '{value}': invalid blockchain address: {reason}")]
84    InvalidAddress {
85        /// The invalid identifier value.
86        value: String,
87        /// The address validation failure.
88        reason: String,
89    },
90}
91
92impl InstrumentId {
93    /// Creates a new [`InstrumentId`] instance.
94    #[must_use]
95    pub fn new(symbol: Symbol, venue: Venue) -> Self {
96        Self { symbol, venue }
97    }
98
99    #[must_use]
100    pub fn is_synthetic(&self) -> bool {
101        self.venue.is_synthetic()
102    }
103}
104
105impl InstrumentId {
106    /// # Errors
107    ///
108    /// Returns an error if `value` is not a valid identifier.
109    pub fn from_as_ref<T: AsRef<str>>(value: T) -> Result<Self, InstrumentIdError> {
110        Self::from_str(value.as_ref())
111    }
112
113    /// Extracts the blockchain from the venue if it's a DEX venue.
114    #[cfg(feature = "defi")]
115    #[must_use]
116    pub fn blockchain(&self) -> Option<Blockchain> {
117        self.venue
118            .parse_dex()
119            .map(|(blockchain, _)| blockchain)
120            .ok()
121    }
122
123    /// Returns the parent-symbol components `(root, class)` if this id has
124    /// a recognised parent shape `<root>.<class>` in its symbol component.
125    ///
126    /// Returns `None` when the symbol has zero or more than one `.`, or when
127    /// the suffix is not a recognised [`InstrumentClass`] parent suffix
128    /// (see [`InstrumentClass::try_from_parent_suffix`]).
129    ///
130    /// Used to gate parent-style subscription fan-out: a `None` return means
131    /// the id does not refer to a parent group and must not be expanded.
132    #[must_use]
133    pub fn parse_parent_components(&self) -> Option<(&str, InstrumentClass)> {
134        let symbol_str = self.symbol.as_str();
135        let (root, suffix) = symbol_str.split_once('.')?;
136        if root.is_empty() || suffix.contains('.') {
137            return None;
138        }
139        let class = InstrumentClass::try_from_parent_suffix(suffix)?;
140        Some((root, class))
141    }
142}
143
144impl FromStr for InstrumentId {
145    type Err = InstrumentIdError;
146
147    fn from_str(s: &str) -> Result<Self, Self::Err> {
148        let value = s.to_string();
149        let (symbol_part, venue_part) =
150            s.rsplit_once('.')
151                .ok_or_else(|| InstrumentIdError::MissingSeparator {
152                    value: value.clone(),
153                })?;
154
155        let venue =
156            Venue::new_checked(venue_part).map_err(|source| InstrumentIdError::InvalidVenue {
157                value: value.clone(),
158                source: Box::new(source),
159            })?;
160
161        let symbol = {
162            #[cfg(feature = "defi")]
163            if venue.is_dex() {
164                let validated_address = validate_address(symbol_part).map_err(|e| {
165                    InstrumentIdError::InvalidAddress {
166                        value: value.clone(),
167                        reason: e.to_string(),
168                    }
169                })?;
170                Symbol::new_checked(validated_address.to_string()).map_err(|source| {
171                    InstrumentIdError::InvalidSymbol {
172                        value: value.clone(),
173                        source: Box::new(source),
174                    }
175                })?
176            } else {
177                Symbol::new_checked(symbol_part).map_err(|source| {
178                    InstrumentIdError::InvalidSymbol {
179                        value: value.clone(),
180                        source: Box::new(source),
181                    }
182                })?
183            }
184
185            #[cfg(not(feature = "defi"))]
186            Symbol::new_checked(symbol_part).map_err(|source| InstrumentIdError::InvalidSymbol {
187                value: value.clone(),
188                source: Box::new(source),
189            })?
190        };
191
192        Ok(Self { symbol, venue })
193    }
194}
195
196impl<T: AsRef<str>> From<T> for InstrumentId {
197    fn from(value: T) -> Self {
198        match Self::from_str(value.as_ref()) {
199            Ok(instrument_id) => instrument_id,
200            Err(e) => panic!("{FAILED}: {e}"),
201        }
202    }
203}
204
205impl Debug for InstrumentId {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        write!(f, "\"{}.{}\"", self.symbol, self.venue)
208    }
209}
210
211impl Display for InstrumentId {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        write!(f, "{}.{}", self.symbol, self.venue)
214    }
215}
216
217impl Serialize for InstrumentId {
218    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
219    where
220        S: serde::Serializer,
221    {
222        serializer.serialize_str(&self.to_string())
223    }
224}
225
226impl<'de> Deserialize<'de> for InstrumentId {
227    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
228    where
229        D: Deserializer<'de>,
230    {
231        let instrument_id_str: std::borrow::Cow<'de, str> = Deserialize::deserialize(deserializer)?;
232        Self::from_str(instrument_id_str.as_ref()).map_err(serde::de::Error::custom)
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use std::str::FromStr;
239
240    use nautilus_core::correctness::CorrectnessError;
241    use rstest::rstest;
242
243    use super::{InstrumentId, InstrumentIdError};
244    use crate::identifiers::stubs::*;
245
246    #[rstest]
247    fn test_instrument_id_parse_success(instrument_id_eth_usdt_binance: InstrumentId) {
248        assert_eq!(instrument_id_eth_usdt_binance.symbol.to_string(), "ETHUSDT");
249        assert_eq!(instrument_id_eth_usdt_binance.venue.to_string(), "BINANCE");
250    }
251
252    #[rstest]
253    fn test_instrument_id_from_str_missing_separator_returns_typed_error() {
254        let error = InstrumentId::from_str("ETHUSDT-BINANCE").unwrap_err();
255
256        assert_eq!(
257            error,
258            InstrumentIdError::MissingSeparator {
259                value: "ETHUSDT-BINANCE".to_string(),
260            },
261        );
262        assert_eq!(
263            error.to_string(),
264            "invalid `InstrumentId` value 'ETHUSDT-BINANCE': missing '.' separator between symbol and venue components",
265        );
266    }
267
268    #[rstest]
269    #[should_panic(expected = "missing '.' separator between symbol and venue components")]
270    fn test_instrument_id_from_panics_with_display_error() {
271        let _ = InstrumentId::from("ETHUSDT-BINANCE");
272    }
273
274    #[rstest]
275    fn test_instrument_id_from_str_invalid_symbol_returns_typed_error() {
276        let error = InstrumentId::from_str(".BINANCE").unwrap_err();
277
278        assert_eq!(
279            error,
280            InstrumentIdError::InvalidSymbol {
281                value: ".BINANCE".to_string(),
282                source: Box::new(CorrectnessError::EmptyString {
283                    param: "value".to_string(),
284                }),
285            },
286        );
287        assert_eq!(
288            error.to_string(),
289            "invalid `InstrumentId` value '.BINANCE': invalid symbol: invalid string for 'value', was empty",
290        );
291    }
292
293    #[rstest]
294    fn test_instrument_id_from_str_invalid_venue_returns_typed_error() {
295        let error = InstrumentId::from_str("ETHUSDT.BINANCÉ").unwrap_err();
296
297        assert_eq!(
298            error,
299            InstrumentIdError::InvalidVenue {
300                value: "ETHUSDT.BINANCÉ".to_string(),
301                source: Box::new(CorrectnessError::NonAsciiString {
302                    param: "value".to_string(),
303                    value: "BINANCÉ".to_string(),
304                }),
305            },
306        );
307        assert_eq!(
308            error.to_string(),
309            concat!(
310                "invalid `InstrumentId` value 'ETHUSDT.BINANCÉ': invalid venue: ",
311                "invalid string for 'value' contained a non-ASCII char, was 'BINANCÉ'",
312            ),
313        );
314    }
315
316    #[rstest]
317    fn test_string_reprs() {
318        let id = InstrumentId::from("ETH/USDT.BINANCE");
319        assert_eq!(id.to_string(), "ETH/USDT.BINANCE");
320        assert_eq!(format!("{id}"), "ETH/USDT.BINANCE");
321    }
322
323    #[rstest]
324    fn test_instrument_id_from_str_with_utf8_symbol() {
325        let non_ascii_symbol = "TËST-PÉRP";
326        let non_ascii_instrument = "TËST-PÉRP.BINANCE";
327
328        let id = InstrumentId::from_str(non_ascii_instrument).unwrap();
329        assert_eq!(id.symbol.to_string(), non_ascii_symbol);
330        assert_eq!(id.venue.to_string(), "BINANCE");
331        assert_eq!(id.to_string(), non_ascii_instrument);
332    }
333
334    #[cfg(feature = "defi")]
335    #[rstest]
336    fn test_blockchain_instrument_id_valid() {
337        let id =
338            InstrumentId::from("0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443.Arbitrum:UniswapV3");
339        assert_eq!(
340            id.symbol.to_string(),
341            "0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443"
342        );
343        assert_eq!(id.venue.to_string(), "Arbitrum:UniswapV3");
344    }
345
346    #[cfg(feature = "defi")]
347    #[rstest]
348    #[should_panic(
349        expected = "invalid venue: Error creating `Venue` from 'InvalidChain:UniswapV3'"
350    )]
351    fn test_blockchain_instrument_id_invalid_chain() {
352        let _ =
353            InstrumentId::from("0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443.InvalidChain:UniswapV3");
354    }
355
356    #[cfg(feature = "defi")]
357    #[rstest]
358    #[should_panic(expected = "invalid venue: Error creating `Venue` from 'Arbitrum:'")]
359    fn test_blockchain_instrument_id_empty_dex() {
360        let _ = InstrumentId::from("0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443.Arbitrum:");
361    }
362
363    #[cfg(feature = "defi")]
364    #[rstest]
365    fn test_regular_venue_with_blockchain_like_name_but_without_dex() {
366        // Should work fine since it doesn't contain ':' (not a DEX venue)
367        let id = InstrumentId::from("0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443.Ethereum");
368        assert_eq!(
369            id.symbol.to_string(),
370            "0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443"
371        );
372        assert_eq!(id.venue.to_string(), "Ethereum");
373    }
374
375    #[cfg(feature = "defi")]
376    #[rstest]
377    #[should_panic(
378        expected = "invalid blockchain address: Ethereum address must start with '0x': invalidaddress"
379    )]
380    fn test_blockchain_instrument_id_invalid_address_no_prefix() {
381        let _ = InstrumentId::from("invalidaddress.Ethereum:UniswapV3");
382    }
383
384    #[cfg(feature = "defi")]
385    #[rstest]
386    #[should_panic(
387        expected = "invalid blockchain address: Blockchain address '0x123' is incorrect"
388    )]
389    fn test_blockchain_instrument_id_invalid_address_short() {
390        let _ = InstrumentId::from("0x123.Ethereum:UniswapV3");
391    }
392
393    #[cfg(feature = "defi")]
394    #[rstest]
395    #[should_panic(expected = "invalid character 'G' at position 39")]
396    fn test_blockchain_instrument_id_invalid_address_non_hex() {
397        let _ = InstrumentId::from("0xC31E54c7a869B9FcBEcc14363CF510d1c41fa44G.Ethereum:UniswapV3");
398    }
399
400    #[cfg(feature = "defi")]
401    #[rstest]
402    #[should_panic(expected = "has incorrect checksum")]
403    fn test_blockchain_instrument_id_invalid_address_checksum() {
404        let _ = InstrumentId::from("0xc31e54c7a869b9fcbecc14363cf510d1c41fa443.Ethereum:UniswapV3");
405    }
406
407    #[cfg(feature = "defi")]
408    #[rstest]
409    fn test_blockchain_extraction_valid_dex() {
410        let id =
411            InstrumentId::from("0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443.Arbitrum:UniswapV3");
412        let blockchain = id.blockchain();
413        assert!(blockchain.is_some());
414        assert_eq!(blockchain.unwrap(), crate::defi::Blockchain::Arbitrum);
415    }
416
417    #[cfg(feature = "defi")]
418    #[rstest]
419    fn test_blockchain_extraction_tradifi_venue() {
420        let id = InstrumentId::from("ETH/USDT.BINANCE");
421        let blockchain = id.blockchain();
422        assert!(blockchain.is_none());
423    }
424
425    use crate::enums::InstrumentClass;
426
427    #[rstest]
428    #[case("ES.FUT.XCME", Some(("ES", InstrumentClass::Future)))]
429    #[case("ES.FUTURE.XCME", Some(("ES", InstrumentClass::Future)))]
430    #[case("ES.OPT.XCME", Some(("ES", InstrumentClass::Option)))]
431    #[case("ES.OPTION.XCME", Some(("ES", InstrumentClass::Option)))]
432    #[case("CL.FUT.XNYM", Some(("CL", InstrumentClass::Future)))]
433    #[case("ECES.OPT.XCME", Some(("ECES", InstrumentClass::Option)))]
434    #[case("ESZ4.XCME", None)]
435    #[case("AUDUSD.SIM", None)]
436    #[case("1.211334112-31570229.BETFAIR", None)]
437    #[case("ES.UNKNOWN.XCME", None)]
438    #[case("ES.FUT.OOPS.XCME", None)]
439    #[case("ES.fut.XCME", None)]
440    #[case("ES.opt.XCME", None)]
441    #[case(".FUT.XCME", None)]
442    #[case(".OPT.XCME", None)]
443    fn test_parse_parent_components(
444        #[case] id_str: &str,
445        #[case] expected: Option<(&str, InstrumentClass)>,
446    ) {
447        let id = InstrumentId::from(id_str);
448        assert_eq!(id.parse_parent_components(), expected);
449    }
450}