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