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