Skip to main content

nautilus_model/identifiers/
option_instrument_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//! Computes candidate option identifiers under an explicit symbology scheme.
17//!
18//! CME inference follows nominal weekday expiry schedules. Exchange holidays and instrument
19//! listings require definition validation by the caller. Standard quarterly equity options
20//! take precedence when the futures month matches the quarterly expiry month; otherwise
21//! weekly or EOM roots apply.
22
23use 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/// The contract inputs used to compute a candidate option identifier.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct OptionContractSpec {
43    /// The option's actual underlying, independent of its series reference-price instrument.
44    pub underlying_instrument_id: InstrumentId,
45    /// The exact strike price.
46    pub strike: Price,
47    /// UNIX expiration timestamp in nanoseconds.
48    pub expiration_ns: UnixNanos,
49    /// The option kind.
50    pub option_kind: OptionKind,
51}
52
53/// A scheme for computing raw option symbols.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum OptionSymbologyScheme {
56    /// CME Globex symbols, with an optional SOFR midcurve tenor code.
57    CmeGlobex {
58        /// `0` denotes one year; `2`, `3`, `4`, and `5` denote those year tenors.
59        mid_curve: Option<u8>,
60    },
61    /// Padded 21-character OSI symbols.
62    Osi {
63        /// An option-root override, such as `SPXW`; otherwise uses the underlying symbol.
64        root: Option<String>,
65    },
66}
67
68/// A contract component cannot be represented by the selected scheme.
69#[derive(Debug, Clone, Error, PartialEq, Eq)]
70pub enum OptionInstrumentIdError {
71    /// The underlying futures code is invalid.
72    #[error("invalid future code '{value}': {reason}")]
73    InvalidFutureCode {
74        /// The invalid code.
75        value: String,
76        /// The validation failure.
77        reason: String,
78    },
79    /// The expiry is unsupported or ambiguous.
80    #[error("invalid expiration '{value}': {reason}")]
81    InvalidExpiration {
82        /// The invalid expiry.
83        value: String,
84        /// The validation failure.
85        reason: String,
86    },
87    /// The strike cannot be represented exactly.
88    #[error("invalid strike '{value}': {reason}")]
89    InvalidStrike {
90        /// The invalid strike.
91        value: String,
92        /// The validation failure.
93        reason: String,
94    },
95    /// The option root is invalid or unsupported.
96    #[error("invalid option root '{value}': {reason}")]
97    InvalidRoot {
98        /// The invalid root.
99        value: String,
100        /// The validation failure.
101        reason: String,
102    },
103}
104
105impl OptionContractSpec {
106    /// Creates a contract specification.
107    #[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    /// Creates a specification from a UTC expiry date or timestamp string.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error if the expiry cannot be parsed.
127    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    /// Returns the UTC calendar date of expiration.
149    #[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
157/// Computes a candidate option ID at the option venue.
158///
159/// The computed symbol does not prove that a matching contract is listed.
160/// CME reads the underlying symbol as a futures code such as `ESH6`, rather than `ES`.
161/// OSI uses that symbol as the option root unless the scheme supplies an override.
162///
163/// # Errors
164///
165/// Returns an error if a contract component is invalid, unsupported, or ambiguous.
166pub 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
197/// Computes a CME Globex option symbol from a nominal UTC expiry date.
198///
199/// Uses the expiry year with the underlying's one- or two-digit year convention.
200/// Supports ES, NQ, RTY, MES, MNQ, SR3 SOFR contracts, and CL WTI weeklies.
201/// Other roots return typed errors. Physically settled Micro E-mini contracts are supported
202/// before June 29, 2026; subsequent physical and financial families are ambiguous.
203/// SOFR and WTI strike digits use hundredths; equity-index strike digits use whole index points.
204/// Holiday-adjusted expiries require validation against instrument definitions.
205///
206/// # Errors
207///
208/// Returns an error if:
209/// - The futures code, root, or midcurve tenor is unsupported.
210/// - The expiry is on a weekend, a nominal WTI monthly date, or an ambiguous micro date.
211/// - The strike is non-positive or cannot fit the supported integral symbol scale.
212pub 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
261/// Computes a padded OSI option symbol with an exact thousandths strike.
262///
263/// # Errors
264///
265/// Returns an error if:
266/// - The root is outside one to six ASCII alphanumeric characters.
267/// - The expiry year is outside `[2000, 2099]`.
268/// - The strike cannot fit eight digits of exact non-negative thousandths.
269pub 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}