Skip to main content

hypercall_types/
symbol.rs

1use rust_decimal::Decimal;
2use std::str::FromStr;
3
4/// Extract the underlying from a perp symbol (e.g., "BTC-PERP" -> "BTC", "BTC" -> "BTC").
5/// Returns `None` for option symbols or other hyphenated symbols that aren't perps.
6pub 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/// Parsed option symbol components with Decimal strike precision.
17#[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    /// Parse a symbol string into its option components.
27    ///
28    /// Supports both YYYYMMDD and DDMMMYY (Deribit) expiry formats.
29    pub fn from_symbol(symbol: &str) -> Result<Self, String> {
30        // Format: "BTC-20240131-100000-C" or "BTC-30AUG25-100000-C"
31        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        // Parse expiry - handle both YYYYMMDD and DDMMMYY formats
39        let expiry = if parts[1].chars().all(|c| c.is_ascii_digit()) {
40            // YYYYMMDD format
41            parts[1]
42                .parse::<u64>()
43                .map_err(|_| format!("Invalid expiry: {}", parts[1]))?
44        } else {
45            // DDMMMYY format (e.g., "30AUG25")
46            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
68// Helper function to parse Deribit-style expiry (e.g., "30AUG25" -> 20250830)
69fn parse_deribit_expiry(expiry_str: &str) -> Option<u64> {
70    // Extract day, month, year from format like "30AUG25"
71    if expiry_str.len() < 5 {
72        return None;
73    }
74
75    // Find where the month starts (first non-digit character)
76    let day_end = expiry_str.chars().position(|c| !c.is_ascii_digit())?;
77    let day = expiry_str[..day_end].parse::<u32>().ok()?;
78
79    // Extract month (3 letters)
80    let month_str = &expiry_str[day_end..];
81    if month_str.chars().count() < 5 {
82        // Need at least 3 chars for month + 2 for year
83        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    // Extract year (2 digits)
103    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    // Convert to YYYYMMDD format
108    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}