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 a unique option series identifier (venue + underlying + expiry).
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::{identifiers::Venue, instruments::CryptoOption};
30
31/// Identifies a unique option series: a specific venue + underlying + settlement currency + expiration.
32#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
33#[cfg_attr(
34    feature = "python",
35    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
36)]
37#[cfg_attr(
38    feature = "python",
39    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
40)]
41pub struct OptionSeriesId {
42    /// The trading venue.
43    pub venue: Venue,
44    /// The underlying asset symbol (e.g. "BTC").
45    pub underlying: Ustr,
46    /// The settlement currency code (e.g. "BTC" for inverse, "USDC" for linear).
47    pub settlement_currency: Ustr,
48    /// UNIX timestamp (nanoseconds) for contract expiration.
49    pub expiration_ns: UnixNanos,
50}
51
52/// Error returned when a value is not a valid [`OptionSeriesId`].
53#[derive(Clone, Debug, Error, Eq, PartialEq)]
54pub enum OptionSeriesIdError {
55    /// The value does not match the expected four-component format.
56    #[error(
57        "invalid `OptionSeriesId` value '{value}': expected format 'VENUE:UNDERLYING:SETTLEMENT:EXPIRY'"
58    )]
59    InvalidFormat {
60        /// The invalid identifier value.
61        value: String,
62    },
63    /// The venue component is invalid.
64    #[error("invalid `OptionSeriesId` value '{value}': invalid venue: {source}")]
65    InvalidVenue {
66        /// The invalid identifier value.
67        value: String,
68        /// The venue validation failure.
69        source: Box<CorrectnessError>,
70    },
71    /// The expiration component is invalid.
72    #[error(
73        "invalid `OptionSeriesId` value '{value}': invalid expiration '{expiration}': {reason}"
74    )]
75    InvalidExpiration {
76        /// The invalid identifier value.
77        value: String,
78        /// The invalid expiration component.
79        expiration: String,
80        /// The expiration validation failure.
81        reason: String,
82    },
83}
84
85impl OptionSeriesId {
86    /// Creates a new [`OptionSeriesId`] instance.
87    #[must_use]
88    pub fn new(
89        venue: Venue,
90        underlying: Ustr,
91        settlement_currency: Ustr,
92        expiration_ns: UnixNanos,
93    ) -> Self {
94        Self {
95            venue,
96            underlying,
97            settlement_currency,
98            expiration_ns,
99        }
100    }
101
102    /// Creates an [`OptionSeriesId`] from venue name, underlying symbol, settlement currency, and date string.
103    ///
104    /// The `date_str` is parsed via `UnixNanos::FromStr`, which accepts `"YYYY-MM-DD"`,
105    /// RFC 3339 timestamps, integer nanoseconds, or floating-point seconds.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if `venue` or `date_str` is invalid.
110    pub fn from_expiry(
111        venue: &str,
112        underlying: &str,
113        settlement_currency: &str,
114        date_str: &str,
115    ) -> Result<Self, OptionSeriesIdError> {
116        let format_value = || format!("{venue}:{underlying}:{settlement_currency}:{date_str}");
117        let venue =
118            Venue::new_checked(venue).map_err(|source| OptionSeriesIdError::InvalidVenue {
119                value: format_value(),
120                source: Box::new(source),
121            })?;
122        let expiration_ns =
123            UnixNanos::from_str(date_str).map_err(|e| OptionSeriesIdError::InvalidExpiration {
124                value: format_value(),
125                expiration: date_str.to_string(),
126                reason: e.to_string(),
127            })?;
128
129        Ok(Self {
130            venue,
131            underlying: Ustr::from(underlying),
132            settlement_currency: Ustr::from(settlement_currency),
133            expiration_ns,
134        })
135    }
136
137    /// Creates an [`OptionSeriesId`] from venue name, underlying symbol, settlement currency, and
138    /// expiration timestamp.
139    ///
140    /// # Errors
141    ///
142    /// Returns an error if `venue` is invalid.
143    pub fn from_expiry_ns(
144        venue: &str,
145        underlying: &str,
146        settlement_currency: &str,
147        expiration_ns: UnixNanos,
148    ) -> Result<Self, OptionSeriesIdError> {
149        let venue =
150            Venue::new_checked(venue).map_err(|source| OptionSeriesIdError::InvalidVenue {
151                value: format!("{venue}:{underlying}:{settlement_currency}:{expiration_ns}"),
152                source: Box::new(source),
153            })?;
154
155        Ok(Self {
156            venue,
157            underlying: Ustr::from(underlying),
158            settlement_currency: Ustr::from(settlement_currency),
159            expiration_ns,
160        })
161    }
162
163    /// Returns the canonical wire representation with nanosecond expiry
164    /// (e.g. `DERIBIT:BTC:BTC:1772524800000000000`).
165    ///
166    /// Used for serialization and persistence where exact round-tripping is required.
167    #[must_use]
168    pub fn to_wire_string(&self) -> String {
169        format!(
170            "{}:{}:{}:{}",
171            self.venue, self.underlying, self.settlement_currency, self.expiration_ns
172        )
173    }
174
175    /// Creates an [`OptionSeriesId`] from a [`CryptoOption`] instrument.
176    #[must_use]
177    pub fn from_crypto_option(option: &CryptoOption) -> Self {
178        Self {
179            venue: option.id.venue,
180            underlying: option.underlying.code,
181            settlement_currency: option.settlement_currency.code,
182            expiration_ns: option.expiration_ns,
183        }
184    }
185}
186
187impl Display for OptionSeriesId {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        let dt = self.expiration_ns.to_datetime_utc();
190        write!(
191            f,
192            "{}:{}:{}:{}",
193            self.venue,
194            self.underlying,
195            self.settlement_currency,
196            dt.strftime("%Y-%m-%dT%H:%M:%SZ"),
197        )
198    }
199}
200
201impl Debug for OptionSeriesId {
202    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        let dt = self.expiration_ns.to_datetime_utc();
204        write!(
205            f,
206            "\"{}:{}:{}:{}\"",
207            self.venue,
208            self.underlying,
209            self.settlement_currency,
210            dt.strftime("%Y-%m-%dT%H:%M:%SZ"),
211        )
212    }
213}
214
215impl FromStr for OptionSeriesId {
216    type Err = OptionSeriesIdError;
217
218    /// Parses `VENUE:UNDERLYING:SETTLEMENT:EXPIRY` where EXPIRY can be
219    /// nanoseconds (`1772524800000000000`) or a date (`2026-03-03`).
220    fn from_str(s: &str) -> Result<Self, Self::Err> {
221        let mut parts = s.splitn(4, ':');
222        let (Some(venue), Some(underlying), Some(settlement_currency), Some(expiration)) =
223            (parts.next(), parts.next(), parts.next(), parts.next())
224        else {
225            return Err(OptionSeriesIdError::InvalidFormat {
226                value: s.to_string(),
227            });
228        };
229
230        let venue =
231            Venue::new_checked(venue).map_err(|source| OptionSeriesIdError::InvalidVenue {
232                value: s.to_string(),
233                source: Box::new(source),
234            })?;
235        let underlying = Ustr::from(underlying);
236        let settlement_currency = Ustr::from(settlement_currency);
237        let expiration_ns = UnixNanos::from_str(expiration).map_err(|e| {
238            OptionSeriesIdError::InvalidExpiration {
239                value: s.to_string(),
240                expiration: expiration.to_string(),
241                reason: e.to_string(),
242            }
243        })?;
244
245        Ok(Self {
246            venue,
247            underlying,
248            settlement_currency,
249            expiration_ns,
250        })
251    }
252}
253
254impl Serialize for OptionSeriesId {
255    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
256    where
257        S: serde::Serializer,
258    {
259        serializer.serialize_str(&self.to_wire_string())
260    }
261}
262
263impl<'de> Deserialize<'de> for OptionSeriesId {
264    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
265    where
266        D: serde::Deserializer<'de>,
267    {
268        let s: std::borrow::Cow<'de, str> = Deserialize::deserialize(deserializer)?;
269        Self::from_str(s.as_ref()).map_err(serde::de::Error::custom)
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use std::collections::HashSet;
276
277    use rstest::*;
278
279    use super::*;
280    use crate::{instruments::stubs::crypto_option_btc_deribit, types::Currency};
281
282    fn test_series_id() -> OptionSeriesId {
283        OptionSeriesId::new(
284            Venue::new("DERIBIT"),
285            Ustr::from("BTC"),
286            Ustr::from("BTC"),
287            UnixNanos::from(1_700_000_000_000_000_000u64),
288        )
289    }
290
291    #[rstest]
292    fn test_option_series_id_new() {
293        let venue = Venue::new("DERIBIT");
294        let underlying = Ustr::from("BTC");
295        let settlement = Ustr::from("BTC");
296        let expiration_ns = UnixNanos::from(1_700_000_000_000_000_000u64);
297
298        let id = OptionSeriesId::new(venue, underlying, settlement, expiration_ns);
299
300        assert_eq!(id.venue, venue);
301        assert_eq!(id.underlying, underlying);
302        assert_eq!(id.settlement_currency, settlement);
303        assert_eq!(id.expiration_ns, expiration_ns);
304    }
305
306    #[rstest]
307    fn test_option_series_id_display() {
308        let id = test_series_id();
309        assert_eq!(id.to_string(), "DERIBIT:BTC:BTC:2023-11-14T22:13:20Z");
310    }
311
312    #[rstest]
313    fn test_option_series_id_wire_string() {
314        let id = test_series_id();
315        assert_eq!(id.to_wire_string(), "DERIBIT:BTC:BTC:1700000000000000000");
316    }
317
318    #[rstest]
319    fn test_option_series_id_debug() {
320        let id = test_series_id();
321        assert_eq!(
322            format!("{id:?}"),
323            "\"DERIBIT:BTC:BTC:2023-11-14T22:13:20Z\""
324        );
325    }
326
327    #[rstest]
328    fn test_option_series_id_from_str() {
329        let id = OptionSeriesId::from_str("DERIBIT:BTC:BTC:1700000000000000000").unwrap();
330
331        assert_eq!(id.venue, Venue::new("DERIBIT"));
332        assert_eq!(id.underlying, Ustr::from("BTC"));
333        assert_eq!(id.settlement_currency, Ustr::from("BTC"));
334        assert_eq!(
335            id.expiration_ns,
336            UnixNanos::from(1_700_000_000_000_000_000u64)
337        );
338    }
339
340    #[rstest]
341    fn test_option_series_id_from_str_rfc3339() {
342        let id = OptionSeriesId::from_str("DERIBIT:BTC:BTC:2023-11-14T22:13:20Z").unwrap();
343        assert_eq!(id.venue, Venue::new("DERIBIT"));
344        assert_eq!(id.underlying, Ustr::from("BTC"));
345        assert_eq!(
346            id.expiration_ns,
347            UnixNanos::from(1_700_000_000_000_000_000u64)
348        );
349    }
350
351    #[rstest]
352    fn test_option_series_id_from_str_date() {
353        let id = OptionSeriesId::from_str("DERIBIT:BTC:BTC:2023-11-14").unwrap();
354        assert_eq!(id.venue, Venue::new("DERIBIT"));
355        assert_eq!(id.underlying, Ustr::from("BTC"));
356        // Date parses as midnight UTC (1699920000 seconds)
357        assert_eq!(
358            id.expiration_ns,
359            UnixNanos::from(1_699_920_000_000_000_000u64)
360        );
361    }
362
363    #[rstest]
364    fn test_option_series_id_from_str_invalid_format() {
365        let error = OptionSeriesId::from_str("DERIBIT:BTC:BTC").unwrap_err();
366
367        assert_eq!(
368            error,
369            OptionSeriesIdError::InvalidFormat {
370                value: "DERIBIT:BTC:BTC".to_string(),
371            },
372        );
373        assert_eq!(
374            error.to_string(),
375            "invalid `OptionSeriesId` value 'DERIBIT:BTC:BTC': expected format 'VENUE:UNDERLYING:SETTLEMENT:EXPIRY'",
376        );
377    }
378
379    #[rstest]
380    fn test_option_series_id_from_str_invalid_venue() {
381        let error = OptionSeriesId::from_str("DÉRIBIT:BTC:BTC:1700000000000000000").unwrap_err();
382
383        assert_eq!(
384            error,
385            OptionSeriesIdError::InvalidVenue {
386                value: "DÉRIBIT:BTC:BTC:1700000000000000000".to_string(),
387                source: Box::new(CorrectnessError::NonAsciiString {
388                    param: "value".to_string(),
389                    value: "DÉRIBIT".to_string(),
390                }),
391            },
392        );
393        assert_eq!(
394            error.to_string(),
395            concat!(
396                "invalid `OptionSeriesId` value 'DÉRIBIT:BTC:BTC:1700000000000000000': ",
397                "invalid venue: invalid string for 'value' contained a non-ASCII char, ",
398                "was 'DÉRIBIT'",
399            ),
400        );
401    }
402
403    #[rstest]
404    fn test_option_series_id_from_str_invalid_expiry() {
405        let error = OptionSeriesId::from_str("DERIBIT:BTC:BTC:not_a_date").unwrap_err();
406
407        assert_eq!(
408            error,
409            OptionSeriesIdError::InvalidExpiration {
410                value: "DERIBIT:BTC:BTC:not_a_date".to_string(),
411                expiration: "not_a_date".to_string(),
412                reason: "Invalid format: not_a_date".to_string(),
413            },
414        );
415        assert_eq!(
416            error.to_string(),
417            concat!(
418                "invalid `OptionSeriesId` value 'DERIBIT:BTC:BTC:not_a_date': ",
419                "invalid expiration 'not_a_date': Invalid format: not_a_date",
420            ),
421        );
422    }
423
424    #[rstest]
425    fn test_option_series_id_inequality() {
426        let id1 = test_series_id();
427        let id2 = OptionSeriesId::new(
428            Venue::new("DERIBIT"),
429            Ustr::from("ETH"),
430            Ustr::from("ETH"),
431            UnixNanos::from(1_700_000_000_000_000_000u64),
432        );
433        assert_ne!(id1, id2);
434    }
435
436    #[rstest]
437    fn test_option_series_id_hash() {
438        let id1 = test_series_id();
439        let id2 = OptionSeriesId::new(
440            Venue::new("DERIBIT"),
441            Ustr::from("ETH"),
442            Ustr::from("ETH"),
443            UnixNanos::from(1_700_000_000_000_000_000u64),
444        );
445
446        let mut set = HashSet::new();
447        set.insert(id1);
448        set.insert(id2);
449        set.insert(id1);
450
451        assert_eq!(set.len(), 2);
452    }
453
454    #[rstest]
455    fn test_option_series_id_serde_roundtrip() {
456        let id = test_series_id();
457
458        let json = serde_json::to_string(&id).unwrap();
459        let deserialized: OptionSeriesId = serde_json::from_str(&json).unwrap();
460
461        assert_eq!(id, deserialized);
462    }
463
464    #[rstest]
465    fn test_option_series_id_deserialize_from_owned_value() {
466        let id = test_series_id();
467        let value = serde_json::Value::String(id.to_wire_string());
468
469        let deserialized: OptionSeriesId = serde_json::from_value(value).unwrap();
470        assert_eq!(id, deserialized);
471    }
472
473    #[rstest]
474    fn test_from_expiry_happy_path() {
475        let id = OptionSeriesId::from_expiry("DERIBIT", "BTC", "BTC", "2025-03-28").unwrap();
476        assert_eq!(id.venue, Venue::new("DERIBIT"));
477        assert_eq!(id.underlying, Ustr::from("BTC"));
478        assert_eq!(id.settlement_currency, Ustr::from("BTC"));
479        assert_eq!(
480            id.expiration_ns,
481            UnixNanos::from(1_743_120_000_000_000_000u64)
482        );
483    }
484
485    #[rstest]
486    fn test_from_expiry_invalid_date() {
487        let result = OptionSeriesId::from_expiry("DERIBIT", "BTC", "BTC", "not-a-date");
488        let error = result.unwrap_err();
489
490        assert_eq!(
491            error,
492            OptionSeriesIdError::InvalidExpiration {
493                value: "DERIBIT:BTC:BTC:not-a-date".to_string(),
494                expiration: "not-a-date".to_string(),
495                reason: "Invalid format: not-a-date".to_string(),
496            },
497        );
498    }
499
500    #[rstest]
501    fn test_from_expiry_invalid_venue() {
502        let error = OptionSeriesId::from_expiry("DÉRIBIT", "BTC", "BTC", "2025-03-28").unwrap_err();
503
504        assert_eq!(
505            error,
506            OptionSeriesIdError::InvalidVenue {
507                value: "DÉRIBIT:BTC:BTC:2025-03-28".to_string(),
508                source: Box::new(CorrectnessError::NonAsciiString {
509                    param: "value".to_string(),
510                    value: "DÉRIBIT".to_string(),
511                }),
512            },
513        );
514    }
515
516    #[rstest]
517    fn test_from_expiry_roundtrip() {
518        let id = OptionSeriesId::from_expiry("DERIBIT", "ETH", "ETH", "2025-06-27").unwrap();
519        let s = id.to_string();
520        let parsed = OptionSeriesId::from_str(&s).unwrap();
521        assert_eq!(id, parsed);
522    }
523
524    #[rstest]
525    fn test_from_crypto_option(mut crypto_option_btc_deribit: CryptoOption) {
526        crypto_option_btc_deribit.settlement_currency = Currency::USDC();
527
528        let id = OptionSeriesId::from_crypto_option(&crypto_option_btc_deribit);
529
530        assert_eq!(id.venue, Venue::new("DERIBIT"));
531        assert_eq!(id.underlying, Ustr::from("BTC"));
532        assert_eq!(id.settlement_currency, Ustr::from("USDC"));
533        assert_eq!(
534            id.expiration_ns,
535            UnixNanos::from(1_673_596_800_000_000_000u64)
536        );
537    }
538
539    #[rstest]
540    fn test_from_expiry_ns_happy_path() {
541        let id = OptionSeriesId::from_expiry_ns(
542            "DERIBIT",
543            "ETH",
544            "USDC",
545            UnixNanos::from(1_700_000_000_000_000_000u64),
546        )
547        .unwrap();
548
549        assert_eq!(id.venue, Venue::new("DERIBIT"));
550        assert_eq!(id.underlying, Ustr::from("ETH"));
551        assert_eq!(id.settlement_currency, Ustr::from("USDC"));
552        assert_eq!(
553            id.expiration_ns,
554            UnixNanos::from(1_700_000_000_000_000_000u64)
555        );
556    }
557
558    #[rstest]
559    fn test_from_expiry_ns_empty_venue() {
560        let error = OptionSeriesId::from_expiry_ns(
561            "",
562            "ETH",
563            "USDC",
564            UnixNanos::from(1_700_000_000_000_000_000u64),
565        )
566        .unwrap_err();
567
568        assert_eq!(
569            error,
570            OptionSeriesIdError::InvalidVenue {
571                value: ":ETH:USDC:1700000000000000000".to_string(),
572                source: Box::new(CorrectnessError::EmptyString {
573                    param: "value".to_string(),
574                }),
575            },
576        );
577        assert_eq!(
578            error.to_string(),
579            concat!(
580                "invalid `OptionSeriesId` value ':ETH:USDC:1700000000000000000': ",
581                "invalid venue: invalid string for 'value', was empty",
582            ),
583        );
584    }
585
586    #[rstest]
587    fn test_from_expiry_ns_non_ascii_venue() {
588        let error = OptionSeriesId::from_expiry_ns(
589            "DÉRIBIT",
590            "ETH",
591            "USDC",
592            UnixNanos::from(1_700_000_000_000_000_000u64),
593        )
594        .unwrap_err();
595
596        assert_eq!(
597            error,
598            OptionSeriesIdError::InvalidVenue {
599                value: "DÉRIBIT:ETH:USDC:1700000000000000000".to_string(),
600                source: Box::new(CorrectnessError::NonAsciiString {
601                    param: "value".to_string(),
602                    value: "DÉRIBIT".to_string(),
603                }),
604            },
605        );
606    }
607
608    #[rstest]
609    fn test_from_expiry_ns_accepts_empty_components() {
610        let id = OptionSeriesId::from_expiry_ns(
611            "DERIBIT",
612            "",
613            "",
614            UnixNanos::from(1_700_000_000_000_000_000u64),
615        )
616        .unwrap();
617
618        assert_eq!(id.to_wire_string(), "DERIBIT:::1700000000000000000");
619    }
620}