Skip to main content

nautilus_model/identifiers/
option_series_id.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Represents an option series and its underlying reference instrument.
17
18use 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/// Identifies an option series and the instrument supplying its reference price.
35///
36/// A reference matching `<UNDERLYING>.<VENUE>` uses the four-part representation. Others use
37/// `VENUE:UNDERLYING:UNDERLYING_INSTRUMENT_ID:SETTLEMENT:EXPIRY`.
38/// The reference instrument participates in equality, hashing, and ordering.
39#[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    /// The trading venue.
50    pub venue: Venue,
51    /// The underlying asset symbol (e.g. "BTC").
52    pub underlying: Ustr,
53    /// The settlement currency code (e.g. "BTC" for inverse, "USDC" for linear).
54    pub settlement_currency: Ustr,
55    /// UNIX timestamp (nanoseconds) for contract expiration.
56    pub expiration_ns: UnixNanos,
57    /// The instrument supplying the reference price, which may trade on another venue.
58    pub underlying_instrument_id: InstrumentId,
59}
60
61/// Error returned when a value is not a valid [`OptionSeriesId`].
62#[derive(Clone, Debug, Error, Eq, PartialEq)]
63pub enum OptionSeriesIdError {
64    /// The value does not match the expected format.
65    #[error(
66        "invalid `OptionSeriesId` value '{value}': expected format 'VENUE:UNDERLYING:[REFERENCE:]SETTLEMENT:EXPIRY'"
67    )]
68    InvalidFormat {
69        /// The invalid identifier value.
70        value: String,
71    },
72    /// The venue component is invalid.
73    #[error("invalid `OptionSeriesId` value '{value}': invalid venue: {source}")]
74    InvalidVenue {
75        /// The invalid identifier value.
76        value: String,
77        /// The venue validation failure.
78        source: Box<CorrectnessError>,
79    },
80    /// The underlying reference instrument is invalid.
81    #[error(
82        "invalid `OptionSeriesId` value '{value}': invalid underlying instrument '{instrument}': {reason}"
83    )]
84    InvalidUnderlyingInstrument {
85        /// The invalid identifier value.
86        value: String,
87        /// The invalid underlying instrument component.
88        instrument: String,
89        /// The instrument validation failure.
90        reason: String,
91    },
92    /// The expiration component is invalid.
93    #[error(
94        "invalid `OptionSeriesId` value '{value}': invalid expiration '{expiration}': {reason}"
95    )]
96    InvalidExpiration {
97        /// The invalid identifier value.
98        value: String,
99        /// The invalid expiration component.
100        expiration: String,
101        /// The expiration validation failure.
102        reason: String,
103    },
104}
105
106impl OptionSeriesId {
107    /// Creates an option series with an explicit reference instrument.
108    #[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    /// Creates a series with the legacy `<UNDERLYING>.<VENUE>` reference instrument.
126    ///
127    /// # Panics
128    ///
129    /// Panics if `underlying` is empty or whitespace-only.
130    #[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    /// Creates a series from a date string and an optional typed reference instrument.
147    ///
148    /// The date accepts `YYYY-MM-DD`, RFC 3339, integer nanoseconds, or floating-point seconds.
149    /// An absent reference derives `<UNDERLYING>.<VENUE>`.
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if the venue, derived underlying instrument, or expiration is invalid.
154    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    /// Creates a series from an expiration timestamp and an optional typed reference instrument.
190    ///
191    /// An absent reference derives `<UNDERLYING>.<VENUE>`.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the venue or derived underlying instrument is invalid.
196    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    /// Returns the canonical wire representation with exact nanosecond expiry.
226    #[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    /// Creates a series from a crypto option and its explicit reference instrument.
246    #[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    /// Returns whether the reference is `<UNDERLYING>.<VENUE>`.
261    #[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        // Date parses as midnight UTC (1699920000 seconds)
499        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}