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