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.core.nautilus_pyo3.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 value = format!("{venue}:{underlying}:{settlement_currency}:{date_str}");
117        let venue =
118            Venue::new_checked(venue).map_err(|source| OptionSeriesIdError::InvalidVenue {
119                value: value.clone(),
120                source: Box::new(source),
121            })?;
122        let expiration_ns =
123            UnixNanos::from_str(date_str).map_err(|e| OptionSeriesIdError::InvalidExpiration {
124                value: value.clone(),
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    /// Returns the canonical wire representation with nanosecond expiry
138    /// (e.g. `DERIBIT:BTC:BTC:1772524800000000000`).
139    ///
140    /// Used for serialization and persistence where exact round-tripping is required.
141    #[must_use]
142    pub fn to_wire_string(&self) -> String {
143        format!(
144            "{}:{}:{}:{}",
145            self.venue, self.underlying, self.settlement_currency, self.expiration_ns
146        )
147    }
148
149    /// Creates an [`OptionSeriesId`] from a [`CryptoOption`] instrument.
150    #[must_use]
151    pub fn from_crypto_option(option: &CryptoOption) -> Self {
152        Self {
153            venue: option.id.venue,
154            underlying: option.underlying.code,
155            settlement_currency: option.settlement_currency.code,
156            expiration_ns: option.expiration_ns,
157        }
158    }
159}
160
161impl Display for OptionSeriesId {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        let dt = self.expiration_ns.to_datetime_utc();
164        write!(
165            f,
166            "{}:{}:{}:{}",
167            self.venue,
168            self.underlying,
169            self.settlement_currency,
170            dt.format("%Y-%m-%dT%H:%M:%SZ"),
171        )
172    }
173}
174
175impl Debug for OptionSeriesId {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        let dt = self.expiration_ns.to_datetime_utc();
178        write!(
179            f,
180            "\"{}:{}:{}:{}\"",
181            self.venue,
182            self.underlying,
183            self.settlement_currency,
184            dt.format("%Y-%m-%dT%H:%M:%SZ"),
185        )
186    }
187}
188
189impl FromStr for OptionSeriesId {
190    type Err = OptionSeriesIdError;
191
192    /// Parses `VENUE:UNDERLYING:SETTLEMENT:EXPIRY` where EXPIRY can be
193    /// nanoseconds (`1772524800000000000`) or a date (`2026-03-03`).
194    fn from_str(s: &str) -> Result<Self, Self::Err> {
195        let value = s.to_string();
196        let parts: Vec<&str> = s.splitn(4, ':').collect();
197        if parts.len() != 4 {
198            return Err(OptionSeriesIdError::InvalidFormat { value });
199        }
200
201        let venue =
202            Venue::new_checked(parts[0]).map_err(|source| OptionSeriesIdError::InvalidVenue {
203                value: value.clone(),
204                source: Box::new(source),
205            })?;
206        let underlying = Ustr::from(parts[1]);
207        let settlement_currency = Ustr::from(parts[2]);
208        let expiration_ns =
209            UnixNanos::from_str(parts[3]).map_err(|e| OptionSeriesIdError::InvalidExpiration {
210                value: value.clone(),
211                expiration: parts[3].to_string(),
212                reason: e.to_string(),
213            })?;
214
215        Ok(Self {
216            venue,
217            underlying,
218            settlement_currency,
219            expiration_ns,
220        })
221    }
222}
223
224impl Serialize for OptionSeriesId {
225    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
226    where
227        S: serde::Serializer,
228    {
229        serializer.serialize_str(&self.to_wire_string())
230    }
231}
232
233impl<'de> Deserialize<'de> for OptionSeriesId {
234    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
235    where
236        D: serde::Deserializer<'de>,
237    {
238        let s: std::borrow::Cow<'de, str> = Deserialize::deserialize(deserializer)?;
239        Self::from_str(s.as_ref()).map_err(serde::de::Error::custom)
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use rstest::*;
246
247    use super::*;
248
249    fn test_series_id() -> OptionSeriesId {
250        OptionSeriesId::new(
251            Venue::new("DERIBIT"),
252            Ustr::from("BTC"),
253            Ustr::from("BTC"),
254            UnixNanos::from(1_700_000_000_000_000_000u64),
255        )
256    }
257
258    #[rstest]
259    fn test_option_series_id_new() {
260        let venue = Venue::new("DERIBIT");
261        let underlying = Ustr::from("BTC");
262        let settlement = Ustr::from("BTC");
263        let expiration_ns = UnixNanos::from(1_700_000_000_000_000_000u64);
264
265        let id = OptionSeriesId::new(venue, underlying, settlement, expiration_ns);
266
267        assert_eq!(id.venue, venue);
268        assert_eq!(id.underlying, underlying);
269        assert_eq!(id.settlement_currency, settlement);
270        assert_eq!(id.expiration_ns, expiration_ns);
271    }
272
273    #[rstest]
274    fn test_option_series_id_display() {
275        let id = test_series_id();
276        assert_eq!(id.to_string(), "DERIBIT:BTC:BTC:2023-11-14T22:13:20Z");
277    }
278
279    #[rstest]
280    fn test_option_series_id_wire_string() {
281        let id = test_series_id();
282        assert_eq!(id.to_wire_string(), "DERIBIT:BTC:BTC:1700000000000000000");
283    }
284
285    #[rstest]
286    fn test_option_series_id_debug() {
287        let id = test_series_id();
288        assert_eq!(
289            format!("{id:?}"),
290            "\"DERIBIT:BTC:BTC:2023-11-14T22:13:20Z\""
291        );
292    }
293
294    #[rstest]
295    fn test_option_series_id_from_str() {
296        let id = OptionSeriesId::from_str("DERIBIT:BTC:BTC:1700000000000000000").unwrap();
297
298        assert_eq!(id.venue, Venue::new("DERIBIT"));
299        assert_eq!(id.underlying, Ustr::from("BTC"));
300        assert_eq!(id.settlement_currency, Ustr::from("BTC"));
301        assert_eq!(
302            id.expiration_ns,
303            UnixNanos::from(1_700_000_000_000_000_000u64)
304        );
305    }
306
307    #[rstest]
308    fn test_option_series_id_from_str_rfc3339() {
309        let id = OptionSeriesId::from_str("DERIBIT:BTC:BTC:2023-11-14T22:13:20Z").unwrap();
310        assert_eq!(id.venue, Venue::new("DERIBIT"));
311        assert_eq!(id.underlying, Ustr::from("BTC"));
312        assert_eq!(
313            id.expiration_ns,
314            UnixNanos::from(1_700_000_000_000_000_000u64)
315        );
316    }
317
318    #[rstest]
319    fn test_option_series_id_from_str_date() {
320        let id = OptionSeriesId::from_str("DERIBIT:BTC:BTC:2023-11-14").unwrap();
321        assert_eq!(id.venue, Venue::new("DERIBIT"));
322        assert_eq!(id.underlying, Ustr::from("BTC"));
323        // Date parses as midnight UTC (1699920000 seconds)
324        assert_eq!(
325            id.expiration_ns,
326            UnixNanos::from(1_699_920_000_000_000_000u64)
327        );
328    }
329
330    #[rstest]
331    fn test_option_series_id_from_str_invalid_format() {
332        let error = OptionSeriesId::from_str("DERIBIT:BTC:BTC").unwrap_err();
333
334        assert_eq!(
335            error,
336            OptionSeriesIdError::InvalidFormat {
337                value: "DERIBIT:BTC:BTC".to_string(),
338            },
339        );
340        assert_eq!(
341            error.to_string(),
342            "invalid `OptionSeriesId` value 'DERIBIT:BTC:BTC': expected format 'VENUE:UNDERLYING:SETTLEMENT:EXPIRY'",
343        );
344    }
345
346    #[rstest]
347    fn test_option_series_id_from_str_invalid_venue() {
348        let error = OptionSeriesId::from_str("DÉRIBIT:BTC:BTC:1700000000000000000").unwrap_err();
349
350        assert_eq!(
351            error,
352            OptionSeriesIdError::InvalidVenue {
353                value: "DÉRIBIT:BTC:BTC:1700000000000000000".to_string(),
354                source: Box::new(CorrectnessError::NonAsciiString {
355                    param: "value".to_string(),
356                    value: "DÉRIBIT".to_string(),
357                }),
358            },
359        );
360        assert_eq!(
361            error.to_string(),
362            concat!(
363                "invalid `OptionSeriesId` value 'DÉRIBIT:BTC:BTC:1700000000000000000': ",
364                "invalid venue: invalid string for 'value' contained a non-ASCII char, ",
365                "was 'DÉRIBIT'",
366            ),
367        );
368    }
369
370    #[rstest]
371    fn test_option_series_id_from_str_invalid_expiry() {
372        let error = OptionSeriesId::from_str("DERIBIT:BTC:BTC:not_a_date").unwrap_err();
373
374        assert_eq!(
375            error,
376            OptionSeriesIdError::InvalidExpiration {
377                value: "DERIBIT:BTC:BTC:not_a_date".to_string(),
378                expiration: "not_a_date".to_string(),
379                reason: "Invalid format: not_a_date".to_string(),
380            },
381        );
382        assert_eq!(
383            error.to_string(),
384            concat!(
385                "invalid `OptionSeriesId` value 'DERIBIT:BTC:BTC:not_a_date': ",
386                "invalid expiration 'not_a_date': Invalid format: not_a_date",
387            ),
388        );
389    }
390
391    #[rstest]
392    fn test_option_series_id_inequality() {
393        let id1 = test_series_id();
394        let id2 = OptionSeriesId::new(
395            Venue::new("DERIBIT"),
396            Ustr::from("ETH"),
397            Ustr::from("ETH"),
398            UnixNanos::from(1_700_000_000_000_000_000u64),
399        );
400        assert_ne!(id1, id2);
401    }
402
403    #[rstest]
404    fn test_option_series_id_hash() {
405        use std::collections::HashSet;
406
407        let id1 = test_series_id();
408        let id2 = OptionSeriesId::new(
409            Venue::new("DERIBIT"),
410            Ustr::from("ETH"),
411            Ustr::from("ETH"),
412            UnixNanos::from(1_700_000_000_000_000_000u64),
413        );
414
415        let mut set = HashSet::new();
416        set.insert(id1);
417        set.insert(id2);
418        set.insert(id1); // duplicate
419
420        assert_eq!(set.len(), 2);
421    }
422
423    #[rstest]
424    fn test_option_series_id_serde_roundtrip() {
425        let id = test_series_id();
426
427        let json = serde_json::to_string(&id).unwrap();
428        let deserialized: OptionSeriesId = serde_json::from_str(&json).unwrap();
429
430        assert_eq!(id, deserialized);
431    }
432
433    #[rstest]
434    fn test_option_series_id_deserialize_from_owned_value() {
435        let id = test_series_id();
436        let value = serde_json::Value::String(id.to_wire_string());
437
438        let deserialized: OptionSeriesId = serde_json::from_value(value).unwrap();
439        assert_eq!(id, deserialized);
440    }
441
442    #[rstest]
443    fn test_from_expiry_happy_path() {
444        let id = OptionSeriesId::from_expiry("DERIBIT", "BTC", "BTC", "2025-03-28").unwrap();
445        assert_eq!(id.venue, Venue::new("DERIBIT"));
446        assert_eq!(id.underlying, Ustr::from("BTC"));
447        assert_eq!(id.settlement_currency, Ustr::from("BTC"));
448        assert!(id.expiration_ns.as_u64() > 0);
449    }
450
451    #[rstest]
452    fn test_from_expiry_invalid_date() {
453        let result = OptionSeriesId::from_expiry("DERIBIT", "BTC", "BTC", "not-a-date");
454        let error = result.unwrap_err();
455
456        assert_eq!(
457            error,
458            OptionSeriesIdError::InvalidExpiration {
459                value: "DERIBIT:BTC:BTC:not-a-date".to_string(),
460                expiration: "not-a-date".to_string(),
461                reason: "Invalid format: not-a-date".to_string(),
462            },
463        );
464    }
465
466    #[rstest]
467    fn test_from_expiry_invalid_venue() {
468        let error = OptionSeriesId::from_expiry("DÉRIBIT", "BTC", "BTC", "2025-03-28").unwrap_err();
469
470        assert_eq!(
471            error,
472            OptionSeriesIdError::InvalidVenue {
473                value: "DÉRIBIT:BTC:BTC:2025-03-28".to_string(),
474                source: Box::new(CorrectnessError::NonAsciiString {
475                    param: "value".to_string(),
476                    value: "DÉRIBIT".to_string(),
477                }),
478            },
479        );
480    }
481
482    #[rstest]
483    fn test_from_expiry_roundtrip() {
484        let id = OptionSeriesId::from_expiry("DERIBIT", "ETH", "ETH", "2025-06-27").unwrap();
485        let s = id.to_string();
486        let parsed = OptionSeriesId::from_str(&s).unwrap();
487        assert_eq!(id, parsed);
488    }
489}