Skip to main content

hypercall/shared/
order_types.rs

1use crate::types::OptionType;
2pub use hypercall_types::perp_underlying;
3pub use hypercall_types::ws_protocol::{WsOrderMessage, WsOrderRequest};
4use hypercall_types::{validate_price_precision, MAX_PRICE_SIGNIFICANT_FIGURES};
5use rust_decimal::prelude::ToPrimitive;
6use rust_decimal::Decimal;
7use rust_decimal_macros::dec;
8use std::str::FromStr;
9use std::time::SystemTime;
10
11#[derive(Debug, Clone)]
12pub struct ParsedSymbol {
13    pub underlying: String,
14    pub expiry: u64,
15    pub strike: Decimal,
16    pub option_type: OptionType,
17}
18
19// Helper function to parse Deribit-style expiry (e.g., "30AUG25" -> 20250830)
20fn parse_deribit_expiry(expiry_str: &str) -> Option<u64> {
21    // Extract day, month, year from format like "30AUG25"
22    if expiry_str.len() < 5 {
23        return None;
24    }
25
26    // Find where the month starts (first non-digit character)
27    let day_end = expiry_str.chars().position(|c| !c.is_ascii_digit())?;
28    let day = expiry_str[..day_end].parse::<u32>().ok()?;
29
30    // Extract month (3 letters)
31    let month_str = &expiry_str[day_end..];
32    if month_str.len() < 5 {
33        // Need at least 3 chars for month + 2 for year
34        return None;
35    }
36    let month_abbr = &month_str[..3];
37    let month = match month_abbr {
38        "JAN" => 1,
39        "FEB" => 2,
40        "MAR" => 3,
41        "APR" => 4,
42        "MAY" => 5,
43        "JUN" => 6,
44        "JUL" => 7,
45        "AUG" => 8,
46        "SEP" => 9,
47        "OCT" => 10,
48        "NOV" => 11,
49        "DEC" => 12,
50        _ => return None,
51    };
52
53    // Extract year (2 digits)
54    let year_str = &month_str[3..];
55    let year = year_str.parse::<u32>().ok()?;
56    let full_year = 2000 + year;
57
58    // Convert to YYYYMMDD format
59    Some(full_year as u64 * 10000 + month as u64 * 100 + day as u64)
60}
61
62impl ParsedSymbol {
63    pub fn from_symbol(symbol: &str) -> Result<Self, String> {
64        // Format: "BTC-20240131-100000-C" or "BTC-30AUG25-100000-C"
65        let parts: Vec<&str> = symbol.split('-').collect();
66        if parts.len() != 4 {
67            return Err(format!("Invalid symbol format: {}", symbol));
68        }
69
70        let underlying = parts[0].to_string();
71
72        // Parse expiry - handle both YYYYMMDD and DDMMMYY formats
73        let expiry = if parts[1].chars().all(|c| c.is_ascii_digit()) {
74            // YYYYMMDD format
75            parts[1]
76                .parse::<u64>()
77                .map_err(|_| format!("Invalid expiry: {}", parts[1]))?
78        } else {
79            // DDMMMYY format (e.g., "30AUG25")
80            parse_deribit_expiry(parts[1])
81                .ok_or_else(|| format!("Invalid expiry format: {}", parts[1]))?
82        };
83
84        let strike =
85            Decimal::from_str(parts[2]).map_err(|_| format!("Invalid strike: {}", parts[2]))?;
86
87        let option_type = match parts[3] {
88            "C" | "c" => OptionType::Call,
89            "P" | "p" => OptionType::Put,
90            _ => return Err(format!("Invalid option type: {}", parts[3])),
91        };
92
93        Ok(ParsedSymbol {
94            underlying,
95            expiry,
96            strike,
97            option_type,
98        })
99    }
100}
101
102pub fn validate_order_request(request: &WsOrderRequest) -> Result<ParsedSymbol, String> {
103    if request.price <= dec!(0) {
104        return Err("Price must be positive".to_string());
105    }
106
107    if request.size <= dec!(0) {
108        return Err("Size must be greater than zero".to_string());
109    }
110
111    // Validate price precision (max 5 significant figures)
112    // Convert Decimal to f64 for validation
113    let price_f64 = request.price.to_f64().unwrap_or(0.0);
114    validate_price_precision(price_f64, MAX_PRICE_SIGNIFICANT_FIGURES)?;
115
116    ParsedSymbol::from_symbol(&request.symbol)
117}
118
119pub fn get_timestamp_millis() -> u64 {
120    SystemTime::now()
121        .duration_since(SystemTime::UNIX_EPOCH)
122        .unwrap()
123        .as_millis() as u64
124}
125
126/// Convert an f64 to Decimal for places where Decimal is required.
127/// Returns Decimal::ZERO if the conversion fails.
128pub fn f64_to_decimal(value: f64) -> Decimal {
129    Decimal::from_f64_retain(value).unwrap_or(Decimal::ZERO)
130}
131
132/// Convert human-readable size (Decimal) to contract units (Decimal).
133/// This is a re-export for convenience within the crate.
134pub fn to_contract_units_decimal(symbol: &str, size: Decimal) -> Decimal {
135    hypercall_types::to_contract_units_decimal(symbol, size)
136}
137
138/// Convert contract units (Decimal) to human-readable size (Decimal).
139/// This is a re-export for convenience within the crate.
140pub fn to_human_readable_decimal(symbol: &str, size: Decimal) -> Decimal {
141    hypercall_types::to_human_readable_decimal(symbol, size)
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use hypercall_types::Side;
148
149    #[test]
150    fn test_parse_symbol_valid() {
151        let symbol = "BTC-20240131-100000-C";
152        let parsed = ParsedSymbol::from_symbol(symbol).unwrap();
153        assert_eq!(parsed.underlying, "BTC");
154        assert_eq!(parsed.expiry, 20240131);
155        assert_eq!(parsed.strike, dec!(100000));
156        assert_eq!(parsed.option_type, OptionType::Call);
157    }
158
159    #[test]
160    fn test_parse_symbol_put() {
161        let symbol = "ETH-20240228-3000-P";
162        let parsed = ParsedSymbol::from_symbol(symbol).unwrap();
163        assert_eq!(parsed.underlying, "ETH");
164        assert_eq!(parsed.expiry, 20240228);
165        assert_eq!(parsed.strike, dec!(3000));
166        assert_eq!(parsed.option_type, OptionType::Put);
167    }
168
169    #[test]
170    fn test_parse_symbol_invalid_format() {
171        let symbol = "BTC-100000-C";
172        assert!(ParsedSymbol::from_symbol(symbol).is_err());
173    }
174
175    #[test]
176    fn test_parse_symbol_deribit_format() {
177        let symbol = "BTC-30AUG25-95000-C";
178        let parsed = ParsedSymbol::from_symbol(symbol).unwrap();
179        assert_eq!(parsed.underlying, "BTC");
180        assert_eq!(parsed.expiry, 20250830);
181        assert_eq!(parsed.strike, dec!(95000));
182        assert_eq!(parsed.option_type, OptionType::Call);
183    }
184
185    #[test]
186    fn test_parse_symbol_deribit_put() {
187        let symbol = "ETH-6SEP25-3000-P";
188        let parsed = ParsedSymbol::from_symbol(symbol).unwrap();
189        assert_eq!(parsed.underlying, "ETH");
190        assert_eq!(parsed.expiry, 20250906);
191        assert_eq!(parsed.strike, dec!(3000));
192        assert_eq!(parsed.option_type, OptionType::Put);
193    }
194
195    #[test]
196    fn perp_underlying_handles_perp_suffix() {
197        assert_eq!(perp_underlying("BTC-PERP"), Some("BTC"));
198        assert_eq!(perp_underlying("ETH-PERP"), Some("ETH"));
199    }
200
201    #[test]
202    fn perp_underlying_handles_raw_symbol() {
203        assert_eq!(perp_underlying("BTC"), Some("BTC"));
204        assert_eq!(perp_underlying("ETH"), Some("ETH"));
205    }
206
207    #[test]
208    fn perp_underlying_rejects_option_symbols() {
209        assert_eq!(perp_underlying("BTC-20240131-100000-C"), None);
210        assert_eq!(perp_underlying("BTC-INVALID"), None);
211    }
212
213    #[test]
214    fn perp_underlying_rejects_empty() {
215        assert_eq!(perp_underlying(""), None);
216    }
217
218    #[test]
219    fn test_validate_order_request_valid() {
220        let request = WsOrderRequest {
221            price: dec!(100),
222            size: dec!(10),
223            symbol: "BTC-20240131-100000-C".to_string(),
224            side: Side::Buy,
225            tif: hypercall_types::TimeInForce::GTC,
226        };
227        assert!(validate_order_request(&request).is_ok());
228    }
229
230    #[test]
231    fn test_validate_order_request_invalid_price() {
232        let request = WsOrderRequest {
233            price: dec!(-10),
234            size: dec!(10),
235            symbol: "BTC-20240131-100000-C".to_string(),
236            side: Side::Buy,
237            tif: hypercall_types::TimeInForce::GTC,
238        };
239        assert!(validate_order_request(&request).is_err());
240    }
241
242    #[test]
243    fn test_validate_order_request_zero_size() {
244        let request = WsOrderRequest {
245            price: dec!(100),
246            size: dec!(0),
247            symbol: "BTC-20240131-100000-C".to_string(),
248            side: Side::Buy,
249            tif: hypercall_types::TimeInForce::GTC,
250        };
251        assert!(validate_order_request(&request).is_err());
252    }
253}