alpaca_option/
contract.rs1use 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}