Skip to main content

alpaca_option/
contract.rs

1use alpaca_time::clock;
2
3use crate::error::{OptionError, OptionResult};
4use crate::types::{OptionContract, OptionPosition, OptionRight};
5
6const OCC_TAIL_LENGTH: usize = 15;
7const MAX_UNDERLYING_LENGTH: usize = 6;
8
9fn canonical_underlying_symbol(symbol: &str) -> String {
10    symbol
11        .trim()
12        .chars()
13        .filter(|ch| ch.is_ascii_alphanumeric())
14        .map(|ch| ch.to_ascii_uppercase())
15        .collect()
16}
17
18fn ensure_underlying_symbol(symbol: &str) -> OptionResult<String> {
19    let trimmed = symbol.trim();
20    if trimmed.is_empty()
21        || !trimmed
22            .chars()
23            .all(|ch| ch.is_ascii_alphanumeric() || ch == '.' || ch == '/')
24    {
25        return Err(OptionError::new(
26            "invalid_underlying_symbol",
27            format!("invalid underlying symbol: {symbol}"),
28        ));
29    }
30
31    let normalized = canonical_underlying_symbol(symbol);
32    if normalized.is_empty() || normalized.len() > MAX_UNDERLYING_LENGTH {
33        return Err(OptionError::new(
34            "invalid_underlying_symbol",
35            format!("invalid underlying symbol: {symbol}"),
36        ));
37    }
38    Ok(normalized)
39}
40
41fn normalize_option_right_text(option_right: &str) -> Option<&'static str> {
42    match option_right.trim().to_ascii_lowercase().as_str() {
43        "call" | "c" => Some("call"),
44        "put" | "p" => Some("put"),
45        _ => None,
46    }
47}
48
49fn canonical_contract_from_option_contract(contract: &OptionContract) -> Option<OptionContract> {
50    if let Some(parsed) = parse_occ_symbol(&contract.occ_symbol) {
51        return Some(parsed);
52    }
53
54    let occ_symbol = build_occ_symbol(
55        &contract.underlying_symbol,
56        &contract.expiration_date,
57        contract.strike,
58        contract.option_right.as_str(),
59    )?;
60    parse_occ_symbol(&occ_symbol)
61}
62
63pub trait ContractLike {
64    fn canonical_contract(&self) -> Option<OptionContract>;
65}
66
67impl<T: ContractLike + ?Sized> ContractLike for &T {
68    fn canonical_contract(&self) -> Option<OptionContract> {
69        (*self).canonical_contract()
70    }
71}
72
73impl ContractLike for OptionContract {
74    fn canonical_contract(&self) -> Option<OptionContract> {
75        canonical_contract_from_option_contract(self)
76    }
77}
78
79impl ContractLike for str {
80    fn canonical_contract(&self) -> Option<OptionContract> {
81        parse_occ_symbol(self)
82    }
83}
84
85impl ContractLike for String {
86    fn canonical_contract(&self) -> Option<OptionContract> {
87        self.as_str().canonical_contract()
88    }
89}
90
91impl ContractLike for OptionPosition {
92    fn canonical_contract(&self) -> Option<OptionContract> {
93        parse_occ_symbol(&self.contract)
94    }
95}
96
97pub fn normalize_underlying_symbol(symbol: &str) -> String {
98    canonical_underlying_symbol(symbol)
99}
100
101pub fn is_occ_symbol(occ_symbol: &str) -> bool {
102    parse_occ_symbol(occ_symbol).is_some()
103}
104
105pub fn parse_occ_symbol(occ_symbol: &str) -> Option<OptionContract> {
106    let normalized = occ_symbol.trim().to_ascii_uppercase();
107    if normalized.len() <= OCC_TAIL_LENGTH {
108        return None;
109    }
110
111    let split = normalized.len() - OCC_TAIL_LENGTH;
112    let underlying_symbol = &normalized[..split];
113    if underlying_symbol.is_empty()
114        || underlying_symbol.len() > MAX_UNDERLYING_LENGTH
115        || !underlying_symbol
116            .chars()
117            .all(|ch| ch.is_ascii_alphanumeric())
118    {
119        return None;
120    }
121
122    let yy = &normalized[split..split + 2];
123    let mm = &normalized[split + 2..split + 4];
124    let dd = &normalized[split + 4..split + 6];
125    let expiration_date = clock::parse_date(&format!("20{yy}-{mm}-{dd}")).ok()?;
126
127    let option_right = OptionRight::from_code(normalized.as_bytes()[split + 6] as char).ok()?;
128
129    let strike_digits = &normalized[split + 7..];
130    if strike_digits.len() != 8 || !strike_digits.chars().all(|ch| ch.is_ascii_digit()) {
131        return None;
132    }
133
134    let strike = strike_digits.parse::<u32>().ok()? as f64 / 1000.0;
135
136    Some(OptionContract {
137        underlying_symbol: underlying_symbol.to_string(),
138        expiration_date,
139        strike,
140        option_right,
141        occ_symbol: normalized,
142    })
143}
144
145pub fn build_occ_symbol(
146    underlying_symbol: &str,
147    expiration_date: &str,
148    strike: f64,
149    option_right: &str,
150) -> Option<String> {
151    let underlying_symbol = ensure_underlying_symbol(underlying_symbol).ok()?;
152    let expiration_date = clock::parse_date(expiration_date).ok()?;
153    let option_right = OptionRight::from_str(normalize_option_right_text(option_right)?).ok()?;
154
155    if !strike.is_finite() || strike < 0.0 {
156        return None;
157    }
158
159    let strike_thousandths = (strike * 1000.0).round();
160    if !(0.0..=99_999_999.0).contains(&strike_thousandths) {
161        return None;
162    }
163    let yymmdd =
164        expiration_date[2..4].to_string() + &expiration_date[5..7] + &expiration_date[8..10];
165
166    Some(format!(
167        "{}{}{}{:08}",
168        underlying_symbol,
169        yymmdd,
170        option_right.code(),
171        strike_thousandths as u32
172    ))
173}
174
175pub fn canonical_contract(input: &impl ContractLike) -> Option<OptionContract> {
176    input.canonical_contract()
177}