1use rust_decimal::Decimal;
4use std::str::FromStr;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ParsedInstrument {
9 pub underlying: String,
10 pub expiry: u64,
11 pub strike: Decimal,
12 pub option_type: hypercall_types::OptionType,
13}
14
15impl ParsedInstrument {
16 pub fn parse(symbol: &str) -> Result<Self, String> {
19 let parts: Vec<&str> = symbol.split('-').collect();
20 if parts.len() != 4 {
21 return Err(format!("Invalid symbol format: {}", symbol));
22 }
23 if parts[0].trim().is_empty() {
24 return Err("Invalid underlying: empty".to_string());
25 }
26
27 let expiry = if parts[1].chars().all(|c| c.is_ascii_digit()) {
28 parts[1]
29 .parse::<u64>()
30 .map_err(|_| format!("Invalid expiry: {}", parts[1]))?
31 } else {
32 parse_deribit_expiry(parts[1])
33 .ok_or_else(|| format!("Invalid expiry format: {}", parts[1]))?
34 };
35 hypercall_types::expiry_date_to_timestamp_checked(parts[0], expiry)
36 .map_err(|e| format!("Invalid expiry {}: {}", expiry, e))?;
37
38 let strike =
39 Decimal::from_str(parts[2]).map_err(|_| format!("Invalid strike: {}", parts[2]))?;
40 if strike <= Decimal::ZERO {
41 return Err(format!("Invalid strike: {}", parts[2]));
42 }
43 let option_type = match parts[3] {
44 "C" | "c" => hypercall_types::OptionType::Call,
45 "P" | "p" => hypercall_types::OptionType::Put,
46 _ => return Err(format!("Invalid option type: {}", parts[3])),
47 };
48
49 Ok(Self {
50 underlying: parts[0].to_string(),
51 expiry,
52 strike,
53 option_type,
54 })
55 }
56
57 pub fn expiry_timestamp(&self) -> Result<i64, String> {
58 hypercall_types::expiry_date_to_timestamp_checked(&self.underlying, self.expiry)
59 .map_err(|e| format!("Invalid expiry {}: {}", self.expiry, e))
60 .and_then(|ts| {
61 i64::try_from(ts).map_err(|_| {
62 format!(
63 "Invalid expiry {}: timestamp {} not representable as i64",
64 self.expiry, ts
65 )
66 })
67 })
68 }
69}
70
71pub fn perp_underlying(symbol: &str) -> Option<&str> {
73 if let Some(underlying) = symbol.strip_suffix("-PERP") {
74 if !underlying.is_empty() {
75 return Some(underlying);
76 }
77 return None;
78 }
79 if !symbol.contains('-') && !symbol.trim().is_empty() {
80 return Some(symbol);
81 }
82 None
83}
84
85pub fn is_option_symbol(symbol: &str) -> bool {
86 ParsedInstrument::parse(symbol).is_ok()
87}
88
89pub fn contract_key(symbol: &str) -> Option<String> {
91 ParsedInstrument::parse(symbol).ok().map(|parsed| {
92 format!(
93 "{}-{}-{}-{}",
94 parsed.underlying,
95 parsed.expiry,
96 parsed.strike,
97 match parsed.option_type {
98 hypercall_types::OptionType::Call => "C",
99 hypercall_types::OptionType::Put => "P",
100 }
101 )
102 })
103}
104
105fn parse_deribit_expiry(expiry_str: &str) -> Option<u64> {
106 if !(6..=7).contains(&expiry_str.len()) {
107 return None;
108 }
109
110 let day_end = expiry_str.chars().position(|c| !c.is_ascii_digit())?;
111 if !(1..=2).contains(&day_end) {
112 return None;
113 }
114 let day = expiry_str[..day_end].parse::<u32>().ok()?;
115 if !(1..=31).contains(&day) {
116 return None;
117 }
118 let month_str = &expiry_str[day_end..];
119 if month_str.len() != 5 {
120 return None;
121 }
122
123 let month = match &month_str[..3].to_ascii_uppercase()[..] {
124 "JAN" => 1,
125 "FEB" => 2,
126 "MAR" => 3,
127 "APR" => 4,
128 "MAY" => 5,
129 "JUN" => 6,
130 "JUL" => 7,
131 "AUG" => 8,
132 "SEP" => 9,
133 "OCT" => 10,
134 "NOV" => 11,
135 "DEC" => 12,
136 _ => return None,
137 };
138
139 let year = month_str[3..5].parse::<u32>().ok()?;
140 Some((2000 + year) as u64 * 10000 + month as u64 * 100 + day as u64)
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use rust_decimal_macros::dec;
147
148 #[test]
149 fn parses_yyyymmdd_option_symbol() {
150 let parsed = ParsedInstrument::parse("ETH-20260131-4000-C").unwrap();
151 assert_eq!(parsed.underlying, "ETH");
152 assert_eq!(parsed.expiry, 20260131);
153 assert_eq!(parsed.strike, dec!(4000));
154 assert_eq!(parsed.option_type, hypercall_types::OptionType::Call);
155 }
156
157 #[test]
158 fn parses_deribit_expiry_symbol() {
159 let parsed = ParsedInstrument::parse("BTC-30AUG25-95000-P").unwrap();
160 assert_eq!(parsed.expiry, 20250830);
161 assert_eq!(parsed.option_type, hypercall_types::OptionType::Put);
162 }
163
164 #[test]
165 fn parses_deribit_expiry_symbol_with_one_digit_day() {
166 let parsed = ParsedInstrument::parse("ETH-6SEP25-3000-P").unwrap();
167 assert_eq!(parsed.expiry, 20250906);
168 assert_eq!(parsed.option_type, hypercall_types::OptionType::Put);
169 }
170
171 #[test]
172 fn rejects_deribit_expiry_with_long_year() {
173 assert!(ParsedInstrument::parse("BTC-30AUG2025-95000-P").is_err());
174 }
175
176 #[test]
177 fn rejects_invalid_option_symbol() {
178 assert!(ParsedInstrument::parse("-20260131-4000-C").is_err());
179 assert!(ParsedInstrument::parse("ETH-PERP").is_err());
180 assert!(ParsedInstrument::parse("ETH-20250231-4000-C").is_err());
181 assert!(ParsedInstrument::parse("ETH-20260131-0-C").is_err());
182 assert!(ParsedInstrument::parse("ETH-20260131-4000-X").is_err());
183 }
184
185 #[test]
186 fn identifies_perp_underlying() {
187 assert_eq!(perp_underlying("BTC-PERP"), Some("BTC"));
188 assert_eq!(perp_underlying("BTC"), Some("BTC"));
189 assert_eq!(perp_underlying("-PERP"), None);
190 assert_eq!(perp_underlying("BTC-20260131-4000-C"), None);
191 }
192
193 #[test]
194 fn equivalent_formats_produce_same_parsed_instrument() {
195 let yyyymmdd = ParsedInstrument::parse("BTC-20251231-100000-C").unwrap();
196 let deribit = ParsedInstrument::parse("BTC-31DEC25-100000-C").unwrap();
197 assert_eq!(yyyymmdd, deribit);
198 }
199
200 #[test]
201 fn contract_key_normalizes_equivalent_symbols() {
202 let k1 = contract_key("BTC-20251231-100000-C").unwrap();
203 let k2 = contract_key("BTC-31DEC25-100000-C").unwrap();
204 assert_eq!(k1, k2);
205 }
206
207 #[test]
208 fn contract_key_returns_none_for_perps() {
209 assert_eq!(contract_key("BTC-PERP"), None);
210 }
211
212 #[test]
213 fn is_option_symbol_works() {
214 assert!(is_option_symbol("BTC-20260131-100000-C"));
215 assert!(is_option_symbol("ETH-6SEP25-3000-P"));
216 assert!(!is_option_symbol("BTC-PERP"));
217 assert!(!is_option_symbol("BTC"));
218 assert!(!is_option_symbol(""));
219 }
220
221 #[test]
222 fn rejects_empty_underlying() {
223 assert!(ParsedInstrument::parse("-20260131-4000-C").is_err());
224 }
225
226 #[test]
227 fn rejects_negative_strike() {
228 assert!(ParsedInstrument::parse("BTC-20260131--100-C").is_err());
229 }
230
231 #[test]
232 fn rejects_zero_strike() {
233 assert!(ParsedInstrument::parse("BTC-20260131-0-C").is_err());
234 }
235
236 #[test]
237 fn case_insensitive_option_type() {
238 let upper = ParsedInstrument::parse("BTC-20260131-100000-C").unwrap();
239 let lower = ParsedInstrument::parse("BTC-20260131-100000-c").unwrap();
240 assert_eq!(upper.option_type, lower.option_type);
241 }
242
243 #[test]
244 fn deribit_all_months() {
245 for (month, num) in [
246 ("JAN", 1),
247 ("FEB", 2),
248 ("MAR", 3),
249 ("APR", 4),
250 ("MAY", 5),
251 ("JUN", 6),
252 ("JUL", 7),
253 ("AUG", 8),
254 ("SEP", 9),
255 ("OCT", 10),
256 ("NOV", 11),
257 ("DEC", 12),
258 ] {
259 let symbol = format!("BTC-15{}25-100000-C", month);
260 let parsed = ParsedInstrument::parse(&symbol).unwrap();
261 let expected = 20250000 + num * 100 + 15;
262 assert_eq!(parsed.expiry, expected, "failed for month {}", month);
263 }
264 }
265}