ig_client/utils/
parsing.rs

1use crate::error::AppError;
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Information about a financial instrument parsed from its name
7/// Used to extract details from instrument descriptions in the IG Markets API
8#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default)]
9pub struct InstrumentInfo {
10    /// The underlying asset or market (e.g., "EU50", "GOLD", "US500")
11    pub underlying: Option<String>,
12    /// The strike price for options
13    pub strike: Option<f64>,
14    /// The type of option ("CALL" or "PUT")
15    pub option_type: Option<String>,
16    /// Whether the instrument is an option
17    pub is_option: bool,
18}
19
20/// Parse the instrument name string to extract trading instrument details
21pub fn parse_instrument_name(instrument_name: &str) -> Result<InstrumentInfo, AppError> {
22    let mut info = InstrumentInfo::default();
23
24    // Skip known administrative entries that are not options
25    if instrument_name.contains("Cargo por tarifa")
26        || instrument_name.contains("Daily Admin Fee")
27        || instrument_name.contains("Fee")
28        || (instrument_name.starts_with("End ") && !instrument_name.starts_with("End of Month"))
29        || instrument_name.contains("Funds")
30        || instrument_name.contains("Funds Transfer")
31    {
32        return Ok(info); // Return default with is_option = false
33    }
34
35    // Check if it's an option by looking for CALL/PUT or Call/Put
36    let option_type_re = Regex::new(r"(?i)(CALL|PUT|Call|Put)\b").unwrap();
37    if let Some(cap) = option_type_re.captures(instrument_name) {
38        let opt_type = cap[1].to_uppercase();
39        info.option_type = Some(opt_type);
40        info.is_option = true;
41    } else {
42        // Not an option
43        return Ok(info);
44    }
45
46    // Extract strike price - look for a number before or after CALL/PUT
47    let strike_re = Regex::new(r"\b(\d+(?:\.\d+)?)\s*(?:CALL|PUT|Call|Put)|(?:CALL|PUT|Call|Put)\s*(?:a\s*)?(\d+(?:\.\d+)?)").unwrap();
48    if let Some(cap) = strike_re.captures(instrument_name) {
49        // The regex has two capture groups, check which one matched
50        let strike_str = cap.get(1).or_else(|| cap.get(2)).unwrap().as_str();
51        if let Ok(strike_val) = strike_str.parse::<f64>() {
52            info.strike = Some(strike_val);
53        }
54    }
55
56    // Extract underlying - this is the most complex part as formats vary
57    extract_underlying(&mut info, instrument_name);
58
59    Ok(info)
60}
61
62/// Helper function to extract the underlying from the instrument name
63fn extract_underlying(info: &mut InstrumentInfo, instrument_name: &str) {
64    // Create a HashMap with all known patterns for easier maintenance
65    let known_patterns: HashMap<&str, &str> = [
66        // EU50 patterns
67        ("Eu Stocks 50", "EU50"),
68        ("EU Stocks 50", "EU50"),
69        ("EU50", "EU50"),
70        // GER40 patterns
71        ("Germany 40", "GER40"),
72        ("GER40", "GER40"),
73        // US500 patterns
74        ("US 500", "US500"),
75        ("US500", "US500"),
76        // USTECH patterns
77        ("US Tech 100", "USTECH"),
78        ("USTECH", "USTECH"),
79        // GOLD patterns
80        ("Gold Futures", "GOLD"),
81        ("Gold (", "GOLD"),
82        ("GOLD", "GOLD"),
83        ("Gold", "GOLD"),
84        // SILVER patterns
85        ("Silver Futures", "SILVER"),
86        ("Silver (", "SILVER"),
87        ("SILVER", "SILVER"),
88        ("Silver", "SILVER"),
89        // NATGAS patterns
90        ("Natural Gas", "NATGAS"),
91        ("NATGAS", "NATGAS"),
92        // UK100 patterns
93        ("FTSE", "UK100"),
94        ("UK100", "UK100"),
95        // US30 patterns
96        ("Wall Street", "US30"),
97        ("US30", "US30"),
98        // OIL patterns
99        ("Crude", "OIL"),
100        ("Oil", "OIL"),
101        ("OIL", "OIL"),
102        // FRA40 patterns
103        ("France 40", "FRA40"),
104        ("FRA40", "FRA40"),
105        // BITCOIN patterns
106        ("Bitcoin", "BITCOIN"),
107        ("BITCOIN", "BITCOIN"),
108        // ETHEREUM patterns
109        ("Ether", "ETHEREUM"),
110        ("ETHEREUM", "ETHEREUM"),
111        // PAYPAL patterns
112        ("Paypal", "PAYPAL"),
113        ("PAYPAL", "PAYPAL"),
114    ]
115    .iter()
116    .cloned()
117    .collect();
118
119    // Special case for "End of Month" at the beginning
120    if instrument_name.starts_with("End of Month") {
121        let parts: Vec<&str> = instrument_name.split_whitespace().collect();
122        if parts.len() >= 5 && parts[3] == "Germany" && parts[4] == "40" {
123            info.underlying = Some("GER40".to_string());
124            return;
125        } else if parts.len() >= 6 && parts[3] == "EU" && parts[4] == "Stocks" && parts[5] == "50" {
126            info.underlying = Some("EU50".to_string());
127            return;
128        }
129    }
130
131    // Special case for "Option premium" entries
132    if instrument_name.starts_with("Option premium") {
133        // Extract the underlying after "received" or "paid"
134        let option_premium_re = Regex::new(r"Option premium (?:received|paid) (.*?)(?:\s+\d+(?:\.\d+)?|\s+\(Wed\)\d+|\s+\(End of Month\)\d+|\s+\(Mon\)\d+|\s+\(£\d+\)|\s+\(E\d+\)|\s+\(\$\d+\))\s*(?:CALL|PUT)").unwrap();
135        if let Some(cap) = option_premium_re.captures(instrument_name) {
136            let underlying_text = cap.get(1).unwrap().as_str().trim();
137
138            // Use the same known_patterns HashMap to map the underlying
139            for (pattern, standard_name) in &known_patterns {
140                if underlying_text.contains(pattern) {
141                    info.underlying = Some(standard_name.to_string());
142                    return;
143                }
144            }
145
146            // If no known pattern matches, use the extracted text as-is
147            info.underlying = Some(underlying_text.to_string());
148            return;
149        }
150    }
151
152    // Special case for barrier options
153    if instrument_name.contains("Barrier Call") || instrument_name.contains("Barrier Put") {
154        if instrument_name.starts_with("Bitcoin") {
155            info.underlying = Some("BITCOIN".to_string());
156            return;
157        } else if instrument_name.starts_with("Ether") {
158            info.underlying = Some("ETHEREUM".to_string());
159            return;
160        }
161    }
162
163    // Try to match against the known_patterns for the entire instrument name
164    for (pattern, standard_name) in &known_patterns {
165        if instrument_name.contains(pattern) {
166            info.underlying = Some(standard_name.to_string());
167            return;
168        }
169    }
170
171    // If no pattern matches, use the first part of the name
172    let parts: Vec<&str> = instrument_name.split_whitespace().collect();
173    if !parts.is_empty() {
174        // If it starts with "Daily" or "Weekly", use the next word
175        if parts[0] == "Daily" || parts[0] == "Weekly" {
176            if parts.len() > 1 {
177                info.underlying = Some(parts[1].to_string());
178            }
179        } else {
180            // Otherwise use the first word
181            info.underlying = Some(parts[0].to_string());
182        }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use serde_json::Value;
190
191    /// Parse the instrument name from a JSON string to extract trading instrument details
192    pub fn parse_instrument_from_json(json_str: &str) -> Result<InstrumentInfo, AppError> {
193        // Parse the JSON string
194        let json_value: Value = match serde_json::from_str(json_str) {
195            Ok(value) => value,
196            Err(e) => return Err(AppError::SerializationError(e.to_string())),
197        };
198
199        // Extract the instrumentName field
200        let instrument_name = match json_value.get("instrumentName") {
201            Some(Value::String(name)) => name,
202            _ => {
203                return Err(AppError::SerializationError(
204                    "Missing or invalid instrumentName field".to_string(),
205                ));
206            }
207        };
208
209        parse_instrument_name(instrument_name)
210    }
211
212    #[test]
213    fn test_barrier_option() {
214        let json = r#"{"instrumentName":"Bitcoin Barrier Call a 69650 COMM DIAAAANMBDWXTAS Tipo de cambio 0.9330"}"#;
215        let info = parse_instrument_from_json(json).unwrap();
216        assert_eq!(info.underlying, Some("BITCOIN".to_string()));
217        assert_eq!(info.strike, Some(69650.0));
218        assert_eq!(info.option_type, Some("CALL".to_string()));
219        assert!(info.is_option);
220    }
221
222    #[test]
223    fn test_ether_barrier() {
224        let json = r#"{"instrumentName":"Ether Barrier Call a 3852 COMM DIAAAANSKFGLDAQ Tipo de cambio 0.9587"}"#;
225        let info = parse_instrument_from_json(json).unwrap();
226        assert_eq!(info.underlying, Some("ETHEREUM".to_string()));
227        assert_eq!(info.strike, Some(3852.0));
228        assert_eq!(info.option_type, Some("CALL".to_string()));
229        assert!(info.is_option);
230    }
231
232    #[test]
233    fn test_regular_option() {
234        let json =
235            r#"{"instrumentName":"Daily Eu Stocks 50 5184 CALL (EUR1) COMM DIAAAAN2FL4MTA6"}"#;
236        let info = parse_instrument_from_json(json).unwrap();
237        assert_eq!(info.underlying, Some("EU50".to_string()));
238        assert_eq!(info.strike, Some(5184.0));
239        assert_eq!(info.option_type, Some("CALL".to_string()));
240        assert!(info.is_option);
241    }
242
243    #[test]
244    fn test_fee_entry() {
245        let json = r#"{"instrumentName":"Fee charge for charts on April 25"}"#;
246        let info = parse_instrument_from_json(json).unwrap();
247        assert!(!info.is_option);
248        assert_eq!(info.underlying, None);
249    }
250
251    #[test]
252    fn test_financing_adjustment() {
253        let json = r#"{"instrumentName":"Daily Financing Adjustment - Bitcoin Barrier Call for 1 day USD converted at 0.9285"}"#;
254        let info = parse_instrument_from_json(json).unwrap();
255        assert!(info.is_option);
256    }
257
258    #[test]
259    fn test_weekly_option() {
260        let json = r#"{"instrumentName":"Weekly Germany 40 (Wed)19900 PUT COMM DIAAAAPDSPMDQAY"}"#;
261        let info = parse_instrument_from_json(json).unwrap();
262        assert_eq!(info.underlying, Some("GER40".to_string()));
263        assert_eq!(info.strike, Some(19900.0));
264        assert_eq!(info.option_type, Some("PUT".to_string()));
265        assert!(info.is_option);
266    }
267
268    #[test]
269    fn test_option_premium() {
270        let json = r#"{"instrumentName":"Option premium received US 500 6040 CALL ($1) Tipo de cambio 0.8947665"}"#;
271        let info = parse_instrument_from_json(json).unwrap();
272        assert_eq!(info.underlying, Some("US500".to_string()));
273        assert_eq!(info.strike, Some(6040.0));
274        assert_eq!(info.option_type, Some("CALL".to_string()));
275        assert!(info.is_option);
276    }
277
278    #[test]
279    fn test_option_premium_germany() {
280        let json =
281            r#"{"instrumentName":"Option premium received Weekly Germany 40 (Wed)23450 PUT"}"#;
282        let info = parse_instrument_from_json(json).unwrap();
283        assert_eq!(info.underlying, Some("GER40".to_string()));
284        assert_eq!(info.strike, Some(23450.0));
285        assert_eq!(info.option_type, Some("PUT".to_string()));
286        assert!(info.is_option);
287    }
288
289    #[test]
290    fn test_end_of_month_option() {
291        let json = r#"{"instrumentName":"Option premium paid End of Month Germany 40 23500 CALL"}"#;
292        let info = parse_instrument_from_json(json).unwrap();
293        assert_eq!(info.underlying, Some("GER40".to_string()));
294        assert_eq!(info.strike, Some(23500.0));
295        assert_eq!(info.option_type, Some("CALL".to_string()));
296        assert!(info.is_option);
297    }
298
299    #[test]
300    fn test_weekly_us_tech_option() {
301        let json =
302            r#"{"instrumentName":"Option premium received Weekly US Tech 100 (Mon) 21550 PUT"}"#;
303        let info = parse_instrument_from_json(json).unwrap();
304        assert_eq!(info.underlying, Some("USTECH".to_string()));
305        assert_eq!(info.strike, Some(21550.0));
306        assert_eq!(info.option_type, Some("PUT".to_string()));
307        assert!(info.is_option);
308    }
309
310    #[test]
311    fn test_oil_weekly_option() {
312        let json = r#"{"instrumentName":"Option premium received Oil Weekly (Dec Fut) 7150 CALL"}"#;
313        let info = parse_instrument_from_json(json).unwrap();
314        assert_eq!(info.underlying, Some("OIL".to_string()));
315        assert_eq!(info.strike, Some(7150.0));
316        assert_eq!(info.option_type, Some("CALL".to_string()));
317        assert!(info.is_option);
318    }
319
320    #[test]
321    fn test_gold_future_option() {
322        let json =
323            r#"{"instrumentName":"Option premium paid Weekly Gold (Feb Future) 2650 PUT ($1)"}"#;
324        let info = parse_instrument_from_json(json).unwrap();
325        assert_eq!(info.underlying, Some("GOLD".to_string()));
326        assert_eq!(info.strike, Some(2650.0));
327        assert_eq!(info.option_type, Some("PUT".to_string()));
328        assert!(info.is_option);
329    }
330
331    #[test]
332    fn test_ge40_call_option() {
333        let json =
334            r#"{"instrumentName":"End of Month Germany 40 22800 CALL COMM DIAAAAPBC65ZQAP"}"#;
335        let info = parse_instrument_from_json(json).unwrap();
336        assert_eq!(info.underlying, Some("GER40".to_string()));
337        assert_eq!(info.strike, Some(22800.0));
338        assert_eq!(info.option_type, Some("CALL".to_string()));
339        assert!(info.is_option);
340    }
341
342    #[test]
343    fn test_ge40_put_option() {
344        let json = r#"{"instrumentName":"End of Month Germany 40 22000 PUT COMM DIAAAAPAWDLTNAB"}"#;
345        let info = parse_instrument_from_json(json).unwrap();
346        assert_eq!(info.underlying, Some("GER40".to_string()));
347        assert_eq!(info.strike, Some(22000.0));
348        assert_eq!(info.option_type, Some("PUT".to_string()));
349        assert!(info.is_option);
350    }
351
352    #[test]
353    fn test_ftse_option() {
354        let json = r#"{"instrumentName":"Option premium paid FTSE 8250 CALL (£1) Tipo de cambio 1.17471"}"#;
355        let info = parse_instrument_from_json(json).unwrap();
356        assert_eq!(info.underlying, Some("UK100".to_string()));
357        assert_eq!(info.strike, Some(8250.0));
358        assert_eq!(info.option_type, Some("CALL".to_string()));
359        assert!(info.is_option);
360    }
361
362    #[test]
363    fn test_funds_transfer() {
364        let json = r#"{"instrumentName":"Funds Transfer from Barreras y Opciones"}"#;
365        let info = parse_instrument_from_json(json).unwrap();
366        assert!(!info.is_option);
367        assert_eq!(info.underlying, None);
368    }
369}