ig_client/utils/
parsing.rs

1use crate::presentation::order::Status;
2use pretty_simple_display::{DebugPretty, DisplaySimple};
3use regex::Regex;
4use serde::{Deserialize, Deserializer, Serialize};
5use tracing::warn;
6
7/// Structure to represent the parsed option information from an instrument name
8#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, PartialEq)]
9pub struct ParsedOptionInfo {
10    /// Name of the underlying asset (e.g., "US Tech 100")
11    pub asset_name: String,
12    /// Strike price of the option (e.g., "19200")
13    pub strike: Option<String>,
14    /// Type of the option: CALL or PUT
15    pub option_type: Option<String>,
16}
17
18/// Structure to represent the parsed market data with additional information
19#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize)]
20pub struct ParsedMarketData {
21    /// Unique identifier for the market (EPIC code)
22    pub epic: String,
23    /// Full name of the financial instrument
24    pub instrument_name: String,
25    /// Expiry date of the instrument (if applicable)
26    pub expiry: String,
27    /// Name of the underlying asset
28    pub asset_name: String,
29    /// Strike price for options
30    pub strike: Option<String>,
31    /// Type of option (e.g., 'CALL' or 'PUT')
32    pub option_type: Option<String>,
33}
34
35/// Normalize text by removing accents and standardizing names
36///
37/// This function converts accented characters to their non-accented equivalents
38/// and standardizes certain names (e.g., "Japan" in different languages)
39pub fn normalize_text(text: &str) -> String {
40    // Special case for Japan in Spanish
41    if text.contains("Japón") {
42        return text.replace("Japón", "Japan");
43    }
44
45    let mut result = String::with_capacity(text.len());
46    for c in text.chars() {
47        match c {
48            'á' | 'à' | 'ä' | 'â' | 'ã' => result.push('a'),
49            'é' | 'è' | 'ë' | 'ê' => result.push('e'),
50            'í' | 'ì' | 'ï' | 'î' => result.push('i'),
51            'ó' | 'ò' | 'ö' | 'ô' | 'õ' => result.push('o'),
52            'ú' | 'ù' | 'ü' | 'û' => result.push('u'),
53            'ñ' => result.push('n'),
54            'ç' => result.push('c'),
55            'Á' | 'À' | 'Ä' | 'Â' | 'Ã' => result.push('A'),
56            'É' | 'È' | 'Ë' | 'Ê' => result.push('E'),
57            'Í' | 'Ì' | 'Ï' | 'Î' => result.push('I'),
58            'Ó' | 'Ò' | 'Ö' | 'Ô' | 'Õ' => result.push('O'),
59            'Ú' | 'Ù' | 'Ü' | 'Û' => result.push('U'),
60            'Ñ' => result.push('N'),
61            'Ç' => result.push('C'),
62            _ => result.push(c),
63        }
64    }
65    result
66}
67
68/// Parse the instrument name to extract asset name, strike price, and option type
69///
70/// # Examples
71///
72/// ```
73/// use ig_client::utils::parsing::parse_instrument_name;
74///
75/// let info = parse_instrument_name("US Tech 100 19200 CALL ($1)");
76/// assert_eq!(info.asset_name, "US Tech 100");
77/// assert_eq!(info.strike, Some("19200".to_string()));
78/// assert_eq!(info.option_type, Some("CALL".to_string()));
79///
80/// let info = parse_instrument_name("Germany 40");
81/// assert_eq!(info.asset_name, "Germany 40");
82/// assert_eq!(info.strike, None);
83/// assert_eq!(info.option_type, None);
84/// ```
85pub fn parse_instrument_name(instrument_name: &str) -> ParsedOptionInfo {
86    // Create regex patterns for different instrument name formats
87    // Lazy initialization of regex patterns
88    lazy_static::lazy_static! {
89        // Pattern for standard options like "US Tech 100 19200 CALL ($1)"
90        static ref OPTION_PATTERN: Regex = Regex::new(r"^(.*?)\s+(\d+(?:\.\d+)?)\s+(CALL|PUT)(?:\s+\(.*?\))?$").unwrap();
91
92        // Pattern for options with decimal strikes like "Volatility Index 10.5 PUT ($1)"
93        static ref DECIMAL_OPTION_PATTERN: Regex = Regex::new(r"^(.*?)\s+(\d+\.\d+)\s+(CALL|PUT)(?:\s+\(.*?\))?$").unwrap();
94
95        // Pattern for options with no space between parenthesis and strike like "Weekly Germany 40 (Wed)27500 PUT"
96        static ref SPECIAL_OPTION_PATTERN: Regex = Regex::new(r"^(.*?)\s+\(([^)]+)\)(\d+)\s+(CALL|PUT)(?:\s+\(.*?\))?$").unwrap();
97
98        // Pattern for options with incomplete parenthesis like "Weekly USDJPY 12950 CALL (Y100"
99        static ref INCOMPLETE_PAREN_PATTERN: Regex = Regex::new(r"^(.*?)\s+(\d+(?:\.\d+)?)\s+(CALL|PUT)\s+\([^)]*$").unwrap();
100
101        // Pattern for other instruments that don't follow the option pattern
102        static ref GENERIC_PATTERN: Regex = Regex::new(r"^(.*?)(?:\s+\(.*?\))?$").unwrap();
103
104        // Pattern to clean up asset names
105        static ref DAILY_WEEKLY_PATTERN: Regex = Regex::new(r"^(Daily|Weekly)\s+(.*?)$").unwrap();
106        static ref END_OF_MONTH_PATTERN: Regex = Regex::new(r"^(End of Month)\s+(.*?)$").unwrap();
107        static ref QUARTERLY_PATTERN: Regex = Regex::new(r"^(Quarterly)\s+(.*?)$").unwrap();
108        static ref MONTHLY_PATTERN: Regex = Regex::new(r"^(Monthly)\s+(.*?)$").unwrap();
109        static ref SUFFIX_PATTERN: Regex = Regex::new(r"^(.*?)\s+\(.*?\)$").unwrap();
110    }
111
112    // Helper function to clean up asset names
113    fn clean_asset_name(asset_name: &str) -> String {
114        // First normalize the text to remove accents
115        let normalized_name = normalize_text(asset_name);
116
117        // Remove prefixes like "Daily", "Weekly", etc.
118        let asset_name = if let Some(captures) = DAILY_WEEKLY_PATTERN.captures(&normalized_name) {
119            captures.get(2).unwrap().as_str().trim()
120        } else if let Some(captures) = END_OF_MONTH_PATTERN.captures(&normalized_name) {
121            captures.get(2).unwrap().as_str().trim()
122        } else if let Some(captures) = QUARTERLY_PATTERN.captures(&normalized_name) {
123            captures.get(2).unwrap().as_str().trim()
124        } else if let Some(captures) = MONTHLY_PATTERN.captures(&normalized_name) {
125            captures.get(2).unwrap().as_str().trim()
126        } else {
127            &normalized_name
128        };
129
130        // Remove suffixes like "(End of Month)", etc.
131        let asset_name = if let Some(captures) = SUFFIX_PATTERN.captures(asset_name) {
132            captures.get(1).unwrap().as_str().trim()
133        } else {
134            asset_name
135        };
136
137        asset_name.to_string()
138    }
139
140    if let Some(captures) = OPTION_PATTERN.captures(instrument_name) {
141        // This is an option with strike and type
142        let asset_name = captures.get(1).unwrap().as_str().trim();
143        ParsedOptionInfo {
144            asset_name: clean_asset_name(asset_name),
145            strike: Some(captures.get(2).unwrap().as_str().to_string()),
146            option_type: Some(captures.get(3).unwrap().as_str().to_string()),
147        }
148    } else if let Some(captures) = SPECIAL_OPTION_PATTERN.captures(instrument_name) {
149        // This is a special case like "Weekly Germany 40 (Wed)27500 PUT"
150        let base_name = captures.get(1).unwrap().as_str().trim();
151        ParsedOptionInfo {
152            asset_name: clean_asset_name(base_name),
153            strike: Some(captures.get(3).unwrap().as_str().to_string()),
154            option_type: Some(captures.get(4).unwrap().as_str().to_string()),
155        }
156    } else if let Some(captures) = INCOMPLETE_PAREN_PATTERN.captures(instrument_name) {
157        // This is a case with incomplete parenthesis like "Weekly USDJPY 12950 CALL (Y100"
158        let asset_name = captures.get(1).unwrap().as_str().trim();
159        ParsedOptionInfo {
160            asset_name: clean_asset_name(asset_name),
161            strike: Some(captures.get(2).unwrap().as_str().to_string()),
162            option_type: Some(captures.get(3).unwrap().as_str().to_string()),
163        }
164    } else if let Some(captures) = DECIMAL_OPTION_PATTERN.captures(instrument_name) {
165        // This is an option with decimal strike
166        let asset_name = captures.get(1).unwrap().as_str().trim();
167        ParsedOptionInfo {
168            asset_name: clean_asset_name(asset_name),
169            strike: Some(captures.get(2).unwrap().as_str().to_string()),
170            option_type: Some(captures.get(3).unwrap().as_str().to_string()),
171        }
172    } else if let Some(captures) = GENERIC_PATTERN.captures(instrument_name) {
173        // This is a generic instrument without strike or type
174        let asset_name = captures.get(1).unwrap().as_str().trim();
175        ParsedOptionInfo {
176            asset_name: clean_asset_name(asset_name),
177            strike: None,
178            option_type: None,
179        }
180    } else {
181        // Fallback for any other format
182        warn!("Could not parse instrument name: {}", instrument_name);
183        ParsedOptionInfo {
184            asset_name: instrument_name.to_string(),
185            strike: None,
186            option_type: None,
187        }
188    }
189}
190
191/// Helper function to deserialize null values as empty vectors
192pub fn deserialize_null_as_empty_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
193where
194    D: serde::Deserializer<'de>,
195    T: serde::Deserialize<'de>,
196{
197    let opt = Option::deserialize(deserializer)?;
198    Ok(opt.unwrap_or_default())
199}
200
201/// Helper function to deserialize a nullable status field
202/// When the status is null in the JSON, we default to Open status
203pub fn deserialize_nullable_status<'de, D>(deserializer: D) -> Result<Status, D::Error>
204where
205    D: Deserializer<'de>,
206{
207    let opt = Option::deserialize(deserializer)?;
208    Ok(opt.unwrap_or(Status::Open))
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_parse_instrument_name_standard_option() {
217        let info = parse_instrument_name("US Tech 100 19200 CALL ($1)");
218        assert_eq!(info.asset_name, "US Tech 100");
219        assert_eq!(info.strike, Some("19200".to_string()));
220        assert_eq!(info.option_type, Some("CALL".to_string()));
221    }
222
223    #[test]
224    fn test_parse_instrument_name_decimal_strike() {
225        let info = parse_instrument_name("Volatility Index 10.5 PUT ($1)");
226        assert_eq!(info.asset_name, "Volatility Index");
227        assert_eq!(info.strike, Some("10.5".to_string()));
228        assert_eq!(info.option_type, Some("PUT".to_string()));
229    }
230
231    #[test]
232    fn test_parse_instrument_name_no_option() {
233        let info = parse_instrument_name("Germany 40");
234        assert_eq!(info.asset_name, "Germany 40");
235        assert_eq!(info.strike, None);
236        assert_eq!(info.option_type, None);
237    }
238
239    #[test]
240    fn test_parse_instrument_name_with_parenthesis() {
241        let info = parse_instrument_name("US 500 (Mini)");
242        assert_eq!(info.asset_name, "US 500");
243        assert_eq!(info.strike, None);
244        assert_eq!(info.option_type, None);
245    }
246
247    #[test]
248    fn test_parse_instrument_name_special_format() {
249        let info = parse_instrument_name("Weekly Germany 40 (Wed)27500 PUT");
250        assert_eq!(info.asset_name, "Germany 40");
251        assert_eq!(info.strike, Some("27500".to_string()));
252        assert_eq!(info.option_type, Some("PUT".to_string()));
253    }
254
255    #[test]
256    fn test_parse_instrument_name_daily_prefix() {
257        let info = parse_instrument_name("Daily Germany 40 24225 CALL");
258        assert_eq!(info.asset_name, "Germany 40");
259        assert_eq!(info.strike, Some("24225".to_string()));
260        assert_eq!(info.option_type, Some("CALL".to_string()));
261    }
262
263    #[test]
264    fn test_parse_instrument_name_weekly_prefix() {
265        let info = parse_instrument_name("Weekly US Tech 100 19200 CALL");
266        assert_eq!(info.asset_name, "US Tech 100");
267        assert_eq!(info.strike, Some("19200".to_string()));
268        assert_eq!(info.option_type, Some("CALL".to_string()));
269    }
270
271    #[test]
272    fn test_parse_instrument_name_end_of_month_prefix() {
273        let info = parse_instrument_name("End of Month EU Stocks 50 4575 PUT");
274        assert_eq!(info.asset_name, "EU Stocks 50");
275        assert_eq!(info.strike, Some("4575".to_string()));
276        assert_eq!(info.option_type, Some("PUT".to_string()));
277    }
278
279    #[test]
280    fn test_parse_instrument_name_end_of_month_suffix() {
281        let info = parse_instrument_name("US 500 (End of Month) 3200 PUT");
282        assert_eq!(info.asset_name, "US 500");
283        assert_eq!(info.strike, Some("3200".to_string()));
284        assert_eq!(info.option_type, Some("PUT".to_string()));
285    }
286
287    #[test]
288    fn test_parse_instrument_name_quarterly_prefix() {
289        let info = parse_instrument_name("Quarterly GBPUSD 10000 PUT ($1)");
290        assert_eq!(info.asset_name, "GBPUSD");
291        assert_eq!(info.strike, Some("10000".to_string()));
292        assert_eq!(info.option_type, Some("PUT".to_string()));
293    }
294
295    #[test]
296    fn test_parse_instrument_name_weekly_with_day() {
297        let info = parse_instrument_name("Weekly Germany 40 (Mon) 18500 PUT");
298        assert_eq!(info.asset_name, "Germany 40");
299        assert_eq!(info.strike, Some("18500".to_string()));
300        assert_eq!(info.option_type, Some("PUT".to_string()));
301    }
302
303    #[test]
304    fn test_parse_instrument_name_incomplete_parenthesis() {
305        let info = parse_instrument_name("Weekly USDJPY 12950 CALL (Y100");
306        assert_eq!(info.asset_name, "USDJPY");
307        assert_eq!(info.strike, Some("12950".to_string()));
308        assert_eq!(info.option_type, Some("CALL".to_string()));
309    }
310
311    #[test]
312    fn test_parse_instrument_name_with_accents() {
313        let info = parse_instrument_name("Japón 225 18500 CALL");
314        assert_eq!(info.asset_name, "Japan 225");
315        assert_eq!(info.strike, Some("18500".to_string()));
316        assert_eq!(info.option_type, Some("CALL".to_string()));
317    }
318}