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.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 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 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 #[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 #[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 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 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); 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}