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;
25use serde::{Deserialize, Serialize};
26use ustr::Ustr;
27
28use crate::{identifiers::Venue, instruments::CryptoOption};
29
30/// Identifies a unique option series: a specific venue + underlying + settlement currency + expiration.
31#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
32#[cfg_attr(
33    feature = "python",
34    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
35)]
36#[cfg_attr(
37    feature = "python",
38    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
39)]
40pub struct OptionSeriesId {
41    /// The trading venue.
42    pub venue: Venue,
43    /// The underlying asset symbol (e.g. "BTC").
44    pub underlying: Ustr,
45    /// The settlement currency code (e.g. "BTC" for inverse, "USDC" for linear).
46    pub settlement_currency: Ustr,
47    /// UNIX timestamp (nanoseconds) for contract expiration.
48    pub expiration_ns: UnixNanos,
49}
50
51impl OptionSeriesId {
52    /// Creates a new [`OptionSeriesId`] instance.
53    #[must_use]
54    pub fn new(
55        venue: Venue,
56        underlying: Ustr,
57        settlement_currency: Ustr,
58        expiration_ns: UnixNanos,
59    ) -> Self {
60        Self {
61            venue,
62            underlying,
63            settlement_currency,
64            expiration_ns,
65        }
66    }
67
68    /// Creates an [`OptionSeriesId`] from venue name, underlying symbol, settlement currency, and date string.
69    ///
70    /// The `date_str` is parsed via `UnixNanos::FromStr`, which accepts `"YYYY-MM-DD"`,
71    /// RFC 3339 timestamps, integer nanoseconds, or floating-point seconds.
72    ///
73    /// # Errors
74    ///
75    /// Returns an error if `date_str` cannot be parsed as a valid date or timestamp.
76    pub fn from_expiry(
77        venue: &str,
78        underlying: &str,
79        settlement_currency: &str,
80        date_str: &str,
81    ) -> anyhow::Result<Self> {
82        let expiration_ns = UnixNanos::from_str(date_str)
83            .map_err(|e| anyhow::anyhow!("Failed to parse expiry date '{date_str}': {e}"))?;
84        Ok(Self {
85            venue: Venue::new(venue),
86            underlying: Ustr::from(underlying),
87            settlement_currency: Ustr::from(settlement_currency),
88            expiration_ns,
89        })
90    }
91
92    /// Returns the canonical wire representation with nanosecond expiry
93    /// (e.g. `DERIBIT:BTC:BTC:1772524800000000000`).
94    ///
95    /// Used for serialization and persistence where exact round-tripping is required.
96    #[must_use]
97    pub fn to_wire_string(&self) -> String {
98        format!(
99            "{}:{}:{}:{}",
100            self.venue, self.underlying, self.settlement_currency, self.expiration_ns
101        )
102    }
103
104    /// Creates an [`OptionSeriesId`] from a [`CryptoOption`] instrument.
105    #[must_use]
106    pub fn from_crypto_option(option: &CryptoOption) -> Self {
107        Self {
108            venue: option.id.venue,
109            underlying: option.underlying.code,
110            settlement_currency: option.settlement_currency.code,
111            expiration_ns: option.expiration_ns,
112        }
113    }
114}
115
116impl Display for OptionSeriesId {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        let dt = self.expiration_ns.to_datetime_utc();
119        write!(
120            f,
121            "{}:{}:{}:{}",
122            self.venue,
123            self.underlying,
124            self.settlement_currency,
125            dt.format("%Y-%m-%dT%H:%M:%SZ"),
126        )
127    }
128}
129
130impl Debug for OptionSeriesId {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        let dt = self.expiration_ns.to_datetime_utc();
133        write!(
134            f,
135            "\"{}:{}:{}:{}\"",
136            self.venue,
137            self.underlying,
138            self.settlement_currency,
139            dt.format("%Y-%m-%dT%H:%M:%SZ"),
140        )
141    }
142}
143
144impl FromStr for OptionSeriesId {
145    type Err = anyhow::Error;
146
147    /// Parses `VENUE:UNDERLYING:SETTLEMENT:EXPIRY` where EXPIRY can be
148    /// nanoseconds (`1772524800000000000`) or a date (`2026-03-03`).
149    fn from_str(s: &str) -> anyhow::Result<Self> {
150        let parts: Vec<&str> = s.splitn(4, ':').collect();
151        if parts.len() != 4 {
152            anyhow::bail!(
153                "Error parsing `OptionSeriesId` from '{s}': expected format 'VENUE:UNDERLYING:SETTLEMENT:EXPIRY'"
154            );
155        }
156
157        let venue = Venue::new(parts[0]);
158        let underlying = Ustr::from(parts[1]);
159        let settlement_currency = Ustr::from(parts[2]);
160        let expiration_ns = UnixNanos::from_str(parts[3]).map_err(|e| {
161            anyhow::anyhow!(
162                "Error parsing `OptionSeriesId` expiration from '{}': {e}",
163                parts[3]
164            )
165        })?;
166
167        Ok(Self {
168            venue,
169            underlying,
170            settlement_currency,
171            expiration_ns,
172        })
173    }
174}
175
176impl Serialize for OptionSeriesId {
177    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
178    where
179        S: serde::Serializer,
180    {
181        serializer.serialize_str(&self.to_wire_string())
182    }
183}
184
185impl<'de> Deserialize<'de> for OptionSeriesId {
186    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
187    where
188        D: serde::Deserializer<'de>,
189    {
190        let s: &str = Deserialize::deserialize(deserializer)?;
191        Self::from_str(s).map_err(serde::de::Error::custom)
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use rstest::*;
198
199    use super::*;
200
201    fn test_series_id() -> OptionSeriesId {
202        OptionSeriesId::new(
203            Venue::new("DERIBIT"),
204            Ustr::from("BTC"),
205            Ustr::from("BTC"),
206            UnixNanos::from(1_700_000_000_000_000_000u64),
207        )
208    }
209
210    #[rstest]
211    fn test_option_series_id_new() {
212        let venue = Venue::new("DERIBIT");
213        let underlying = Ustr::from("BTC");
214        let settlement = Ustr::from("BTC");
215        let expiration_ns = UnixNanos::from(1_700_000_000_000_000_000u64);
216
217        let id = OptionSeriesId::new(venue, underlying, settlement, expiration_ns);
218
219        assert_eq!(id.venue, venue);
220        assert_eq!(id.underlying, underlying);
221        assert_eq!(id.settlement_currency, settlement);
222        assert_eq!(id.expiration_ns, expiration_ns);
223    }
224
225    #[rstest]
226    fn test_option_series_id_display() {
227        let id = test_series_id();
228        assert_eq!(id.to_string(), "DERIBIT:BTC:BTC:2023-11-14T22:13:20Z");
229    }
230
231    #[rstest]
232    fn test_option_series_id_wire_string() {
233        let id = test_series_id();
234        assert_eq!(id.to_wire_string(), "DERIBIT:BTC:BTC:1700000000000000000");
235    }
236
237    #[rstest]
238    fn test_option_series_id_debug() {
239        let id = test_series_id();
240        assert_eq!(
241            format!("{id:?}"),
242            "\"DERIBIT:BTC:BTC:2023-11-14T22:13:20Z\""
243        );
244    }
245
246    #[rstest]
247    fn test_option_series_id_from_str() {
248        let id = OptionSeriesId::from_str("DERIBIT:BTC:BTC:1700000000000000000").unwrap();
249
250        assert_eq!(id.venue, Venue::new("DERIBIT"));
251        assert_eq!(id.underlying, Ustr::from("BTC"));
252        assert_eq!(id.settlement_currency, Ustr::from("BTC"));
253        assert_eq!(
254            id.expiration_ns,
255            UnixNanos::from(1_700_000_000_000_000_000u64)
256        );
257    }
258
259    #[rstest]
260    fn test_option_series_id_from_str_rfc3339() {
261        let id = OptionSeriesId::from_str("DERIBIT:BTC:BTC:2023-11-14T22:13:20Z").unwrap();
262        assert_eq!(id.venue, Venue::new("DERIBIT"));
263        assert_eq!(id.underlying, Ustr::from("BTC"));
264        assert_eq!(
265            id.expiration_ns,
266            UnixNanos::from(1_700_000_000_000_000_000u64)
267        );
268    }
269
270    #[rstest]
271    fn test_option_series_id_from_str_date() {
272        let id = OptionSeriesId::from_str("DERIBIT:BTC:BTC:2023-11-14").unwrap();
273        assert_eq!(id.venue, Venue::new("DERIBIT"));
274        assert_eq!(id.underlying, Ustr::from("BTC"));
275        // Date parses as midnight UTC (1699920000 seconds)
276        assert_eq!(
277            id.expiration_ns,
278            UnixNanos::from(1_699_920_000_000_000_000u64)
279        );
280    }
281
282    #[rstest]
283    fn test_option_series_id_from_str_invalid_format() {
284        assert!(OptionSeriesId::from_str("DERIBIT:BTC:BTC").is_err());
285    }
286
287    #[rstest]
288    fn test_option_series_id_from_str_invalid_expiry() {
289        assert!(OptionSeriesId::from_str("DERIBIT:BTC:BTC:not_a_date").is_err());
290    }
291
292    #[rstest]
293    fn test_option_series_id_inequality() {
294        let id1 = test_series_id();
295        let id2 = OptionSeriesId::new(
296            Venue::new("DERIBIT"),
297            Ustr::from("ETH"),
298            Ustr::from("ETH"),
299            UnixNanos::from(1_700_000_000_000_000_000u64),
300        );
301        assert_ne!(id1, id2);
302    }
303
304    #[rstest]
305    fn test_option_series_id_hash() {
306        use std::collections::HashSet;
307
308        let id1 = test_series_id();
309        let id2 = OptionSeriesId::new(
310            Venue::new("DERIBIT"),
311            Ustr::from("ETH"),
312            Ustr::from("ETH"),
313            UnixNanos::from(1_700_000_000_000_000_000u64),
314        );
315
316        let mut set = HashSet::new();
317        set.insert(id1);
318        set.insert(id2);
319        set.insert(id1); // duplicate
320
321        assert_eq!(set.len(), 2);
322    }
323
324    #[rstest]
325    fn test_option_series_id_serde_roundtrip() {
326        let id = test_series_id();
327
328        let json = serde_json::to_string(&id).unwrap();
329        let deserialized: OptionSeriesId = serde_json::from_str(&json).unwrap();
330
331        assert_eq!(id, deserialized);
332    }
333
334    #[rstest]
335    fn test_from_expiry_happy_path() {
336        let id = OptionSeriesId::from_expiry("DERIBIT", "BTC", "BTC", "2025-03-28").unwrap();
337        assert_eq!(id.venue, Venue::new("DERIBIT"));
338        assert_eq!(id.underlying, Ustr::from("BTC"));
339        assert_eq!(id.settlement_currency, Ustr::from("BTC"));
340        assert!(id.expiration_ns.as_u64() > 0);
341    }
342
343    #[rstest]
344    fn test_from_expiry_invalid_date() {
345        let result = OptionSeriesId::from_expiry("DERIBIT", "BTC", "BTC", "not-a-date");
346        assert!(result.is_err());
347    }
348
349    #[rstest]
350    fn test_from_expiry_roundtrip() {
351        let id = OptionSeriesId::from_expiry("DERIBIT", "ETH", "ETH", "2025-06-27").unwrap();
352        let s = id.to_string();
353        let parsed = OptionSeriesId::from_str(&s).unwrap();
354        assert_eq!(id, parsed);
355    }
356}