ig_client/utils/
parsing.rs

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