1use std::{
19 fmt::{Debug, Display},
20 hash::Hash,
21 str::FromStr,
22};
23
24use nautilus_core::{UnixNanos, correctness::CorrectnessError};
25use serde::{Deserialize, Serialize};
26use thiserror::Error;
27use ustr::Ustr;
28
29use crate::{
30 identifiers::{InstrumentId, Symbol, Venue},
31 instruments::CryptoOption,
32};
33
34#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
40#[cfg_attr(
41 feature = "python",
42 pyo3::pyclass(module = "nautilus_trader.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 OptionSeriesId {
49 pub venue: Venue,
51 pub underlying: Ustr,
53 pub settlement_currency: Ustr,
55 pub expiration_ns: UnixNanos,
57 pub underlying_instrument_id: InstrumentId,
59}
60
61#[derive(Clone, Debug, Error, Eq, PartialEq)]
63pub enum OptionSeriesIdError {
64 #[error(
66 "invalid `OptionSeriesId` value '{value}': expected format 'VENUE:UNDERLYING:[REFERENCE:]SETTLEMENT:EXPIRY'"
67 )]
68 InvalidFormat {
69 value: String,
71 },
72 #[error("invalid `OptionSeriesId` value '{value}': invalid venue: {source}")]
74 InvalidVenue {
75 value: String,
77 source: Box<CorrectnessError>,
79 },
80 #[error(
82 "invalid `OptionSeriesId` value '{value}': invalid underlying instrument '{instrument}': {reason}"
83 )]
84 InvalidUnderlyingInstrument {
85 value: String,
87 instrument: String,
89 reason: String,
91 },
92 #[error(
94 "invalid `OptionSeriesId` value '{value}': invalid expiration '{expiration}': {reason}"
95 )]
96 InvalidExpiration {
97 value: String,
99 expiration: String,
101 reason: String,
103 },
104}
105
106impl OptionSeriesId {
107 #[must_use]
109 pub fn new(
110 venue: Venue,
111 underlying: Ustr,
112 settlement_currency: Ustr,
113 expiration_ns: UnixNanos,
114 underlying_instrument_id: InstrumentId,
115 ) -> Self {
116 Self {
117 venue,
118 underlying,
119 settlement_currency,
120 expiration_ns,
121 underlying_instrument_id,
122 }
123 }
124
125 #[must_use]
131 pub fn new_derived(
132 venue: Venue,
133 underlying: Ustr,
134 settlement_currency: Ustr,
135 expiration_ns: UnixNanos,
136 ) -> Self {
137 Self::new(
138 venue,
139 underlying,
140 settlement_currency,
141 expiration_ns,
142 InstrumentId::new(Symbol::new(underlying.as_str()), venue),
143 )
144 }
145
146 pub fn from_expiry(
155 venue: &str,
156 underlying: &str,
157 settlement_currency: &str,
158 date_str: &str,
159 underlying_instrument_id: Option<InstrumentId>,
160 ) -> Result<Self, OptionSeriesIdError> {
161 let value = format!("{venue}:{underlying}:{settlement_currency}:{date_str}");
162
163 let venue =
164 Venue::new_checked(venue).map_err(|source| OptionSeriesIdError::InvalidVenue {
165 value: value.clone(),
166 source: Box::new(source),
167 })?;
168 let expiration_ns =
169 UnixNanos::from_str(date_str).map_err(|e| OptionSeriesIdError::InvalidExpiration {
170 value: value.clone(),
171 expiration: date_str.to_string(),
172 reason: e.to_string(),
173 })?;
174
175 let instrument = match underlying_instrument_id {
176 Some(instrument) => instrument,
177 None => derived_instrument(venue, underlying, &value)?,
178 };
179
180 Ok(Self::new(
181 venue,
182 Ustr::from(underlying),
183 Ustr::from(settlement_currency),
184 expiration_ns,
185 instrument,
186 ))
187 }
188
189 pub fn from_expiry_ns(
197 venue: &str,
198 underlying: &str,
199 settlement_currency: &str,
200 expiration_ns: UnixNanos,
201 underlying_instrument_id: Option<InstrumentId>,
202 ) -> Result<Self, OptionSeriesIdError> {
203 let value = format!("{venue}:{underlying}:{settlement_currency}:{expiration_ns}");
204
205 let venue =
206 Venue::new_checked(venue).map_err(|source| OptionSeriesIdError::InvalidVenue {
207 value: value.clone(),
208 source: Box::new(source),
209 })?;
210
211 let instrument = match underlying_instrument_id {
212 Some(instrument) => instrument,
213 None => derived_instrument(venue, underlying, &value)?,
214 };
215
216 Ok(Self::new(
217 venue,
218 Ustr::from(underlying),
219 Ustr::from(settlement_currency),
220 expiration_ns,
221 instrument,
222 ))
223 }
224
225 #[must_use]
227 pub fn to_wire_string(&self) -> String {
228 if self.is_derived() {
229 format!(
230 "{}:{}:{}:{}",
231 self.venue, self.underlying, self.settlement_currency, self.expiration_ns
232 )
233 } else {
234 format!(
235 "{}:{}:{}:{}:{}",
236 self.venue,
237 self.underlying,
238 self.underlying_instrument_id,
239 self.settlement_currency,
240 self.expiration_ns
241 )
242 }
243 }
244
245 #[must_use]
247 pub fn from_crypto_option(
248 option: &CryptoOption,
249 underlying_instrument_id: InstrumentId,
250 ) -> Self {
251 Self::new(
252 option.id.venue,
253 option.underlying.code,
254 option.settlement_currency.code,
255 option.expiration_ns,
256 underlying_instrument_id,
257 )
258 }
259
260 #[must_use]
262 pub fn is_derived(&self) -> bool {
263 self.underlying_instrument_id.venue == self.venue
264 && self.underlying_instrument_id.symbol.as_str() == self.underlying.as_str()
265 }
266}
267
268impl Display for OptionSeriesId {
269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270 write!(f, "{}:{}:", self.venue, self.underlying)?;
271
272 if !self.is_derived() {
273 write!(f, "{}:", self.underlying_instrument_id)?;
274 }
275
276 write!(
277 f,
278 "{}:{}",
279 self.settlement_currency,
280 self.expiration_ns.to_datetime_utc()
281 )
282 }
283}
284
285impl Debug for OptionSeriesId {
286 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287 write!(f, "\"{self}\"")
288 }
289}
290
291impl FromStr for OptionSeriesId {
292 type Err = OptionSeriesIdError;
293
294 fn from_str(s: &str) -> Result<Self, Self::Err> {
295 let mut parts = s.splitn(3, ':');
296
297 let (Some(venue), Some(underlying), Some(rest)) =
298 (parts.next(), parts.next(), parts.next())
299 else {
300 return Err(OptionSeriesIdError::InvalidFormat {
301 value: s.to_string(),
302 });
303 };
304
305 let venue =
306 Venue::new_checked(venue).map_err(|source| OptionSeriesIdError::InvalidVenue {
307 value: s.to_string(),
308 source: Box::new(source),
309 })?;
310
311 let (instrument, settlement, expiration) = parse_reference(rest);
312
313 let instrument = match instrument {
314 Some(instrument) => InstrumentId::from_str(instrument).map_err(|e| {
315 OptionSeriesIdError::InvalidUnderlyingInstrument {
316 value: s.to_string(),
317 instrument: instrument.to_string(),
318 reason: e.to_string(),
319 }
320 })?,
321 None => derived_instrument(venue, underlying, s)?,
322 };
323
324 let (Some(settlement), Some(expiration)) = (settlement, expiration) else {
325 return Err(OptionSeriesIdError::InvalidFormat {
326 value: s.to_string(),
327 });
328 };
329
330 let expiration_ns = UnixNanos::from_str(expiration).map_err(|e| {
331 OptionSeriesIdError::InvalidExpiration {
332 value: s.to_string(),
333 expiration: expiration.to_string(),
334 reason: e.to_string(),
335 }
336 })?;
337
338 Ok(Self::new(
339 venue,
340 Ustr::from(underlying),
341 Ustr::from(settlement),
342 expiration_ns,
343 instrument,
344 ))
345 }
346}
347
348impl Serialize for OptionSeriesId {
349 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
350 where
351 S: serde::Serializer,
352 {
353 serializer.serialize_str(&self.to_wire_string())
354 }
355}
356
357impl<'de> Deserialize<'de> for OptionSeriesId {
358 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
359 where
360 D: serde::Deserializer<'de>,
361 {
362 let s: std::borrow::Cow<'de, str> = Deserialize::deserialize(deserializer)?;
363 Self::from_str(s.as_ref()).map_err(serde::de::Error::custom)
364 }
365}
366
367fn parse_reference(rest: &str) -> (Option<&str>, Option<&str>, Option<&str>) {
368 let legacy = rest.split_once(':');
369 if let Some((settlement, expiration)) = legacy
370 && UnixNanos::from_str(expiration).is_ok()
371 {
372 return (None, Some(settlement), Some(expiration));
373 }
374
375 let mut extended = None;
376
377 for (index, _) in rest.match_indices(':') {
378 let reference = &rest[..index];
379 if !reference.contains('.') {
380 continue;
381 }
382
383 let Some((settlement, expiration)) = rest[index + 1..].split_once(':') else {
384 continue;
385 };
386
387 extended = Some((Some(reference), Some(settlement), Some(expiration)));
388 if UnixNanos::from_str(expiration).is_ok() {
389 break;
390 }
391 }
392
393 extended.unwrap_or(match legacy {
394 Some((settlement, expiration)) => (None, Some(settlement), Some(expiration)),
395 None => (None, None, None),
396 })
397}
398
399fn derived_instrument(
400 venue: Venue,
401 underlying: &str,
402 value: &str,
403) -> Result<InstrumentId, OptionSeriesIdError> {
404 let symbol = Symbol::new_checked(underlying).map_err(|e| {
405 OptionSeriesIdError::InvalidUnderlyingInstrument {
406 value: value.to_string(),
407 instrument: format!("{underlying}.{venue}"),
408 reason: e.to_string(),
409 }
410 })?;
411
412 Ok(InstrumentId::new(symbol, venue))
413}
414
415#[cfg(test)]
416mod tests {
417 use std::collections::HashSet;
418
419 use rstest::*;
420
421 use super::*;
422 use crate::{instruments::stubs::crypto_option_btc_deribit, types::Currency};
423
424 fn test_series_id() -> OptionSeriesId {
425 OptionSeriesId::new_derived(
426 Venue::new("DERIBIT"),
427 Ustr::from("BTC"),
428 Ustr::from("BTC"),
429 UnixNanos::from(1_700_000_000_000_000_000u64),
430 )
431 }
432
433 #[rstest]
434 fn test_option_series_id_new() {
435 let venue = Venue::new("DERIBIT");
436 let underlying = Ustr::from("BTC");
437 let settlement = Ustr::from("BTC");
438 let expiration_ns = UnixNanos::from(1_700_000_000_000_000_000u64);
439
440 let id = OptionSeriesId::new_derived(venue, underlying, settlement, expiration_ns);
441
442 assert_eq!(id.venue, venue);
443 assert_eq!(id.underlying, underlying);
444 assert_eq!(id.settlement_currency, settlement);
445 assert_eq!(id.expiration_ns, expiration_ns);
446 }
447
448 #[rstest]
449 fn test_option_series_id_display() {
450 let id = test_series_id();
451 assert_eq!(id.to_string(), "DERIBIT:BTC:BTC:2023-11-14T22:13:20Z");
452 }
453
454 #[rstest]
455 fn test_option_series_id_wire_string() {
456 let id = test_series_id();
457 assert_eq!(id.to_wire_string(), "DERIBIT:BTC:BTC:1700000000000000000");
458 }
459
460 #[rstest]
461 fn test_option_series_id_debug() {
462 let id = test_series_id();
463 assert_eq!(
464 format!("{id:?}"),
465 "\"DERIBIT:BTC:BTC:2023-11-14T22:13:20Z\""
466 );
467 }
468
469 #[rstest]
470 fn test_option_series_id_from_str() {
471 let id = OptionSeriesId::from_str("DERIBIT:BTC:BTC:1700000000000000000").unwrap();
472
473 assert_eq!(id.venue, Venue::new("DERIBIT"));
474 assert_eq!(id.underlying, Ustr::from("BTC"));
475 assert_eq!(id.settlement_currency, Ustr::from("BTC"));
476 assert_eq!(
477 id.expiration_ns,
478 UnixNanos::from(1_700_000_000_000_000_000u64)
479 );
480 }
481
482 #[rstest]
483 fn test_option_series_id_from_str_rfc3339() {
484 let id = OptionSeriesId::from_str("DERIBIT:BTC:BTC:2023-11-14T22:13:20Z").unwrap();
485 assert_eq!(id.venue, Venue::new("DERIBIT"));
486 assert_eq!(id.underlying, Ustr::from("BTC"));
487 assert_eq!(
488 id.expiration_ns,
489 UnixNanos::from(1_700_000_000_000_000_000u64)
490 );
491 }
492
493 #[rstest]
494 fn test_option_series_id_from_str_date() {
495 let id = OptionSeriesId::from_str("DERIBIT:BTC:BTC:2023-11-14").unwrap();
496 assert_eq!(id.venue, Venue::new("DERIBIT"));
497 assert_eq!(id.underlying, Ustr::from("BTC"));
498 assert_eq!(
500 id.expiration_ns,
501 UnixNanos::from(1_699_920_000_000_000_000u64)
502 );
503 }
504
505 #[rstest]
506 fn test_option_series_id_from_str_invalid_format() {
507 let error = OptionSeriesId::from_str("DERIBIT:BTC:BTC").unwrap_err();
508
509 assert_eq!(
510 error,
511 OptionSeriesIdError::InvalidFormat {
512 value: "DERIBIT:BTC:BTC".to_string(),
513 },
514 );
515 assert_eq!(
516 error.to_string(),
517 "invalid `OptionSeriesId` value 'DERIBIT:BTC:BTC': expected format 'VENUE:UNDERLYING:[REFERENCE:]SETTLEMENT:EXPIRY'",
518 );
519 }
520
521 #[rstest]
522 fn test_option_series_id_from_str_invalid_venue() {
523 let error = OptionSeriesId::from_str("DÉRIBIT:BTC:BTC:1700000000000000000").unwrap_err();
524
525 assert_eq!(
526 error,
527 OptionSeriesIdError::InvalidVenue {
528 value: "DÉRIBIT:BTC:BTC:1700000000000000000".to_string(),
529 source: Box::new(CorrectnessError::NonAsciiString {
530 param: "value".to_string(),
531 value: "DÉRIBIT".to_string(),
532 }),
533 },
534 );
535 assert_eq!(
536 error.to_string(),
537 concat!(
538 "invalid `OptionSeriesId` value 'DÉRIBIT:BTC:BTC:1700000000000000000': ",
539 "invalid venue: invalid string for 'value' contained a non-ASCII char, ",
540 "was 'DÉRIBIT'",
541 ),
542 );
543 }
544
545 #[rstest]
546 fn test_option_series_id_from_str_invalid_expiry() {
547 let error = OptionSeriesId::from_str("DERIBIT:BTC:BTC:not_a_date").unwrap_err();
548
549 assert_eq!(
550 error,
551 OptionSeriesIdError::InvalidExpiration {
552 value: "DERIBIT:BTC:BTC:not_a_date".to_string(),
553 expiration: "not_a_date".to_string(),
554 reason: "Invalid format: not_a_date".to_string(),
555 },
556 );
557 assert_eq!(
558 error.to_string(),
559 concat!(
560 "invalid `OptionSeriesId` value 'DERIBIT:BTC:BTC:not_a_date': ",
561 "invalid expiration 'not_a_date': Invalid format: not_a_date",
562 ),
563 );
564 }
565
566 #[rstest]
567 fn test_option_series_id_inequality() {
568 let id1 = test_series_id();
569 let id2 = OptionSeriesId::new_derived(
570 Venue::new("DERIBIT"),
571 Ustr::from("ETH"),
572 Ustr::from("ETH"),
573 UnixNanos::from(1_700_000_000_000_000_000u64),
574 );
575 assert_ne!(id1, id2);
576 }
577
578 #[rstest]
579 fn test_option_series_id_hash() {
580 let id1 = test_series_id();
581 let id2 = OptionSeriesId::new_derived(
582 Venue::new("DERIBIT"),
583 Ustr::from("ETH"),
584 Ustr::from("ETH"),
585 UnixNanos::from(1_700_000_000_000_000_000u64),
586 );
587
588 let mut set = HashSet::new();
589 set.insert(id1);
590 set.insert(id2);
591 set.insert(id1);
592
593 assert_eq!(set.len(), 2);
594 }
595
596 #[rstest]
597 fn test_option_series_id_serde_roundtrip() {
598 let id = test_series_id();
599
600 let json = serde_json::to_string(&id).unwrap();
601 let deserialized: OptionSeriesId = serde_json::from_str(&json).unwrap();
602
603 assert_eq!(id, deserialized);
604 }
605
606 #[rstest]
607 fn test_option_series_id_deserialize_from_owned_value() {
608 let id = test_series_id();
609 let value = serde_json::Value::String(id.to_wire_string());
610
611 let deserialized: OptionSeriesId = serde_json::from_value(value).unwrap();
612 assert_eq!(id, deserialized);
613 }
614
615 #[rstest]
616 fn test_from_expiry_happy_path() {
617 let id = OptionSeriesId::from_expiry("DERIBIT", "BTC", "BTC", "2025-03-28", None).unwrap();
618 assert_eq!(id.venue, Venue::new("DERIBIT"));
619 assert_eq!(id.underlying, Ustr::from("BTC"));
620 assert_eq!(id.settlement_currency, Ustr::from("BTC"));
621 assert_eq!(
622 id.expiration_ns,
623 UnixNanos::from(1_743_120_000_000_000_000u64)
624 );
625 }
626
627 #[rstest]
628 fn test_from_expiry_invalid_date() {
629 let result = OptionSeriesId::from_expiry("DERIBIT", "BTC", "BTC", "not-a-date", None);
630 let error = result.unwrap_err();
631
632 assert_eq!(
633 error,
634 OptionSeriesIdError::InvalidExpiration {
635 value: "DERIBIT:BTC:BTC:not-a-date".to_string(),
636 expiration: "not-a-date".to_string(),
637 reason: "Invalid format: not-a-date".to_string(),
638 },
639 );
640 }
641
642 #[rstest]
643 fn test_from_expiry_invalid_venue() {
644 let error =
645 OptionSeriesId::from_expiry("DÉRIBIT", "BTC", "BTC", "2025-03-28", None).unwrap_err();
646
647 assert_eq!(
648 error,
649 OptionSeriesIdError::InvalidVenue {
650 value: "DÉRIBIT:BTC:BTC:2025-03-28".to_string(),
651 source: Box::new(CorrectnessError::NonAsciiString {
652 param: "value".to_string(),
653 value: "DÉRIBIT".to_string(),
654 }),
655 },
656 );
657 }
658
659 #[rstest]
660 fn test_from_expiry_roundtrip() {
661 let id = OptionSeriesId::from_expiry("DERIBIT", "ETH", "ETH", "2025-06-27", None).unwrap();
662 let s = id.to_string();
663 let parsed = OptionSeriesId::from_str(&s).unwrap();
664 assert_eq!(id, parsed);
665 }
666
667 #[rstest]
668 fn test_from_crypto_option(mut crypto_option_btc_deribit: CryptoOption) {
669 crypto_option_btc_deribit.settlement_currency = Currency::USDC();
670
671 let id = OptionSeriesId::from_crypto_option(
672 &crypto_option_btc_deribit,
673 InstrumentId::from("BTC.DERIBIT"),
674 );
675
676 assert_eq!(id.venue, Venue::new("DERIBIT"));
677 assert_eq!(id.underlying, Ustr::from("BTC"));
678 assert_eq!(id.settlement_currency, Ustr::from("USDC"));
679 assert_eq!(
680 id.expiration_ns,
681 UnixNanos::from(1_673_596_800_000_000_000u64)
682 );
683 }
684
685 #[rstest]
686 fn test_from_expiry_ns_happy_path() {
687 let id = OptionSeriesId::from_expiry_ns(
688 "DERIBIT",
689 "ETH",
690 "USDC",
691 UnixNanos::from(1_700_000_000_000_000_000u64),
692 None,
693 )
694 .unwrap();
695
696 assert_eq!(id.venue, Venue::new("DERIBIT"));
697 assert_eq!(id.underlying, Ustr::from("ETH"));
698 assert_eq!(id.settlement_currency, Ustr::from("USDC"));
699 assert_eq!(
700 id.expiration_ns,
701 UnixNanos::from(1_700_000_000_000_000_000u64)
702 );
703 }
704
705 #[rstest]
706 fn test_from_expiry_ns_empty_venue() {
707 let error = OptionSeriesId::from_expiry_ns(
708 "",
709 "ETH",
710 "USDC",
711 UnixNanos::from(1_700_000_000_000_000_000u64),
712 None,
713 )
714 .unwrap_err();
715
716 assert_eq!(
717 error,
718 OptionSeriesIdError::InvalidVenue {
719 value: ":ETH:USDC:1700000000000000000".to_string(),
720 source: Box::new(CorrectnessError::EmptyString {
721 param: "value".to_string(),
722 }),
723 },
724 );
725 assert_eq!(
726 error.to_string(),
727 concat!(
728 "invalid `OptionSeriesId` value ':ETH:USDC:1700000000000000000': ",
729 "invalid venue: invalid string for 'value', was empty",
730 ),
731 );
732 }
733
734 #[rstest]
735 fn test_from_expiry_ns_non_ascii_venue() {
736 let error = OptionSeriesId::from_expiry_ns(
737 "DÉRIBIT",
738 "ETH",
739 "USDC",
740 UnixNanos::from(1_700_000_000_000_000_000u64),
741 None,
742 )
743 .unwrap_err();
744
745 assert_eq!(
746 error,
747 OptionSeriesIdError::InvalidVenue {
748 value: "DÉRIBIT:ETH:USDC:1700000000000000000".to_string(),
749 source: Box::new(CorrectnessError::NonAsciiString {
750 param: "value".to_string(),
751 value: "DÉRIBIT".to_string(),
752 }),
753 },
754 );
755 }
756
757 #[rstest]
758 fn test_from_expiry_ns_rejects_empty_derived_underlying() {
759 let id = OptionSeriesId::from_expiry_ns(
760 "DERIBIT",
761 "",
762 "",
763 UnixNanos::from(1_700_000_000_000_000_000u64),
764 None,
765 )
766 .unwrap_err();
767
768 assert_eq!(
769 id,
770 OptionSeriesIdError::InvalidUnderlyingInstrument {
771 value: "DERIBIT:::1700000000000000000".to_string(),
772 instrument: ".DERIBIT".to_string(),
773 reason: "invalid string for 'value', was empty".to_string(),
774 }
775 );
776 }
777
778 #[rstest]
779 #[case("BTC.DERIBIT", "USD", "DERIBIT:BTC:USD:2023-11-14T22:13:20.123456789Z")]
780 #[case(
781 "BTCUSDT.BINANCE",
782 "USD",
783 "DERIBIT:BTC:BTCUSDT.BINANCE:USD:2023-11-14T22:13:20.123456789Z"
784 )]
785 #[case(
786 "xyz:TSLA-USD-PERP.HYPERLIQUID",
787 "USD",
788 "DERIBIT:BTC:xyz:TSLA-USD-PERP.HYPERLIQUID:USD:2023-11-14T22:13:20.123456789Z"
789 )]
790 #[case(
791 "UD:2E: SG 2500275.GLBX",
792 "USD",
793 "DERIBIT:BTC:UD:2E: SG 2500275.GLBX:USD:2023-11-14T22:13:20.123456789Z"
794 )]
795 #[case(
796 "BTC.DERIBIT",
797 "USDC.e",
798 "DERIBIT:BTC:USDC.e:2023-11-14T22:13:20.123456789Z"
799 )]
800 #[case(
801 "BTCUSDT.BINANCE",
802 "USDC.e",
803 "DERIBIT:BTC:BTCUSDT.BINANCE:USDC.e:2023-11-14T22:13:20.123456789Z"
804 )]
805 #[case(
806 "xyz:TSLA-USD-PERP.HYPERLIQUID",
807 "USDC.e",
808 "DERIBIT:BTC:xyz:TSLA-USD-PERP.HYPERLIQUID:USDC.e:2023-11-14T22:13:20.123456789Z"
809 )]
810 fn test_reference_roundtrip(
811 #[case] reference: &str,
812 #[case] settlement: &str,
813 #[case] expected: &str,
814 ) {
815 let instrument = InstrumentId::from(reference);
816
817 let id = OptionSeriesId::new(
818 Venue::from("DERIBIT"),
819 Ustr::from("BTC"),
820 Ustr::from(settlement),
821 UnixNanos::from(1_700_000_000_123_456_789u64),
822 instrument,
823 );
824 let display = id.to_string();
825 let wire = id.to_wire_string();
826 let json = serde_json::to_value(id).unwrap();
827 assert_eq!(display, expected);
828 assert_eq!(OptionSeriesId::from_str(&display).unwrap(), id);
829 assert_eq!(OptionSeriesId::from_str(&wire).unwrap(), id);
830 assert_eq!(serde_json::from_value::<OptionSeriesId>(json).unwrap(), id);
831 assert_eq!(id.underlying_instrument_id, instrument);
832 }
833
834 #[rstest]
835 fn test_reference_participates_in_identity() {
836 let first = OptionSeriesId::new(
837 Venue::from("XCME"),
838 Ustr::from("ES"),
839 Ustr::from("USD"),
840 UnixNanos::from(17u64),
841 InstrumentId::from("ESH6.XCME"),
842 );
843
844 let second = OptionSeriesId {
845 underlying_instrument_id: InstrumentId::from("ESM6.XCME"),
846 ..first
847 };
848
849 let mut ids = HashSet::new();
850 ids.insert(first);
851 ids.insert(second);
852 assert_ne!(first, second);
853 assert_ne!(first.cmp(&second), std::cmp::Ordering::Equal);
854 assert_eq!(ids.len(), 2);
855 assert_eq!(first.to_wire_string(), "XCME:ES:ESH6.XCME:USD:17");
856 }
857
858 #[rstest]
859 #[case("DERIBIT::USD:1700000000000000000")]
860 #[case("DERIBIT: :USD:1700000000000000000")]
861 #[case("DERIBIT:BTC:.BINANCE:USD:1700000000000000000")]
862 fn test_invalid_reference_returns_typed_error(#[case] value: &str) {
863 let error = OptionSeriesId::from_str(value).unwrap_err();
864 assert!(matches!(
865 error,
866 OptionSeriesIdError::InvalidUnderlyingInstrument { .. }
867 ));
868 assert!(
869 serde_json::from_value::<OptionSeriesId>(serde_json::Value::String(value.to_string()))
870 .is_err()
871 );
872 }
873
874 #[rstest]
875 fn test_explicit_reference_constructors() {
876 let reference = InstrumentId::from("BTCUSDT.BINANCE");
877 let id =
878 OptionSeriesId::from_expiry("DERIBIT", "BTC", "USD", "2026-03-20", Some(reference))
879 .unwrap();
880 let ns = OptionSeriesId::from_expiry_ns(
881 "DERIBIT",
882 "BTC",
883 "USD",
884 id.expiration_ns,
885 Some(reference),
886 )
887 .unwrap();
888 assert_eq!(id, ns);
889 assert_eq!(id.venue, Venue::from("DERIBIT"));
890 assert_eq!(id.underlying, Ustr::from("BTC"));
891 assert_eq!(id.settlement_currency, Ustr::from("USD"));
892 assert_eq!(id.expiration_ns, UnixNanos::from_str("2026-03-20").unwrap());
893 assert_eq!(id.underlying_instrument_id, reference);
894 }
895}