Skip to main content

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
35impl ParsedMarketData {
36    /// Checks if the current financial instrument is a call option.
37    ///
38    /// A call option is a financial derivative that gives the holder the right (but not the obligation)
39    /// to buy an underlying asset at a specified price within a specified time period. This method checks
40    /// whether the instrument is a call option by inspecting the already-parsed `option_type` field.
41    ///
42    /// # Returns
43    ///
44    /// * `true` if the parsed `option_type` is exactly `"CALL"`, indicating it is a call option.
45    /// * `false` otherwise.
46    ///
47    #[must_use]
48    #[inline]
49    pub fn is_call(&self) -> bool {
50        self.option_type.as_deref() == Some("CALL")
51    }
52
53    /// Checks if the financial instrument is a "PUT" option.
54    ///
55    /// This method inspects the already-parsed `option_type` field rather than
56    /// re-scanning the instrument name, so it stays in sync with the parser.
57    ///
58    /// # Returns
59    /// * `true` - If the parsed `option_type` is exactly `"PUT"`.
60    /// * `false` - Otherwise.
61    ///
62    #[must_use]
63    #[inline]
64    pub fn is_put(&self) -> bool {
65        self.option_type.as_deref() == Some("PUT")
66    }
67}
68
69/// Normalize text by removing accents and standardizing names
70///
71/// This function converts accented characters to their non-accented equivalents
72/// and standardizes certain names (e.g., "Japan" in different languages)
73pub fn normalize_text(text: &str) -> String {
74    // Special case for Japan in Spanish
75    if text.contains("Japón") {
76        return text.replace("Japón", "Japan");
77    }
78
79    let mut result = String::with_capacity(text.len());
80    for c in text.chars() {
81        match c {
82            'á' | 'à' | 'ä' | 'â' | 'ã' => result.push('a'),
83            'é' | 'è' | 'ë' | 'ê' => result.push('e'),
84            'í' | 'ì' | 'ï' | 'î' => result.push('i'),
85            'ó' | 'ò' | 'ö' | 'ô' | 'õ' => result.push('o'),
86            'ú' | 'ù' | 'ü' | 'û' => result.push('u'),
87            'ñ' => result.push('n'),
88            'ç' => result.push('c'),
89            'Á' | 'À' | 'Ä' | 'Â' | 'Ã' => result.push('A'),
90            'É' | 'È' | 'Ë' | 'Ê' => result.push('E'),
91            'Í' | 'Ì' | 'Ï' | 'Î' => result.push('I'),
92            'Ó' | 'Ò' | 'Ö' | 'Ô' | 'Õ' => result.push('O'),
93            'Ú' | 'Ù' | 'Ü' | 'Û' => result.push('U'),
94            'Ñ' => result.push('N'),
95            'Ç' => result.push('C'),
96            _ => result.push(c),
97        }
98    }
99    result
100}
101
102/// Parse the instrument name to extract asset name, strike price, and option type
103///
104/// # Examples
105///
106/// ```
107/// use ig_client::utils::parsing::parse_instrument_name;
108///
109/// let info = parse_instrument_name("US Tech 100 19200 CALL ($1)");
110/// assert_eq!(info.asset_name, "US Tech 100");
111/// assert_eq!(info.strike, Some("19200".to_string()));
112/// assert_eq!(info.option_type, Some("CALL".to_string()));
113///
114/// let info = parse_instrument_name("Germany 40");
115/// assert_eq!(info.asset_name, "Germany 40");
116/// assert_eq!(info.strike, None);
117/// assert_eq!(info.option_type, None);
118/// ```
119pub fn parse_instrument_name(instrument_name: &str) -> ParsedOptionInfo {
120    // Create regex patterns for different instrument name formats
121    // Lazy initialization of regex patterns
122    lazy_static::lazy_static! {
123        // Pattern for standard options like "US Tech 100 19200 CALL ($1)".
124        // The strike group accepts an optional decimal part, so this pattern
125        // already covers decimal strikes like "Volatility Index 10.5 PUT ($1)".
126        static ref OPTION_PATTERN: Regex = Regex::new(r"^(.*?)\s+(\d+(?:\.\d+)?)\s+((?i:CALL|PUT))(?:\s+\(.*?\))?$").expect("valid option regex");
127
128        // Pattern for options with no space between parenthesis and strike like "Weekly Germany 40 (Wed)27500 PUT"
129        static ref SPECIAL_OPTION_PATTERN: Regex = Regex::new(r"^(.*?)\s+\(([^)]+)\)(\d+)\s+((?i:CALL|PUT))(?:\s+\(.*?\))?$").expect("valid special option regex");
130
131        // Pattern for options with incomplete parenthesis like "Weekly USDJPY 12950 CALL (Y100"
132        static ref INCOMPLETE_PAREN_PATTERN: Regex = Regex::new(r"^(.*?)\s+(\d+(?:\.\d+)?)\s+((?i:CALL|PUT))\s+\([^)]*$").expect("valid incomplete paren regex");
133
134        // Pattern for other instruments that don't follow the option pattern
135        static ref GENERIC_PATTERN: Regex = Regex::new(r"^(.*?)(?:\s+\(.*?\))?$").expect("valid generic regex");
136
137        // Pattern to clean up asset names
138        static ref DAILY_WEEKLY_PATTERN: Regex = Regex::new(r"^(Daily|Weekly)\s+(.*?)$").expect("valid daily/weekly regex");
139        static ref END_OF_MONTH_PATTERN: Regex = Regex::new(r"^(End of Month)\s+(.*?)$").expect("valid end-of-month regex");
140        static ref QUARTERLY_PATTERN: Regex = Regex::new(r"^(Quarterly)\s+(.*?)$").expect("valid quarterly regex");
141        static ref MONTHLY_PATTERN: Regex = Regex::new(r"^(Monthly)\s+(.*?)$").expect("valid monthly regex");
142        static ref SUFFIX_PATTERN: Regex = Regex::new(r"^(.*?)\s+\(.*?\)$").expect("valid suffix regex");
143    }
144
145    // Helper function to clean up asset names
146    fn clean_asset_name(asset_name: &str) -> String {
147        // First normalize the text to remove accents
148        let normalized_name = normalize_text(asset_name);
149
150        // Remove prefixes like "Daily", "Weekly", etc.
151        let asset_name = if let Some(captures) = DAILY_WEEKLY_PATTERN.captures(&normalized_name) {
152            captures.get(2).map_or("", |m| m.as_str()).trim()
153        } else if let Some(captures) = END_OF_MONTH_PATTERN.captures(&normalized_name) {
154            captures.get(2).map_or("", |m| m.as_str()).trim()
155        } else if let Some(captures) = QUARTERLY_PATTERN.captures(&normalized_name) {
156            captures.get(2).map_or("", |m| m.as_str()).trim()
157        } else if let Some(captures) = MONTHLY_PATTERN.captures(&normalized_name) {
158            captures.get(2).map_or("", |m| m.as_str()).trim()
159        } else {
160            &normalized_name
161        };
162
163        // Remove suffixes like "(End of Month)", etc.
164        let asset_name = if let Some(captures) = SUFFIX_PATTERN.captures(asset_name) {
165            captures.get(1).map_or("", |m| m.as_str()).trim()
166        } else {
167            asset_name
168        };
169
170        asset_name.to_string()
171    }
172
173    if let Some(captures) = OPTION_PATTERN.captures(instrument_name) {
174        // This is an option with strike and type
175        let asset_name = captures.get(1).map_or("", |m| m.as_str()).trim();
176        ParsedOptionInfo {
177            asset_name: clean_asset_name(asset_name),
178            strike: captures.get(2).map(|m| m.as_str().to_string()),
179            option_type: captures.get(3).map(|m| m.as_str().to_uppercase()),
180        }
181    } else if let Some(captures) = SPECIAL_OPTION_PATTERN.captures(instrument_name) {
182        // This is a special case like "Weekly Germany 40 (Wed)27500 PUT"
183        let base_name = captures.get(1).map_or("", |m| m.as_str()).trim();
184        ParsedOptionInfo {
185            asset_name: clean_asset_name(base_name),
186            strike: captures.get(3).map(|m| m.as_str().to_string()),
187            option_type: captures.get(4).map(|m| m.as_str().to_uppercase()),
188        }
189    } else if let Some(captures) = INCOMPLETE_PAREN_PATTERN.captures(instrument_name) {
190        // This is a case with incomplete parenthesis like "Weekly USDJPY 12950 CALL (Y100"
191        let asset_name = captures.get(1).map_or("", |m| m.as_str()).trim();
192        ParsedOptionInfo {
193            asset_name: clean_asset_name(asset_name),
194            strike: captures.get(2).map(|m| m.as_str().to_string()),
195            option_type: captures.get(3).map(|m| m.as_str().to_uppercase()),
196        }
197    } else if let Some(captures) = GENERIC_PATTERN.captures(instrument_name) {
198        // This is a generic instrument without strike or type
199        let asset_name = captures.get(1).map_or("", |m| m.as_str()).trim();
200        ParsedOptionInfo {
201            asset_name: clean_asset_name(asset_name),
202            strike: None,
203            option_type: None,
204        }
205    } else {
206        // Fallback for any other format
207        warn!("Could not parse instrument name: {}", instrument_name);
208        ParsedOptionInfo {
209            asset_name: instrument_name.to_string(),
210            strike: None,
211            option_type: None,
212        }
213    }
214}
215
216/// Helper function to deserialize null values as empty vectors
217pub fn deserialize_null_as_empty_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
218where
219    D: serde::Deserializer<'de>,
220    T: serde::Deserialize<'de>,
221{
222    let opt = Option::deserialize(deserializer)?;
223    Ok(opt.unwrap_or_default())
224}
225
226/// Helper function to deserialize a nullable status field
227/// When the status is null in the JSON, we default to Open status
228pub fn deserialize_nullable_status<'de, D>(deserializer: D) -> Result<Status, D::Error>
229where
230    D: Deserializer<'de>,
231{
232    let opt = Option::deserialize(deserializer)?;
233    Ok(opt.unwrap_or(Status::Open))
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn test_parse_instrument_name_standard_option() {
242        let info = parse_instrument_name("US Tech 100 19200 CALL ($1)");
243        assert_eq!(info.asset_name, "US Tech 100");
244        assert_eq!(info.strike, Some("19200".to_string()));
245        assert_eq!(info.option_type, Some("CALL".to_string()));
246    }
247
248    #[test]
249    fn test_parse_instrument_name_mixed_case_option_type() {
250        // IG returns some option instrument names with the option type in
251        // title case (e.g. Wall Street monthly options); the parser must be
252        // case-insensitive and normalize the type to uppercase.
253        let info = parse_instrument_name("Wall Street 50000 Call ($1)");
254        assert_eq!(info.asset_name, "Wall Street");
255        assert_eq!(info.strike, Some("50000".to_string()));
256        assert_eq!(info.option_type, Some("CALL".to_string()));
257
258        let info = parse_instrument_name("Wall Street 50000 Put ($1)");
259        assert_eq!(info.option_type, Some("PUT".to_string()));
260    }
261
262    #[test]
263    fn test_parse_instrument_name_decimal_strike() {
264        // Proves `OPTION_PATTERN` alone handles decimal strikes; there is no
265        // separate decimal-strike pattern.
266        let info = parse_instrument_name("Volatility Index 10.5 PUT ($1)");
267        assert_eq!(info.asset_name, "Volatility Index");
268        assert_eq!(info.strike, Some("10.5".to_string()));
269        assert_eq!(info.option_type, Some("PUT".to_string()));
270    }
271
272    #[test]
273    fn test_parse_instrument_name_decimal_strike_no_suffix() {
274        // A decimal strike without a trailing "($1)" suffix is still handled by
275        // `OPTION_PATTERN`.
276        let info = parse_instrument_name("Volatility Index 10.5 CALL");
277        assert_eq!(info.asset_name, "Volatility Index");
278        assert_eq!(info.strike, Some("10.5".to_string()));
279        assert_eq!(info.option_type, Some("CALL".to_string()));
280    }
281
282    #[test]
283    fn test_parse_instrument_name_no_option() {
284        let info = parse_instrument_name("Germany 40");
285        assert_eq!(info.asset_name, "Germany 40");
286        assert_eq!(info.strike, None);
287        assert_eq!(info.option_type, None);
288    }
289
290    fn market_with_option_type(option_type: Option<&str>) -> ParsedMarketData {
291        ParsedMarketData {
292            epic: "OP.D.OTCSPX3.6910C.IP".to_string(),
293            instrument_name: "US 500 6910 CALL ($1)".to_string(),
294            expiry: "DEC-25".to_string(),
295            asset_name: "US 500".to_string(),
296            strike: Some("6910".to_string()),
297            option_type: option_type.map(str::to_string),
298        }
299    }
300
301    #[test]
302    fn test_parsed_market_data_is_call_uses_parsed_option_type() {
303        let call = market_with_option_type(Some("CALL"));
304        assert!(call.is_call());
305        assert!(!call.is_put());
306    }
307
308    #[test]
309    fn test_parsed_market_data_is_put_uses_parsed_option_type() {
310        let put = market_with_option_type(Some("PUT"));
311        assert!(put.is_put());
312        assert!(!put.is_call());
313    }
314
315    #[test]
316    fn test_parsed_market_data_no_option_type_is_neither() {
317        // A non-option instrument has `option_type == None`, so neither
318        // predicate fires even though the name might contain other tokens.
319        let none = market_with_option_type(None);
320        assert!(!none.is_call());
321        assert!(!none.is_put());
322    }
323
324    #[test]
325    fn test_parse_instrument_name_with_parenthesis() {
326        let info = parse_instrument_name("US 500 (Mini)");
327        assert_eq!(info.asset_name, "US 500");
328        assert_eq!(info.strike, None);
329        assert_eq!(info.option_type, None);
330    }
331
332    #[test]
333    fn test_parse_instrument_name_special_format() {
334        let info = parse_instrument_name("Weekly Germany 40 (Wed)27500 PUT");
335        assert_eq!(info.asset_name, "Germany 40");
336        assert_eq!(info.strike, Some("27500".to_string()));
337        assert_eq!(info.option_type, Some("PUT".to_string()));
338    }
339
340    #[test]
341    fn test_parse_instrument_name_daily_prefix() {
342        let info = parse_instrument_name("Daily Germany 40 24225 CALL");
343        assert_eq!(info.asset_name, "Germany 40");
344        assert_eq!(info.strike, Some("24225".to_string()));
345        assert_eq!(info.option_type, Some("CALL".to_string()));
346    }
347
348    #[test]
349    fn test_parse_instrument_name_weekly_prefix() {
350        let info = parse_instrument_name("Weekly US Tech 100 19200 CALL");
351        assert_eq!(info.asset_name, "US Tech 100");
352        assert_eq!(info.strike, Some("19200".to_string()));
353        assert_eq!(info.option_type, Some("CALL".to_string()));
354    }
355
356    #[test]
357    fn test_parse_instrument_name_end_of_month_prefix() {
358        let info = parse_instrument_name("End of Month EU Stocks 50 4575 PUT");
359        assert_eq!(info.asset_name, "EU Stocks 50");
360        assert_eq!(info.strike, Some("4575".to_string()));
361        assert_eq!(info.option_type, Some("PUT".to_string()));
362    }
363
364    #[test]
365    fn test_parse_instrument_name_end_of_month_suffix() {
366        let info = parse_instrument_name("US 500 (End of Month) 3200 PUT");
367        assert_eq!(info.asset_name, "US 500");
368        assert_eq!(info.strike, Some("3200".to_string()));
369        assert_eq!(info.option_type, Some("PUT".to_string()));
370    }
371
372    #[test]
373    fn test_parse_instrument_name_quarterly_prefix() {
374        let info = parse_instrument_name("Quarterly GBPUSD 10000 PUT ($1)");
375        assert_eq!(info.asset_name, "GBPUSD");
376        assert_eq!(info.strike, Some("10000".to_string()));
377        assert_eq!(info.option_type, Some("PUT".to_string()));
378    }
379
380    #[test]
381    fn test_parse_instrument_name_weekly_with_day() {
382        let info = parse_instrument_name("Weekly Germany 40 (Mon) 18500 PUT");
383        assert_eq!(info.asset_name, "Germany 40");
384        assert_eq!(info.strike, Some("18500".to_string()));
385        assert_eq!(info.option_type, Some("PUT".to_string()));
386    }
387
388    #[test]
389    fn test_parse_instrument_name_incomplete_parenthesis() {
390        let info = parse_instrument_name("Weekly USDJPY 12950 CALL (Y100");
391        assert_eq!(info.asset_name, "USDJPY");
392        assert_eq!(info.strike, Some("12950".to_string()));
393        assert_eq!(info.option_type, Some("CALL".to_string()));
394    }
395
396    #[test]
397    fn test_parse_instrument_name_with_accents() {
398        let info = parse_instrument_name("Japón 225 18500 CALL");
399        assert_eq!(info.asset_name, "Japan 225");
400        assert_eq!(info.strike, Some("18500".to_string()));
401        assert_eq!(info.option_type, Some("CALL".to_string()));
402    }
403}