hypercall_types/
symbol.rs1use rust_decimal::Decimal;
2use std::str::FromStr;
3
4pub fn perp_underlying(symbol: &str) -> Option<&str> {
7 if let Some(underlying) = symbol.strip_suffix("-PERP") {
8 return Some(underlying);
9 }
10 if !symbol.contains('-') && !symbol.is_empty() {
11 return Some(symbol);
12 }
13 None
14}
15
16#[derive(Debug, Clone)]
18pub struct ParsedOptionSymbol {
19 pub underlying: String,
20 pub expiry: u64,
21 pub strike: Decimal,
22 pub option_type: crate::OptionType,
23}
24
25impl ParsedOptionSymbol {
26 pub fn from_symbol(symbol: &str) -> Result<Self, String> {
30 let parts: Vec<&str> = symbol.split('-').collect();
32 if parts.len() != 4 {
33 return Err(format!("Invalid symbol format: {}", symbol));
34 }
35
36 let underlying = parts[0].to_string();
37
38 let expiry = if parts[1].chars().all(|c| c.is_ascii_digit()) {
40 parts[1]
42 .parse::<u64>()
43 .map_err(|_| format!("Invalid expiry: {}", parts[1]))?
44 } else {
45 parse_deribit_expiry(parts[1])
47 .ok_or_else(|| format!("Invalid expiry format: {}", parts[1]))?
48 };
49
50 let strike =
51 Decimal::from_str(parts[2]).map_err(|_| format!("Invalid strike: {}", parts[2]))?;
52
53 let option_type = match parts[3] {
54 "C" | "c" => crate::OptionType::Call,
55 "P" | "p" => crate::OptionType::Put,
56 _ => return Err(format!("Invalid option type: {}", parts[3])),
57 };
58
59 Ok(ParsedOptionSymbol {
60 underlying,
61 expiry,
62 strike,
63 option_type,
64 })
65 }
66}
67
68fn parse_deribit_expiry(expiry_str: &str) -> Option<u64> {
70 if expiry_str.len() < 5 {
72 return None;
73 }
74
75 let day_end = expiry_str.chars().position(|c| !c.is_ascii_digit())?;
77 let day = expiry_str[..day_end].parse::<u32>().ok()?;
78
79 let month_str = &expiry_str[day_end..];
81 if month_str.chars().count() < 5 {
82 return None;
84 }
85 let month_abbr = month_str.chars().take(3).collect::<String>();
86 let month = match month_abbr.as_str() {
87 "JAN" => 1,
88 "FEB" => 2,
89 "MAR" => 3,
90 "APR" => 4,
91 "MAY" => 5,
92 "JUN" => 6,
93 "JUL" => 7,
94 "AUG" => 8,
95 "SEP" => 9,
96 "OCT" => 10,
97 "NOV" => 11,
98 "DEC" => 12,
99 _ => return None,
100 };
101
102 let year_str = month_str.chars().skip(3).collect::<String>();
104 let year = year_str.parse::<u32>().ok()?;
105 let full_year = 2000 + year;
106
107 Some(full_year as u64 * 10000 + month as u64 * 100 + day as u64)
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn perp_underlying_handles_perp_suffix() {
117 assert_eq!(perp_underlying("BTC-PERP"), Some("BTC"));
118 assert_eq!(perp_underlying("ETH-PERP"), Some("ETH"));
119 }
120
121 #[test]
122 fn perp_underlying_handles_raw_symbol() {
123 assert_eq!(perp_underlying("BTC"), Some("BTC"));
124 assert_eq!(perp_underlying("ETH"), Some("ETH"));
125 }
126
127 #[test]
128 fn perp_underlying_rejects_option_symbols() {
129 assert_eq!(perp_underlying("BTC-20240131-100000-C"), None);
130 assert_eq!(perp_underlying("BTC-INVALID"), None);
131 }
132
133 #[test]
134 fn perp_underlying_rejects_empty() {
135 assert_eq!(perp_underlying(""), None);
136 }
137
138 #[test]
139 fn parsed_option_symbol_handles_yyyymmdd_call() {
140 let parsed = ParsedOptionSymbol::from_symbol("BTC-20240131-100000-C").unwrap();
141
142 assert_eq!(parsed.underlying, "BTC");
143 assert_eq!(parsed.expiry, 20240131);
144 assert_eq!(parsed.strike, Decimal::from_str("100000").unwrap());
145 assert_eq!(parsed.option_type, crate::OptionType::Call);
146 }
147
148 #[test]
149 fn parsed_option_symbol_handles_deribit_put() {
150 let parsed = ParsedOptionSymbol::from_symbol("ETH-30AUG25-2500-P").unwrap();
151
152 assert_eq!(parsed.underlying, "ETH");
153 assert_eq!(parsed.expiry, 20250830);
154 assert_eq!(parsed.strike, Decimal::from_str("2500").unwrap());
155 assert_eq!(parsed.option_type, crate::OptionType::Put);
156 }
157
158 #[test]
159 fn parsed_option_symbol_rejects_invalid_format() {
160 assert!(ParsedOptionSymbol::from_symbol("BTC-INVALID").is_err());
161 }
162
163 #[test]
164 fn parsed_option_symbol_rejects_non_ascii_deribit_expiry_without_panicking() {
165 assert!(ParsedOptionSymbol::from_symbol("ETH-30ÅÅG25-2500-P").is_err());
166 }
167}