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    deserialization::StringInput,
31    identifiers::{InstrumentId, Symbol, Venue},
32    instruments::CryptoOption,
33};
34
35/// Identifies an option series and the instrument supplying its reference price.
36///
37/// A reference matching `<UNDERLYING>.<VENUE>` uses the four-part representation. Others use
38/// `VENUE:UNDERLYING:UNDERLYING_INSTRUMENT_ID:SETTLEMENT:EXPIRY`.
39/// The reference instrument participates in equality, hashing, and ordering.
40#[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    /// The trading venue.
51    pub venue: Venue,
52    /// The underlying asset symbol (e.g. "BTC").
53    pub underlying: Ustr,
54    /// The settlement currency code (e.g. "BTC" for inverse, "USDC" for linear).
55    pub settlement_currency: Ustr,
56    /// UNIX timestamp (nanoseconds) for contract expiration.
57    pub expiration_ns: UnixNanos,
58    /// The instrument supplying the reference price, which may trade on another venue.
59    pub underlying_instrument_id: InstrumentId,
60}
61
62/// Error returned when a value is not a valid [`OptionSeriesId`].
63#[derive(Clone, Debug, Error, Eq, PartialEq)]
64pub enum OptionSeriesIdError {
65    /// The value does not match the expected format.
66    #[error(
67        "invalid `OptionSeriesId` value '{value}': expected format 'VENUE:UNDERLYING:[REFERENCE:]SETTLEMENT:EXPIRY'"
68    )]
69    InvalidFormat {
70        /// The invalid identifier value.
71        value: String,
72    },
73    /// The venue component is invalid.
74    #[error("invalid `OptionSeriesId` value '{value}': invalid venue: {source}")]
75    InvalidVenue {
76        /// The invalid identifier value.
77        value: String,
78        /// The venue validation failure.
79        source: Box<CorrectnessError>,
80    },
81    /// The underlying reference instrument is invalid.
82    #[error(
83        "invalid `OptionSeriesId` value '{value}': invalid underlying instrument '{instrument}': {reason}"
84    )]
85    InvalidUnderlyingInstrument {
86        /// The invalid identifier value.
87        value: String,
88        /// The invalid underlying instrument component.
89        instrument: String,
90        /// The instrument validation failure.
91        reason: String,
92    },
93    /// The expiration component is invalid.
94    #[error(
95        "invalid `OptionSeriesId` value '{value}': invalid expiration '{expiration}': {reason}"
96    )]
97    InvalidExpiration {
98        /// The invalid identifier value.
99        value: String,
100        /// The invalid expiration component.
101        expiration: String,
102        /// The expiration validation failure.
103        reason: String,
104    },
105}
106
107impl OptionSeriesId {
108    /// Creates an option series with an explicit reference instrument.
109    #[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    /// Creates a series with the legacy `<UNDERLYING>.<VENUE>` reference instrument.
127    ///
128    /// # Panics
129    ///
130    /// Panics if `underlying` is empty or whitespace-only.
131    #[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    /// Creates a series from a date string and an optional typed reference instrument.
148    ///
149    /// The date accepts `YYYY-MM-DD`, RFC 3339, integer nanoseconds, or floating-point seconds.
150    /// An absent reference derives `<UNDERLYING>.<VENUE>`.
151    ///
152    /// # Errors
153    ///
154    /// Returns an error if the venue, derived underlying instrument, or expiration is invalid.
155    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    /// Creates a series from an expiration timestamp and an optional typed reference instrument.
191    ///
192    /// An absent reference derives `<UNDERLYING>.<VENUE>`.
193    ///
194    /// # Errors
195    ///
196    /// Returns an error if the venue or derived underlying instrument is invalid.
197    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    /// Returns the canonical wire representation with exact nanosecond expiry.
227    #[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    /// Creates a series from a crypto option and its explicit reference instrument.
247    #[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    /// Returns whether the reference is `<UNDERLYING>.<VENUE>`.
262    #[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        // Date parses as midnight UTC (1699920000 seconds)
500        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}