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, PoolIdentifier, validation::validate_address};
30use crate::{
31 enums::InstrumentClass,
32 identifiers::{Symbol, Venue},
33};
34
35pub const GENERIC_SPREAD_ID_SEPARATOR: &str = "___";
37
38#[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 pub symbol: Symbol,
54 pub venue: Venue,
56}
57
58#[derive(Clone, Debug, Error, Eq, PartialEq)]
60pub enum InstrumentIdError {
61 #[error(
63 "invalid `InstrumentId` value '{value}': missing '.' separator between symbol and venue components"
64 )]
65 MissingSeparator {
66 value: String,
68 },
69 #[error("invalid `InstrumentId` value '{value}': invalid symbol: {source}")]
71 InvalidSymbol {
72 value: String,
74 source: Box<CorrectnessError>,
76 },
77 #[error("invalid `InstrumentId` value '{value}': invalid venue: {source}")]
79 InvalidVenue {
80 value: String,
82 source: Box<CorrectnessError>,
84 },
85 #[error("invalid `InstrumentId` value '{value}': invalid blockchain address: {reason}")]
87 InvalidAddress {
88 value: String,
90 reason: String,
92 },
93}
94
95impl InstrumentId {
96 #[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 pub fn from_as_ref<T: AsRef<str>>(value: T) -> Result<Self, InstrumentIdError> {
111 Self::from_str(value.as_ref())
112 }
113
114 #[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 #[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}