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