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