nautilus_model/identifiers/
option_series_id.rs1use 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#[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 pub venue: Venue,
43 pub underlying: Ustr,
45 pub settlement_currency: Ustr,
47 pub expiration_ns: UnixNanos,
49}
50
51impl OptionSeriesId {
52 #[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 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 #[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 #[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 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 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); 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}