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::{FAILED, check_valid_string_ascii, check_valid_string_utf8};
25use serde::{Deserialize, Deserializer, Serialize};
26
27#[cfg(feature = "defi")]
28use crate::defi::{Blockchain, validation::validate_address};
29use crate::identifiers::{Symbol, Venue};
30
31/// Represents a valid instrument ID.
32///
33/// The symbol and venue combination should uniquely identify the instrument.
34#[repr(C)]
35#[derive(Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
36#[cfg_attr(
37    feature = "python",
38    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.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 InstrumentId {
45    /// The instruments ticker symbol.
46    pub symbol: Symbol,
47    /// The instruments trading venue.
48    pub venue: Venue,
49}
50
51impl InstrumentId {
52    /// Creates a new [`InstrumentId`] instance.
53    #[must_use]
54    pub fn new(symbol: Symbol, venue: Venue) -> Self {
55        Self { symbol, venue }
56    }
57
58    #[must_use]
59    pub fn is_synthetic(&self) -> bool {
60        self.venue.is_synthetic()
61    }
62}
63
64impl InstrumentId {
65    /// # Errors
66    ///
67    /// Returns an error if parsing the string fails or string is invalid.
68    pub fn from_as_ref<T: AsRef<str>>(value: T) -> anyhow::Result<Self> {
69        Self::from_str(value.as_ref())
70    }
71
72    /// Extracts the blockchain from the venue if it's a DEX venue.
73    #[cfg(feature = "defi")]
74    #[must_use]
75    pub fn blockchain(&self) -> Option<Blockchain> {
76        self.venue
77            .parse_dex()
78            .map(|(blockchain, _)| blockchain)
79            .ok()
80    }
81}
82
83impl FromStr for InstrumentId {
84    type Err = anyhow::Error;
85
86    fn from_str(s: &str) -> anyhow::Result<Self> {
87        match s.rsplit_once('.') {
88            Some((symbol_part, venue_part)) => {
89                check_valid_string_utf8(symbol_part, stringify!(value))?;
90                check_valid_string_ascii(venue_part, stringify!(value))?;
91
92                let venue = Venue::new_checked(venue_part)?;
93
94                let symbol = {
95                    #[cfg(feature = "defi")]
96                    if venue.is_dex() {
97                        let validated_address = validate_address(symbol_part)
98                            .map_err(|e| anyhow::anyhow!(err_message(s, &e.to_string())))?;
99                        Symbol::new(validated_address.to_string())
100                    } else {
101                        Symbol::new(symbol_part)
102                    }
103
104                    #[cfg(not(feature = "defi"))]
105                    Symbol::new(symbol_part)
106                };
107
108                Ok(Self { symbol, venue })
109            }
110            None => {
111                anyhow::bail!(err_message(
112                    s,
113                    "missing '.' separator between symbol and venue components"
114                ))
115            }
116        }
117    }
118}
119
120impl<T: AsRef<str>> From<T> for InstrumentId {
121    fn from(value: T) -> Self {
122        Self::from_str(value.as_ref()).expect(FAILED)
123    }
124}
125
126impl Debug for InstrumentId {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        write!(f, "\"{}.{}\"", self.symbol, self.venue)
129    }
130}
131
132impl Display for InstrumentId {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        write!(f, "{}.{}", self.symbol, self.venue)
135    }
136}
137
138impl Serialize for InstrumentId {
139    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
140    where
141        S: serde::Serializer,
142    {
143        serializer.serialize_str(&self.to_string())
144    }
145}
146
147impl<'de> Deserialize<'de> for InstrumentId {
148    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
149    where
150        D: Deserializer<'de>,
151    {
152        let instrument_id_str: String = Deserialize::deserialize(deserializer)?;
153        Self::from_str(&instrument_id_str).map_err(serde::de::Error::custom)
154    }
155}
156
157fn err_message(s: &str, e: &str) -> String {
158    format!("Error parsing `InstrumentId` from '{s}': {e}")
159}
160
161#[cfg(test)]
162mod tests {
163    use std::str::FromStr;
164
165    use rstest::rstest;
166
167    use super::InstrumentId;
168    use crate::identifiers::stubs::*;
169
170    #[rstest]
171    fn test_instrument_id_parse_success(instrument_id_eth_usdt_binance: InstrumentId) {
172        assert_eq!(instrument_id_eth_usdt_binance.symbol.to_string(), "ETHUSDT");
173        assert_eq!(instrument_id_eth_usdt_binance.venue.to_string(), "BINANCE");
174    }
175
176    #[rstest]
177    #[should_panic(
178        expected = "Error parsing `InstrumentId` from 'ETHUSDT-BINANCE': missing '.' separator between symbol and venue components"
179    )]
180    fn test_instrument_id_parse_failure_no_dot() {
181        let _ = InstrumentId::from("ETHUSDT-BINANCE");
182    }
183
184    #[rstest]
185    fn test_string_reprs() {
186        let id = InstrumentId::from("ETH/USDT.BINANCE");
187        assert_eq!(id.to_string(), "ETH/USDT.BINANCE");
188        assert_eq!(format!("{id}"), "ETH/USDT.BINANCE");
189    }
190
191    #[rstest]
192    fn test_instrument_id_from_str_with_utf8_symbol() {
193        let non_ascii_symbol = "TËST-PÉRP";
194        let non_ascii_instrument = "TËST-PÉRP.BINANCE";
195
196        let id = InstrumentId::from_str(non_ascii_instrument).unwrap();
197        assert_eq!(id.symbol.to_string(), non_ascii_symbol);
198        assert_eq!(id.venue.to_string(), "BINANCE");
199        assert_eq!(id.to_string(), non_ascii_instrument);
200    }
201
202    #[cfg(feature = "defi")]
203    #[rstest]
204    fn test_blockchain_instrument_id_valid() {
205        let id =
206            InstrumentId::from("0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443.Arbitrum:UniswapV3");
207        assert_eq!(
208            id.symbol.to_string(),
209            "0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443"
210        );
211        assert_eq!(id.venue.to_string(), "Arbitrum:UniswapV3");
212    }
213
214    #[cfg(feature = "defi")]
215    #[rstest]
216    #[should_panic(
217        expected = "Error creating `Venue` from 'InvalidChain:UniswapV3': invalid blockchain venue 'InvalidChain:UniswapV3': chain 'InvalidChain' not recognized"
218    )]
219    fn test_blockchain_instrument_id_invalid_chain() {
220        let _ =
221            InstrumentId::from("0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443.InvalidChain:UniswapV3");
222    }
223
224    #[cfg(feature = "defi")]
225    #[rstest]
226    #[should_panic(
227        expected = "Error creating `Venue` from 'Arbitrum:': invalid blockchain venue 'Arbitrum:': expected format 'Chain:DexId'"
228    )]
229    fn test_blockchain_instrument_id_empty_dex() {
230        let _ = InstrumentId::from("0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443.Arbitrum:");
231    }
232
233    #[cfg(feature = "defi")]
234    #[rstest]
235    fn test_regular_venue_with_blockchain_like_name_but_without_dex() {
236        // Should work fine since it doesn't contain ':' (not a DEX venue)
237        let id = InstrumentId::from("0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443.Ethereum");
238        assert_eq!(
239            id.symbol.to_string(),
240            "0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443"
241        );
242        assert_eq!(id.venue.to_string(), "Ethereum");
243    }
244
245    #[cfg(feature = "defi")]
246    #[rstest]
247    #[should_panic(
248        expected = "Error parsing `InstrumentId` from 'invalidaddress.Ethereum:UniswapV3': Ethereum address must start with '0x': invalidaddress"
249    )]
250    fn test_blockchain_instrument_id_invalid_address_no_prefix() {
251        let _ = InstrumentId::from("invalidaddress.Ethereum:UniswapV3");
252    }
253
254    #[cfg(feature = "defi")]
255    #[rstest]
256    #[should_panic(
257        expected = "Error parsing `InstrumentId` from '0x123.Ethereum:UniswapV3': Blockchain address '0x123' is incorrect: odd number of digits"
258    )]
259    fn test_blockchain_instrument_id_invalid_address_short() {
260        let _ = InstrumentId::from("0x123.Ethereum:UniswapV3");
261    }
262
263    #[cfg(feature = "defi")]
264    #[rstest]
265    #[should_panic(
266        expected = "Error parsing `InstrumentId` from '0xC31E54c7a869B9FcBEcc14363CF510d1c41fa44G.Ethereum:UniswapV3': Blockchain address '0xC31E54c7a869B9FcBEcc14363CF510d1c41fa44G' is incorrect: invalid character 'G' at position 39"
267    )]
268    fn test_blockchain_instrument_id_invalid_address_non_hex() {
269        let _ = InstrumentId::from("0xC31E54c7a869B9FcBEcc14363CF510d1c41fa44G.Ethereum:UniswapV3");
270    }
271
272    #[cfg(feature = "defi")]
273    #[rstest]
274    #[should_panic(
275        expected = "Error parsing `InstrumentId` from '0xc31e54c7a869b9fcbecc14363cf510d1c41fa443.Ethereum:UniswapV3': Blockchain address '0xc31e54c7a869b9fcbecc14363cf510d1c41fa443' has incorrect checksum"
276    )]
277    fn test_blockchain_instrument_id_invalid_address_checksum() {
278        let _ = InstrumentId::from("0xc31e54c7a869b9fcbecc14363cf510d1c41fa443.Ethereum:UniswapV3");
279    }
280
281    #[cfg(feature = "defi")]
282    #[rstest]
283    fn test_blockchain_extraction_valid_dex() {
284        let id =
285            InstrumentId::from("0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443.Arbitrum:UniswapV3");
286        let blockchain = id.blockchain();
287        assert!(blockchain.is_some());
288        assert_eq!(blockchain.unwrap(), crate::defi::Blockchain::Arbitrum);
289    }
290
291    #[cfg(feature = "defi")]
292    #[rstest]
293    fn test_blockchain_extraction_tradifi_venue() {
294        let id = InstrumentId::from("ETH/USDT.BINANCE");
295        let blockchain = id.blockchain();
296        assert!(blockchain.is_none());
297    }
298}