ig_client/utils/
parsing.rs

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