1use std::str::FromStr;
24
25use jiff::{civil::Date, tz::TimeZone};
26use nautilus_core::UnixNanos;
27use thiserror::Error;
28
29use crate::{
30 enums::OptionKind,
31 identifiers::{InstrumentId, Symbol, Venue},
32 types::{
33 Price,
34 fixed::{check_fixed_precision, raw_scale},
35 },
36};
37
38const MONTH_CODES: &[u8; 12] = b"FGHJKMNQUVXZ";
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct OptionContractSpec {
43 pub underlying_instrument_id: InstrumentId,
45 pub strike: Price,
47 pub expiration_ns: UnixNanos,
49 pub option_kind: OptionKind,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum OptionSymbologyScheme {
56 CmeGlobex {
58 mid_curve: Option<u8>,
60 },
61 Osi {
63 root: Option<String>,
65 },
66}
67
68#[derive(Debug, Clone, Error, PartialEq, Eq)]
70pub enum OptionInstrumentIdError {
71 #[error("invalid future code '{value}': {reason}")]
73 InvalidFutureCode {
74 value: String,
76 reason: String,
78 },
79 #[error("invalid expiration '{value}': {reason}")]
81 InvalidExpiration {
82 value: String,
84 reason: String,
86 },
87 #[error("invalid strike '{value}': {reason}")]
89 InvalidStrike {
90 value: String,
92 reason: String,
94 },
95 #[error("invalid option root '{value}': {reason}")]
97 InvalidRoot {
98 value: String,
100 reason: String,
102 },
103}
104
105impl OptionContractSpec {
106 #[must_use]
108 pub const fn new(
109 underlying_instrument_id: InstrumentId,
110 strike: Price,
111 expiration_ns: UnixNanos,
112 option_kind: OptionKind,
113 ) -> Self {
114 Self {
115 underlying_instrument_id,
116 strike,
117 expiration_ns,
118 option_kind,
119 }
120 }
121
122 pub fn from_expiry(
128 underlying_instrument_id: InstrumentId,
129 strike: Price,
130 expiry: &str,
131 option_kind: OptionKind,
132 ) -> Result<Self, OptionInstrumentIdError> {
133 let expiration_ns = UnixNanos::from_str(expiry).map_err(|e| {
134 OptionInstrumentIdError::InvalidExpiration {
135 value: expiry.to_string(),
136 reason: e.to_string(),
137 }
138 })?;
139
140 Ok(Self::new(
141 underlying_instrument_id,
142 strike,
143 expiration_ns,
144 option_kind,
145 ))
146 }
147
148 #[must_use]
150 pub fn expiry_date(&self) -> Date {
151 TimeZone::UTC
152 .to_datetime(self.expiration_ns.to_datetime_utc())
153 .date()
154 }
155}
156
157pub fn compute_option_instrument_id(
167 spec: &OptionContractSpec,
168 scheme: &OptionSymbologyScheme,
169 venue: Venue,
170) -> Result<InstrumentId, OptionInstrumentIdError> {
171 let symbol = match scheme {
172 OptionSymbologyScheme::CmeGlobex { mid_curve } => cme_globex_option_symbol(
173 spec.underlying_instrument_id.symbol.as_str(),
174 spec.strike,
175 spec.option_kind,
176 spec.expiry_date(),
177 *mid_curve,
178 )?,
179 OptionSymbologyScheme::Osi { root } => osi_option_symbol(
180 root.as_deref()
181 .unwrap_or(spec.underlying_instrument_id.symbol.as_str()),
182 spec.expiry_date(),
183 spec.option_kind,
184 spec.strike,
185 )?,
186 };
187
188 let checked =
189 Symbol::new_checked(&symbol).map_err(|e| OptionInstrumentIdError::InvalidRoot {
190 value: symbol,
191 reason: e.to_string(),
192 })?;
193
194 Ok(InstrumentId::new(checked, venue))
195}
196
197pub fn cme_globex_option_symbol(
213 future_code: &str,
214 strike: Price,
215 option_kind: OptionKind,
216 expiry: Date,
217 mid_curve: Option<u8>,
218) -> Result<String, OptionInstrumentIdError> {
219 let (root, future_month, future_year) = parse_futures_symbol(future_code)?;
220 let day = expiry.weekday().to_monday_one_offset();
221 if day > 5 {
222 return Err(invalid_expiry(
223 expiry,
224 "CME nominal expiry must be a weekday",
225 ));
226 }
227
228 if mid_curve.is_some() && root != "SR3" {
229 return Err(OptionInstrumentIdError::InvalidRoot {
230 value: root.to_string(),
231 reason: "midcurve tenors apply only to SR3".to_string(),
232 });
233 }
234
235 let week = (expiry.day() - 1) / 7 + 1;
236 let month = expiry.month();
237
238 let option_root = match root {
239 "ES" | "NQ" | "RTY" | "MES" | "MNQ" => equity_root(root, future_month, expiry, day, week)?,
240 "SR3" => sofr_root(expiry, mid_curve, day, week)?,
241 "CL" => crude_root(expiry, day, week)?,
242 _ => {
243 return Err(OptionInstrumentIdError::InvalidRoot {
244 value: root.to_string(),
245 reason: "unsupported CME option root".to_string(),
246 });
247 }
248 };
249
250 let month_code = char::from(MONTH_CODES[(month - 1) as usize]);
251 let side = option_letter(option_kind);
252 let year_width = future_year.len();
253 let year_modulus = if year_width == 1 { 10 } else { 100 };
254 let year = expiry.year().rem_euclid(year_modulus);
255 let strike = cme_strike(root, strike)?;
256 Ok(format!(
257 "{option_root}{month_code}{year:0year_width$} {side}{strike}"
258 ))
259}
260
261pub fn osi_option_symbol(
270 root: &str,
271 expiry: Date,
272 option_kind: OptionKind,
273 strike: Price,
274) -> Result<String, OptionInstrumentIdError> {
275 if root.is_empty() || root.len() > 6 || !root.bytes().all(|c| c.is_ascii_alphanumeric()) {
276 return Err(OptionInstrumentIdError::InvalidRoot {
277 value: root.to_string(),
278 reason: "OSI root must be one to six ASCII alphanumeric characters".to_string(),
279 });
280 }
281
282 if !(2000..=2099).contains(&expiry.year()) {
283 return Err(invalid_expiry(
284 expiry,
285 "OSI expiry year must be in [2000, 2099]",
286 ));
287 }
288
289 let reason = "OSI strike must fit eight digits of exact non-negative thousandths";
290 let scaled = strike_digits(strike, 3, reason)?;
291 if scaled > 99_999_999 {
292 return Err(OptionInstrumentIdError::InvalidStrike {
293 value: strike.to_string(),
294 reason: reason.to_string(),
295 });
296 }
297
298 let year = expiry.year() % 100;
299 let month = expiry.month();
300 let day = expiry.day();
301 let side = option_letter(option_kind);
302 Ok(format!(
303 "{root:<6}{year:02}{month:02}{day:02}{side}{scaled:08}"
304 ))
305}
306
307fn equity_root(
308 root: &str,
309 future_month: u32,
310 expiry: Date,
311 day: i8,
312 week: i8,
313) -> Result<String, OptionInstrumentIdError> {
314 if matches!(root, "MES" | "MNQ")
315 && expiry >= Date::new(2026, 6, 29).expect("valid transition date")
316 {
317 return Err(invalid_expiry(
318 expiry,
319 "physical and financial micro option families are ambiguous",
320 ));
321 }
322
323 let (daily, friday, eom, quarterly) = match root {
324 "ES" => ("E", "EW", "EW", "ES"),
325 "NQ" => ("Q", "QN", "QNE", "NQ"),
326 "RTY" => ("R", "R", "RTM", "RTO"),
327 "MES" => ("X", "EX", "EX", "MES"),
328 "MNQ" => ("D", "MQ", "MQE", "MNQ"),
329 _ => unreachable!("equity root dispatched by caller"),
330 };
331
332 if business_days_remaining(expiry) == 0 {
333 return Ok(eom.to_string());
334 }
335
336 if day == 5 && week == 3 && expiry.month() % 3 == 0 && future_month == expiry.month() as u32 {
337 return Ok(quarterly.to_string());
338 }
339
340 if day == 5 {
341 let suffix = if root == "RTY" { "E" } else { "" };
342 return Ok(format!("{friday}{week}{suffix}"));
343 }
344
345 let day_code = match (root, day) {
346 ("RTY", 2) => 'U',
347 (_, 1) => 'A',
348 (_, 2) => 'B',
349 (_, 3) => 'C',
350 _ => 'D',
351 };
352
353 Ok(format!("{daily}{week}{day_code}"))
354}
355
356fn sofr_root(
357 expiry: Date,
358 mid_curve: Option<u8>,
359 day: i8,
360 week: i8,
361) -> Result<String, OptionInstrumentIdError> {
362 let day_fifteen = Date::new(expiry.year(), expiry.month(), 15).expect("valid fifteenth day");
363 let third_wednesday = 15 + (3 - day_fifteen.weekday().to_monday_one_offset()).rem_euclid(7);
364 let monthly = day == 5 && expiry.day() == third_wednesday - 5;
365
366 let base = match mid_curve {
367 None => "SR3".to_string(),
368 Some(tenor @ (0 | 2 | 3 | 4 | 5)) => format!("S{tenor}"),
369 _ => {
370 return Err(OptionInstrumentIdError::InvalidRoot {
371 value: format!("SR3 midcurve {mid_curve:?}"),
372 reason: "unsupported SOFR midcurve tenor".to_string(),
373 });
374 }
375 };
376
377 if monthly {
378 return Ok(base);
379 }
380
381 if day != 5 || !matches!(mid_curve, Some(0 | 2 | 3)) {
382 return Err(invalid_expiry(
383 expiry,
384 "unsupported SOFR weekly expiry or tenor",
385 ));
386 }
387
388 Ok(format!("{base}{week}"))
389}
390
391fn crude_root(expiry: Date, day: i8, week: i8) -> Result<String, OptionInstrumentIdError> {
392 let anchor = Date::new(expiry.year(), expiry.month(), 25).expect("valid twenty-fifth day");
393 let monthly = previous_weekdays(previous_weekdays(anchor, 0)?, 6)?;
394 if expiry == monthly {
395 return Err(invalid_expiry(
396 expiry,
397 "monthly and weekly WTI expiries require a verified definition",
398 ));
399 }
400
401 let prefix = match day {
402 1 => "ML",
403 2 => "NL",
404 3 => "WL",
405 4 => "XL",
406 _ => "LO",
407 };
408
409 Ok(format!("{prefix}{week}"))
410}
411
412fn cme_strike(root: &str, strike: Price) -> Result<u128, OptionInstrumentIdError> {
413 let precision = match root {
414 "SR3" | "CL" => 2,
415 _ => 0,
416 };
417
418 let scaled = strike_digits(
419 strike,
420 precision,
421 "strike cannot be represented as integral CME symbol digits",
422 )?;
423
424 if scaled == 0 {
425 return Err(OptionInstrumentIdError::InvalidStrike {
426 value: strike.to_string(),
427 reason: "strike must be positive".to_string(),
428 });
429 }
430
431 Ok(scaled)
432}
433
434fn strike_digits(
435 strike: Price,
436 precision: u8,
437 reason: &str,
438) -> Result<u128, OptionInstrumentIdError> {
439 if strike.is_undefined()
440 || strike.is_error()
441 || check_fixed_precision(strike.precision).is_err()
442 {
443 return Err(OptionInstrumentIdError::InvalidStrike {
444 value: format!("raw={}, precision={}", strike.raw(), strike.precision),
445 reason: "strike must be a numeric price with valid precision".to_string(),
446 });
447 }
448
449 let divisor = raw_scale(strike.precision) / 10_u128.pow(u32::from(precision));
450 #[allow(
451 clippy::useless_conversion,
452 reason = "raw magnitude is u64 in standard precision and u128 in high precision"
453 )]
454 let raw = u128::from(strike.raw().unsigned_abs());
455 if strike.is_negative() || raw % divisor != 0 {
456 return Err(OptionInstrumentIdError::InvalidStrike {
457 value: strike.to_string(),
458 reason: reason.to_string(),
459 });
460 }
461
462 Ok(raw / divisor)
463}
464
465fn previous_weekdays(mut date: Date, mut count: u8) -> Result<Date, OptionInstrumentIdError> {
466 while date.weekday().to_monday_one_offset() > 5 || count > 0 {
467 date = date
468 .yesterday()
469 .map_err(|e| invalid_expiry(date, &e.to_string()))?;
470
471 if date.weekday().to_monday_one_offset() <= 5 && count > 0 {
472 count -= 1;
473 }
474 }
475
476 Ok(date)
477}
478
479fn business_days_remaining(expiry: Date) -> u8 {
480 let mut days = 0;
481 let mut date = expiry;
482 while let Ok(next) = date.tomorrow() {
483 if next.month() != expiry.month() {
484 break;
485 }
486
487 if next.weekday().to_monday_one_offset() <= 5 {
488 days += 1;
489 }
490
491 date = next;
492 }
493
494 days
495}
496
497fn option_letter(kind: OptionKind) -> char {
498 match kind {
499 OptionKind::Call => 'C',
500 OptionKind::Put => 'P',
501 }
502}
503
504fn invalid_expiry(expiry: Date, reason: &str) -> OptionInstrumentIdError {
505 OptionInstrumentIdError::InvalidExpiration {
506 value: expiry.to_string(),
507 reason: reason.to_string(),
508 }
509}
510
511fn parse_futures_symbol(code: &str) -> Result<(&str, u32, &str), OptionInstrumentIdError> {
512 let invalid = |reason: &str| OptionInstrumentIdError::InvalidFutureCode {
513 value: code.to_string(),
514 reason: reason.to_string(),
515 };
516
517 if !code
518 .bytes()
519 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
520 {
521 return Err(invalid("expected uppercase ASCII letters and digits"));
522 }
523
524 let year_start = code
525 .rfind(|c: char| !c.is_ascii_digit())
526 .map_or(0, |index| index + 1);
527 let head = &code[..year_start];
528 let year = &code[year_start..];
529
530 if !matches!(year.len(), 1 | 2) {
531 return Err(invalid("year must contain one or two digits"));
532 }
533
534 let Some(month_code) = head.as_bytes().last() else {
535 return Err(invalid("missing month code"));
536 };
537
538 let month = MONTH_CODES
539 .iter()
540 .position(|c| c == month_code)
541 .ok_or_else(|| invalid("invalid month code"))?;
542 let root = &head[..head.len() - 1];
543 if root.is_empty() {
544 return Err(invalid("missing future root"));
545 }
546
547 Ok((root, month as u32 + 1, year))
548}
549
550#[cfg(test)]
551mod tests {
552 use rstest::*;
553
554 use super::*;
555 use crate::types::{
556 ERROR_PRICE, PRICE_ERROR, PRICE_UNDEF, fixed::FIXED_PRECISION, price::PriceRaw,
557 };
558
559 #[rstest]
560 #[case(0)]
561 #[case(FIXED_PRECISION)]
562 fn test_option_builders_reject_raw_strike_remainders(#[case] precision: u8) {
563 let scale = PriceRaw::try_from(crate::types::fixed::raw_scale(precision)).unwrap();
564 let strike = Price::from_raw(150 * scale + 1, precision);
565 let cme =
566 cme_globex_option_symbol("ESH6", strike, OptionKind::Call, date(2026, 1, 9), None);
567 let osi = osi_option_symbol("AAPL", date(2026, 1, 9), OptionKind::Call, strike);
568
569 assert!(matches!(
570 cme,
571 Err(OptionInstrumentIdError::InvalidStrike { .. })
572 ));
573 assert!(matches!(
574 osi,
575 Err(OptionInstrumentIdError::InvalidStrike { .. })
576 ));
577 }
578
579 #[rstest]
580 #[case(0)]
581 #[case(FIXED_PRECISION)]
582 fn test_option_builders_preserve_raw_strike(#[case] precision: u8) {
583 let scale = PriceRaw::try_from(crate::types::fixed::raw_scale(precision)).unwrap();
584 let strike = Price::from_raw(75 * scale + scale / 4, precision);
585 let cme =
586 cme_globex_option_symbol("CLH6", strike, OptionKind::Call, date(2026, 1, 9), None)
587 .unwrap();
588 let osi = osi_option_symbol("AAPL", date(2026, 1, 9), OptionKind::Call, strike).unwrap();
589
590 assert_eq!(cme, "LO2F6 C7525");
591 assert_eq!(osi, "AAPL 260109C00075250");
592 }
593
594 #[rstest]
595 #[case(ERROR_PRICE)]
596 #[case(Price::from_raw(PRICE_ERROR, 0))]
597 #[case(Price::from_raw(PRICE_UNDEF, 0))]
598 fn test_option_builders_reject_strike_sentinels(#[case] strike: Price) {
599 let cme =
600 cme_globex_option_symbol("ESH6", strike, OptionKind::Call, date(2026, 1, 9), None);
601 let osi = osi_option_symbol("AAPL", date(2026, 1, 9), OptionKind::Call, strike);
602
603 assert!(matches!(
604 cme,
605 Err(OptionInstrumentIdError::InvalidStrike { .. })
606 ));
607 assert!(matches!(
608 osi,
609 Err(OptionInstrumentIdError::InvalidStrike { .. })
610 ));
611 }
612
613 #[cfg(feature = "high-precision")]
614 #[rstest]
615 fn test_option_builders_reject_strike_beyond_decimal_mantissa() {
616 let scale = PriceRaw::try_from(crate::types::fixed::raw_scale(FIXED_PRECISION)).unwrap();
617 let strike = Price::from_raw(10_000_000_000_000 * scale + 1, FIXED_PRECISION);
618 let cme =
619 cme_globex_option_symbol("ESH6", strike, OptionKind::Call, date(2026, 1, 9), None);
620 let osi = osi_option_symbol("AAPL", date(2026, 1, 9), OptionKind::Call, strike);
621
622 assert!(matches!(
623 cme,
624 Err(OptionInstrumentIdError::InvalidStrike { .. })
625 ));
626 assert!(matches!(
627 osi,
628 Err(OptionInstrumentIdError::InvalidStrike { .. })
629 ));
630 }
631
632 #[rstest]
633 #[case("ESH6", "7250", "2026-01-27", OptionKind::Call, "E4BF6 C7250")]
634 #[case("ESH6", "7100", "2026-01-08", OptionKind::Put, "E2DF6 P7100")]
635 #[case("ESH26", "7000", "2026-01-09", OptionKind::Call, "EW2F26 C7000")]
636 #[case("ESH6", "5300", "2026-01-16", OptionKind::Call, "EW3F6 C5300")]
637 #[case("ESH6", "7250", "2026-01-30", OptionKind::Call, "EWF6 C7250")]
638 #[case("ESH6", "7250", "2026-01-30", OptionKind::Put, "EWF6 P7250")]
639 #[case("ESH6", "7000", "2026-03-20", OptionKind::Put, "ESH6 P7000")]
640 #[case("ESU6", "9600", "2026-09-18", OptionKind::Call, "ESU6 C9600")]
641 #[case("ESU6", "6575", "2026-09-18", OptionKind::Put, "ESU6 P6575")]
642 #[case("ESM4", "3550", "2024-06-21", OptionKind::Call, "ESM4 C3550")]
643 #[case("SR3Z3", "95.75", "2023-12-15", OptionKind::Put, "SR3Z3 P9575")]
644 #[case("ESH6", "6000.00", "2025-12-31", OptionKind::Call, "EWZ5 C6000")]
645 #[case("NQH6", "18000", "2026-01-13", OptionKind::Put, "Q2BF6 P18000")]
646 #[case("NQH6", "18000", "2026-01-09", OptionKind::Call, "QN2F6 C18000")]
647 #[case("NQH6", "18000", "2026-01-30", OptionKind::Put, "QNEF6 P18000")]
648 #[case("RTYH6", "2500", "2026-01-13", OptionKind::Call, "R2UF6 C2500")]
649 #[case("RTYH6", "2500", "2026-01-09", OptionKind::Put, "R2EF6 P2500")]
650 #[case("RTYH6", "2500", "2026-01-30", OptionKind::Call, "RTMF6 C2500")]
651 #[case("RTYH6", "2500", "2026-03-20", OptionKind::Call, "RTOH6 C2500")]
652 #[case("MESH6", "5300", "2026-01-13", OptionKind::Call, "X2BF6 C5300")]
653 #[case("MNQH6", "18000", "2026-01-08", OptionKind::Put, "D2DF6 P18000")]
654 #[case("CLZ5", "75", "2025-11-14", OptionKind::Put, "LO2X5 P7500")]
655 #[case("CLZ5", "75", "2025-11-10", OptionKind::Call, "ML2X5 C7500")]
656 #[case("CLZ5", "75", "2025-11-11", OptionKind::Put, "NL2X5 P7500")]
657 #[case("CLZ5", "75", "2025-11-12", OptionKind::Call, "WL2X5 C7500")]
658 #[case("CLZ5", "75", "2025-11-13", OptionKind::Put, "XL2X5 P7500")]
659 fn test_compute_cme_fixtures(
660 #[case] underlying: &str,
661 #[case] strike: &str,
662 #[case] expiry: &str,
663 #[case] kind: OptionKind,
664 #[case] expected: &str,
665 ) {
666 let underlying_id = InstrumentId::from_str(&format!("{underlying}.XCME")).unwrap();
667 let spec =
668 OptionContractSpec::from_expiry(underlying_id, Price::from(strike), expiry, kind)
669 .unwrap();
670 let id = compute_option_instrument_id(
671 &spec,
672 &OptionSymbologyScheme::CmeGlobex { mid_curve: None },
673 Venue::from("GLBX"),
674 )
675 .unwrap();
676 assert_eq!(id.symbol.as_str(), expected);
677 assert_eq!(id.venue, Venue::from("GLBX"));
678 assert_eq!(spec.underlying_instrument_id, underlying_id);
679 assert_eq!(spec.strike, Price::from(strike));
680 assert_eq!(spec.option_kind, kind);
681 assert_eq!(spec.expiration_ns, UnixNanos::from_str(expiry).unwrap());
682 }
683
684 #[rstest]
685 #[case("2026-03-13", Some(0), "S0H6 C9800")]
686 #[case("2026-03-20", Some(0), "S03H6 C9800")]
687 #[case("2026-03-13", Some(2), "S2H6 C9800")]
688 #[case("2026-03-13", Some(4), "S4H6 C9800")]
689 #[case("2026-03-13", None, "SR3H6 C9800")]
690 fn test_sofr_midcurve_fixtures(
691 #[case] expiry: &str,
692 #[case] tenor: Option<u8>,
693 #[case] expected: &str,
694 ) {
695 let spec = OptionContractSpec::from_expiry(
696 InstrumentId::from("SR3H7.XCME"),
697 Price::from("98"),
698 expiry,
699 OptionKind::Call,
700 )
701 .unwrap();
702 let id = compute_option_instrument_id(
703 &spec,
704 &OptionSymbologyScheme::CmeGlobex { mid_curve: tenor },
705 Venue::from("XCME"),
706 )
707 .unwrap();
708 assert_eq!(id.to_string(), format!("{expected}.XCME"));
709 }
710
711 #[rstest]
712 #[case("esh6")]
713 #[case("H6")]
714 #[case("ES6")]
715 #[case("ESH")]
716 #[case("ES H6")]
717 #[case("")]
718 #[case("123")]
719 #[case("ESH12345")]
720 #[case("ESH123")]
721 #[case("ESH2026")]
722 #[case("ÉSH6")]
723 fn test_invalid_future_code(#[case] code: &str) {
724 let error = cme_globex_option_symbol(
725 code,
726 Price::from("100"),
727 OptionKind::Call,
728 date(2026, 1, 9),
729 None,
730 )
731 .unwrap_err();
732 assert!(
733 matches!(error, OptionInstrumentIdError::InvalidFutureCode { value, .. } if value == code)
734 );
735 }
736
737 #[rstest]
738 #[case("6EH6", None)]
739 #[case("ZNH6", None)]
740 #[case("ZFH6", None)]
741 #[case("ZTH6", None)]
742 #[case("ZBH6", None)]
743 #[case("TNH6", None)]
744 #[case("UBH6", None)]
745 #[case("GCG6", None)]
746 #[case("SIH6", None)]
747 #[case("NGZ5", None)]
748 #[case("ESH6", Some(0))]
749 #[case("SR3H7", Some(1))]
750 #[case("SR3H7", Some(6))]
751 fn test_unsupported_cme_root(#[case] code: &str, #[case] tenor: Option<u8>) {
752 let error = cme_globex_option_symbol(
753 code,
754 Price::from("100"),
755 OptionKind::Call,
756 date(2026, 1, 9),
757 tenor,
758 )
759 .unwrap_err();
760 assert!(matches!(error, OptionInstrumentIdError::InvalidRoot { .. }));
761 }
762
763 #[rstest]
764 #[case("ESH6", date(2026, 1, 31), None)]
765 #[case("MESH6", date(2026, 7, 2), None)]
766 #[case("CLZ5", date(2025, 11, 17), None)]
767 #[case("SR3H7", date(2026, 3, 20), None)]
768 #[case("SR3H7", date(2026, 3, 20), Some(4))]
769 fn test_unrepresentable_cme_expiry(
770 #[case] code: &str,
771 #[case] expiry: Date,
772 #[case] tenor: Option<u8>,
773 ) {
774 let error =
775 cme_globex_option_symbol(code, Price::from("100"), OptionKind::Call, expiry, tenor)
776 .unwrap_err();
777 assert!(
778 matches!(error, OptionInstrumentIdError::InvalidExpiration { value, .. } if value == expiry.to_string())
779 );
780 }
781
782 #[rstest]
783 #[case("0")]
784 #[case("-1")]
785 fn test_cme_nonpositive_strike(#[case] strike: &str) {
786 let error = cme_globex_option_symbol(
787 "ESH6",
788 Price::from(strike),
789 OptionKind::Call,
790 date(2026, 1, 9),
791 None,
792 )
793 .unwrap_err();
794 assert!(
795 matches!(error, OptionInstrumentIdError::InvalidStrike { value, .. } if value == strike)
796 );
797 }
798
799 #[rstest]
800 #[case("ESH6", "7000.5")]
801 #[case("SR3H7", "95.751")]
802 #[case("CLZ5", "75.001")]
803 fn test_cme_unrepresentable_strike(#[case] code: &str, #[case] strike: &str) {
804 let error = cme_globex_option_symbol(
805 code,
806 Price::from(strike),
807 OptionKind::Call,
808 date(2026, 1, 9),
809 code.starts_with("SR3").then_some(0),
810 )
811 .unwrap_err();
812 assert_eq!(
813 error,
814 OptionInstrumentIdError::InvalidStrike {
815 value: strike.to_string(),
816 reason: "strike cannot be represented as integral CME symbol digits".to_string(),
817 }
818 );
819 }
820
821 #[rstest]
822 #[case(None, "AAPL 231215C00150000.OPRA")]
823 #[case(Some("SPXW"), "SPXW 231215C00150000.OPRA")]
824 fn test_compute_osi_root_and_venue(#[case] root: Option<&str>, #[case] expected: &str) {
825 let spec = OptionContractSpec::from_expiry(
826 InstrumentId::from("AAPL.XNAS"),
827 Price::from("150"),
828 "2023-12-15",
829 OptionKind::Call,
830 )
831 .unwrap();
832
833 let id = compute_option_instrument_id(
834 &spec,
835 &OptionSymbologyScheme::Osi {
836 root: root.map(str::to_string),
837 },
838 Venue::from("OPRA"),
839 )
840 .unwrap();
841
842 assert_eq!(id.to_string(), expected);
843 }
844
845 #[rstest]
846 fn test_invalid_spec_expiry() {
847 let error = OptionContractSpec::from_expiry(
848 InstrumentId::from("ESH6.XCME"),
849 Price::from("7250"),
850 "not-a-date",
851 OptionKind::Call,
852 )
853 .unwrap_err();
854 assert!(
855 matches!(error, OptionInstrumentIdError::InvalidExpiration { value, .. } if value == "not-a-date")
856 );
857 }
858
859 #[rstest]
860 #[case(
861 "AAPL",
862 date(2023, 12, 15),
863 OptionKind::Call,
864 "150",
865 "AAPL 231215C00150000"
866 )]
867 #[case(
868 "SPY",
869 date(2024, 1, 19),
870 OptionKind::Put,
871 "340",
872 "SPY 240119P00340000"
873 )]
874 #[case(
875 "SPXW",
876 date(2026, 1, 20),
877 OptionKind::Put,
878 "6835",
879 "SPXW 260120P06835000"
880 )]
881 #[case(
882 "F",
883 date(2024, 6, 21),
884 OptionKind::Call,
885 "12.5",
886 "F 240621C00012500"
887 )]
888 #[case(
889 "BRKB",
890 date(2024, 6, 21),
891 OptionKind::Call,
892 "412.375",
893 "BRKB 240621C00412375"
894 )]
895 fn test_osi_option_symbol(
896 #[case] root: &str,
897 #[case] expiry: Date,
898 #[case] kind: OptionKind,
899 #[case] strike: &str,
900 #[case] expected: &str,
901 ) {
902 let symbol = osi_option_symbol(root, expiry, kind, Price::from(strike)).unwrap();
903 assert_eq!(symbol, expected);
904 assert_eq!(symbol.len(), 21);
905 }
906
907 #[rstest]
908 fn test_osi_rejects_long_root() {
909 let result = osi_option_symbol(
910 "TOOLONG",
911 date(2024, 6, 21),
912 OptionKind::Call,
913 Price::from("100"),
914 );
915 assert!(matches!(
916 result,
917 Err(OptionInstrumentIdError::InvalidRoot { .. })
918 ));
919 }
920
921 #[rstest]
922 fn test_osi_rejects_non_ascii_root() {
923 let result = osi_option_symbol(
924 "SPÉ",
925 date(2024, 6, 21),
926 OptionKind::Call,
927 Price::from("100"),
928 );
929 assert!(matches!(
930 result,
931 Err(OptionInstrumentIdError::InvalidRoot { .. })
932 ));
933 }
934
935 #[rstest]
936 fn test_osi_rejects_sub_thousandth_strike() {
937 let result = osi_option_symbol(
938 "AAPL",
939 date(2024, 6, 21),
940 OptionKind::Call,
941 Price::from("150.1234"),
942 );
943 assert!(matches!(
944 result,
945 Err(OptionInstrumentIdError::InvalidStrike { .. })
946 ));
947 }
948
949 #[rstest]
950 fn test_osi_rejects_strike_over_eight_digits() {
951 let result = osi_option_symbol(
952 "AAPL",
953 date(2024, 6, 21),
954 OptionKind::Call,
955 Price::from("100000"),
956 );
957 assert!(matches!(
958 result,
959 Err(OptionInstrumentIdError::InvalidStrike { .. })
960 ));
961 }
962
963 #[rstest]
964 fn test_osi_rejects_pre_2000_expiration() {
965 let result = osi_option_symbol(
966 "AAPL",
967 date(1999, 12, 17),
968 OptionKind::Call,
969 Price::from("100"),
970 );
971 assert!(matches!(
972 result,
973 Err(OptionInstrumentIdError::InvalidExpiration { .. })
974 ));
975 }
976
977 #[rstest]
978 #[case("0", "AAPL 991231C00000000")]
979 #[case("99999.999", "AAPL 991231C99999999")]
980 fn test_osi_strike_boundaries(#[case] strike: &str, #[case] expected: &str) {
981 let symbol = osi_option_symbol(
982 "AAPL",
983 date(2099, 12, 31),
984 OptionKind::Call,
985 Price::from(strike),
986 )
987 .unwrap();
988 assert_eq!(symbol, expected);
989 }
990
991 #[rstest]
992 fn test_osi_negative_strike_returns_typed_error() {
993 let error = osi_option_symbol(
994 "AAPL",
995 date(2024, 6, 21),
996 OptionKind::Put,
997 Price::from("-1"),
998 )
999 .unwrap_err();
1000 assert_eq!(
1001 error,
1002 OptionInstrumentIdError::InvalidStrike {
1003 value: "-1".to_string(),
1004 reason: "OSI strike must fit eight digits of exact non-negative thousandths"
1005 .to_string(),
1006 }
1007 );
1008 }
1009
1010 fn date(year: i16, month: i8, day: i8) -> Date {
1011 Date::new(year, month, day).unwrap()
1012 }
1013}