Skip to main content

hypercall_engine/
instrument.rs

1//! Pure instrument parsing helpers for engine-owned validation.
2
3use rust_decimal::Decimal;
4use std::str::FromStr;
5
6/// Parsed option instrument.
7#[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    /// Parse `UNDERLYING-YYYYMMDD-STRIKE-C/P` or Deribit-style
17    /// `UNDERLYING-DDMMMYY-STRIKE-C/P` option symbols.
18    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
71/// Return the underlying for supported perp symbols.
72pub 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
89/// Derive a stable contract key for open-sell tracking.
90pub 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}