nautilus_model/identifiers/
instrument_id.rs1use 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#[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 pub symbol: Symbol,
51 pub venue: Venue,
53}
54
55#[derive(Clone, Debug, Error, Eq, PartialEq)]
57pub enum InstrumentIdError {
58 #[error(
60 "invalid `InstrumentId` value '{value}': missing '.' separator between symbol and venue components"
61 )]
62 MissingSeparator {
63 value: String,
65 },
66 #[error("invalid `InstrumentId` value '{value}': invalid symbol: {source}")]
68 InvalidSymbol {
69 value: String,
71 source: Box<CorrectnessError>,
73 },
74 #[error("invalid `InstrumentId` value '{value}': invalid venue: {source}")]
76 InvalidVenue {
77 value: String,
79 source: Box<CorrectnessError>,
81 },
82 #[error("invalid `InstrumentId` value '{value}': invalid blockchain address: {reason}")]
84 InvalidAddress {
85 value: String,
87 reason: String,
89 },
90}
91
92impl InstrumentId {
93 #[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 pub fn from_as_ref<T: AsRef<str>>(value: T) -> Result<Self, InstrumentIdError> {
110 Self::from_str(value.as_ref())
111 }
112
113 #[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 #[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 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}