1use 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#[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 pub venue: Venue,
44 pub underlying: Ustr,
46 pub settlement_currency: Ustr,
48 pub expiration_ns: UnixNanos,
50}
51
52#[derive(Clone, Debug, Error, Eq, PartialEq)]
54pub enum OptionSeriesIdError {
55 #[error(
57 "invalid `OptionSeriesId` value '{value}': expected format 'VENUE:UNDERLYING:SETTLEMENT:EXPIRY'"
58 )]
59 InvalidFormat {
60 value: String,
62 },
63 #[error("invalid `OptionSeriesId` value '{value}': invalid venue: {source}")]
65 InvalidVenue {
66 value: String,
68 source: Box<CorrectnessError>,
70 },
71 #[error(
73 "invalid `OptionSeriesId` value '{value}': invalid expiration '{expiration}': {reason}"
74 )]
75 InvalidExpiration {
76 value: String,
78 expiration: String,
80 reason: String,
82 },
83}
84
85impl OptionSeriesId {
86 #[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 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 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 #[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 #[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 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 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}