ig-client 0.16.2

This crate provides a client for the IG Markets API
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
use crate::presentation::order::Status;
use pretty_simple_display::{DebugPretty, DisplaySimple};
use regex::Regex;
use serde::{Deserialize, Deserializer, Serialize};
use tracing::warn;

/// Structure to represent the parsed option information from an instrument name
#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, PartialEq)]
pub struct ParsedOptionInfo {
    /// Name of the underlying asset (e.g., "US Tech 100")
    pub asset_name: String,
    /// Strike price of the option (e.g., "19200")
    pub strike: Option<String>,
    /// Type of the option: CALL or PUT
    pub option_type: Option<String>,
}

/// Structure to represent the parsed market data with additional information
#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize)]
pub struct ParsedMarketData {
    /// Unique identifier for the market (EPIC code)
    pub epic: String,
    /// Full name of the financial instrument
    pub instrument_name: String,
    /// Expiry date of the instrument (if applicable)
    pub expiry: String,
    /// Name of the underlying asset
    pub asset_name: String,
    /// Strike price for options
    pub strike: Option<String>,
    /// Type of option (e.g., 'CALL' or 'PUT')
    pub option_type: Option<String>,
}

impl ParsedMarketData {
    /// Checks if the current financial instrument is a call option.
    ///
    /// A call option is a financial derivative that gives the holder the right (but not the obligation)
    /// to buy an underlying asset at a specified price within a specified time period. This method checks
    /// whether the instrument is a call option by inspecting the already-parsed `option_type` field.
    ///
    /// # Returns
    ///
    /// * `true` if the parsed `option_type` is exactly `"CALL"`, indicating it is a call option.
    /// * `false` otherwise.
    ///
    #[must_use]
    #[inline]
    pub fn is_call(&self) -> bool {
        self.option_type.as_deref() == Some("CALL")
    }

    /// Checks if the financial instrument is a "PUT" option.
    ///
    /// This method inspects the already-parsed `option_type` field rather than
    /// re-scanning the instrument name, so it stays in sync with the parser.
    ///
    /// # Returns
    /// * `true` - If the parsed `option_type` is exactly `"PUT"`.
    /// * `false` - Otherwise.
    ///
    #[must_use]
    #[inline]
    pub fn is_put(&self) -> bool {
        self.option_type.as_deref() == Some("PUT")
    }
}

/// Normalize text by removing accents and standardizing names
///
/// This function converts accented characters to their non-accented equivalents
/// and standardizes certain names (e.g., "Japan" in different languages)
pub fn normalize_text(text: &str) -> String {
    // Special case for Japan in Spanish
    if text.contains("Japón") {
        return text.replace("Japón", "Japan");
    }

    let mut result = String::with_capacity(text.len());
    for c in text.chars() {
        match c {
            'á' | 'à' | 'ä' | 'â' | 'ã' => result.push('a'),
            'é' | 'è' | 'ë' | 'ê' => result.push('e'),
            'í' | 'ì' | 'ï' | 'î' => result.push('i'),
            'ó' | 'ò' | 'ö' | 'ô' | 'õ' => result.push('o'),
            'ú' | 'ù' | 'ü' | 'û' => result.push('u'),
            'ñ' => result.push('n'),
            'ç' => result.push('c'),
            'Á' | 'À' | 'Ä' | 'Â' | 'Ã' => result.push('A'),
            'É' | 'È' | 'Ë' | 'Ê' => result.push('E'),
            'Í' | 'Ì' | 'Ï' | 'Î' => result.push('I'),
            'Ó' | 'Ò' | 'Ö' | 'Ô' | 'Õ' => result.push('O'),
            'Ú' | 'Ù' | 'Ü' | 'Û' => result.push('U'),
            'Ñ' => result.push('N'),
            'Ç' => result.push('C'),
            _ => result.push(c),
        }
    }
    result
}

/// Parse the instrument name to extract asset name, strike price, and option type
///
/// # Examples
///
/// ```
/// use ig_client::utils::parsing::parse_instrument_name;
///
/// let info = parse_instrument_name("US Tech 100 19200 CALL ($1)");
/// assert_eq!(info.asset_name, "US Tech 100");
/// assert_eq!(info.strike, Some("19200".to_string()));
/// assert_eq!(info.option_type, Some("CALL".to_string()));
///
/// let info = parse_instrument_name("Germany 40");
/// assert_eq!(info.asset_name, "Germany 40");
/// assert_eq!(info.strike, None);
/// assert_eq!(info.option_type, None);
/// ```
pub fn parse_instrument_name(instrument_name: &str) -> ParsedOptionInfo {
    // Create regex patterns for different instrument name formats
    // Lazy initialization of regex patterns
    lazy_static::lazy_static! {
        // Pattern for standard options like "US Tech 100 19200 CALL ($1)".
        // The strike group accepts an optional decimal part, so this pattern
        // already covers decimal strikes like "Volatility Index 10.5 PUT ($1)".
        static ref OPTION_PATTERN: Regex = Regex::new(r"^(.*?)\s+(\d+(?:\.\d+)?)\s+((?i:CALL|PUT))(?:\s+\(.*?\))?$").expect("valid option regex");

        // Pattern for options with no space between parenthesis and strike like "Weekly Germany 40 (Wed)27500 PUT"
        static ref SPECIAL_OPTION_PATTERN: Regex = Regex::new(r"^(.*?)\s+\(([^)]+)\)(\d+)\s+((?i:CALL|PUT))(?:\s+\(.*?\))?$").expect("valid special option regex");

        // Pattern for options with incomplete parenthesis like "Weekly USDJPY 12950 CALL (Y100"
        static ref INCOMPLETE_PAREN_PATTERN: Regex = Regex::new(r"^(.*?)\s+(\d+(?:\.\d+)?)\s+((?i:CALL|PUT))\s+\([^)]*$").expect("valid incomplete paren regex");

        // Pattern for other instruments that don't follow the option pattern
        static ref GENERIC_PATTERN: Regex = Regex::new(r"^(.*?)(?:\s+\(.*?\))?$").expect("valid generic regex");

        // Pattern to clean up asset names
        static ref DAILY_WEEKLY_PATTERN: Regex = Regex::new(r"^(Daily|Weekly)\s+(.*?)$").expect("valid daily/weekly regex");
        static ref END_OF_MONTH_PATTERN: Regex = Regex::new(r"^(End of Month)\s+(.*?)$").expect("valid end-of-month regex");
        static ref QUARTERLY_PATTERN: Regex = Regex::new(r"^(Quarterly)\s+(.*?)$").expect("valid quarterly regex");
        static ref MONTHLY_PATTERN: Regex = Regex::new(r"^(Monthly)\s+(.*?)$").expect("valid monthly regex");
        static ref SUFFIX_PATTERN: Regex = Regex::new(r"^(.*?)\s+\(.*?\)$").expect("valid suffix regex");
    }

    // Helper function to clean up asset names
    fn clean_asset_name(asset_name: &str) -> String {
        // First normalize the text to remove accents
        let normalized_name = normalize_text(asset_name);

        // Remove prefixes like "Daily", "Weekly", etc.
        let asset_name = if let Some(captures) = DAILY_WEEKLY_PATTERN.captures(&normalized_name) {
            captures.get(2).map_or("", |m| m.as_str()).trim()
        } else if let Some(captures) = END_OF_MONTH_PATTERN.captures(&normalized_name) {
            captures.get(2).map_or("", |m| m.as_str()).trim()
        } else if let Some(captures) = QUARTERLY_PATTERN.captures(&normalized_name) {
            captures.get(2).map_or("", |m| m.as_str()).trim()
        } else if let Some(captures) = MONTHLY_PATTERN.captures(&normalized_name) {
            captures.get(2).map_or("", |m| m.as_str()).trim()
        } else {
            &normalized_name
        };

        // Remove suffixes like "(End of Month)", etc.
        let asset_name = if let Some(captures) = SUFFIX_PATTERN.captures(asset_name) {
            captures.get(1).map_or("", |m| m.as_str()).trim()
        } else {
            asset_name
        };

        asset_name.to_string()
    }

    if let Some(captures) = OPTION_PATTERN.captures(instrument_name) {
        // This is an option with strike and type
        let asset_name = captures.get(1).map_or("", |m| m.as_str()).trim();
        ParsedOptionInfo {
            asset_name: clean_asset_name(asset_name),
            strike: captures.get(2).map(|m| m.as_str().to_string()),
            option_type: captures.get(3).map(|m| m.as_str().to_uppercase()),
        }
    } else if let Some(captures) = SPECIAL_OPTION_PATTERN.captures(instrument_name) {
        // This is a special case like "Weekly Germany 40 (Wed)27500 PUT"
        let base_name = captures.get(1).map_or("", |m| m.as_str()).trim();
        ParsedOptionInfo {
            asset_name: clean_asset_name(base_name),
            strike: captures.get(3).map(|m| m.as_str().to_string()),
            option_type: captures.get(4).map(|m| m.as_str().to_uppercase()),
        }
    } else if let Some(captures) = INCOMPLETE_PAREN_PATTERN.captures(instrument_name) {
        // This is a case with incomplete parenthesis like "Weekly USDJPY 12950 CALL (Y100"
        let asset_name = captures.get(1).map_or("", |m| m.as_str()).trim();
        ParsedOptionInfo {
            asset_name: clean_asset_name(asset_name),
            strike: captures.get(2).map(|m| m.as_str().to_string()),
            option_type: captures.get(3).map(|m| m.as_str().to_uppercase()),
        }
    } else if let Some(captures) = GENERIC_PATTERN.captures(instrument_name) {
        // This is a generic instrument without strike or type
        let asset_name = captures.get(1).map_or("", |m| m.as_str()).trim();
        ParsedOptionInfo {
            asset_name: clean_asset_name(asset_name),
            strike: None,
            option_type: None,
        }
    } else {
        // Fallback for any other format
        warn!("Could not parse instrument name: {}", instrument_name);
        ParsedOptionInfo {
            asset_name: instrument_name.to_string(),
            strike: None,
            option_type: None,
        }
    }
}

/// Helper function to deserialize null values as empty vectors
pub fn deserialize_null_as_empty_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
    D: serde::Deserializer<'de>,
    T: serde::Deserialize<'de>,
{
    let opt = Option::deserialize(deserializer)?;
    Ok(opt.unwrap_or_default())
}

/// Helper function to deserialize a nullable status field
/// When the status is null in the JSON, we default to Open status
pub fn deserialize_nullable_status<'de, D>(deserializer: D) -> Result<Status, D::Error>
where
    D: Deserializer<'de>,
{
    let opt = Option::deserialize(deserializer)?;
    Ok(opt.unwrap_or(Status::Open))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_instrument_name_standard_option() {
        let info = parse_instrument_name("US Tech 100 19200 CALL ($1)");
        assert_eq!(info.asset_name, "US Tech 100");
        assert_eq!(info.strike, Some("19200".to_string()));
        assert_eq!(info.option_type, Some("CALL".to_string()));
    }

    #[test]
    fn test_parse_instrument_name_mixed_case_option_type() {
        // IG returns some option instrument names with the option type in
        // title case (e.g. Wall Street monthly options); the parser must be
        // case-insensitive and normalize the type to uppercase.
        let info = parse_instrument_name("Wall Street 50000 Call ($1)");
        assert_eq!(info.asset_name, "Wall Street");
        assert_eq!(info.strike, Some("50000".to_string()));
        assert_eq!(info.option_type, Some("CALL".to_string()));

        let info = parse_instrument_name("Wall Street 50000 Put ($1)");
        assert_eq!(info.option_type, Some("PUT".to_string()));
    }

    #[test]
    fn test_parse_instrument_name_decimal_strike() {
        // Proves `OPTION_PATTERN` alone handles decimal strikes; there is no
        // separate decimal-strike pattern.
        let info = parse_instrument_name("Volatility Index 10.5 PUT ($1)");
        assert_eq!(info.asset_name, "Volatility Index");
        assert_eq!(info.strike, Some("10.5".to_string()));
        assert_eq!(info.option_type, Some("PUT".to_string()));
    }

    #[test]
    fn test_parse_instrument_name_decimal_strike_no_suffix() {
        // A decimal strike without a trailing "($1)" suffix is still handled by
        // `OPTION_PATTERN`.
        let info = parse_instrument_name("Volatility Index 10.5 CALL");
        assert_eq!(info.asset_name, "Volatility Index");
        assert_eq!(info.strike, Some("10.5".to_string()));
        assert_eq!(info.option_type, Some("CALL".to_string()));
    }

    #[test]
    fn test_parse_instrument_name_no_option() {
        let info = parse_instrument_name("Germany 40");
        assert_eq!(info.asset_name, "Germany 40");
        assert_eq!(info.strike, None);
        assert_eq!(info.option_type, None);
    }

    fn market_with_option_type(option_type: Option<&str>) -> ParsedMarketData {
        ParsedMarketData {
            epic: "OP.D.OTCSPX3.6910C.IP".to_string(),
            instrument_name: "US 500 6910 CALL ($1)".to_string(),
            expiry: "DEC-25".to_string(),
            asset_name: "US 500".to_string(),
            strike: Some("6910".to_string()),
            option_type: option_type.map(str::to_string),
        }
    }

    #[test]
    fn test_parsed_market_data_is_call_uses_parsed_option_type() {
        let call = market_with_option_type(Some("CALL"));
        assert!(call.is_call());
        assert!(!call.is_put());
    }

    #[test]
    fn test_parsed_market_data_is_put_uses_parsed_option_type() {
        let put = market_with_option_type(Some("PUT"));
        assert!(put.is_put());
        assert!(!put.is_call());
    }

    #[test]
    fn test_parsed_market_data_no_option_type_is_neither() {
        // A non-option instrument has `option_type == None`, so neither
        // predicate fires even though the name might contain other tokens.
        let none = market_with_option_type(None);
        assert!(!none.is_call());
        assert!(!none.is_put());
    }

    #[test]
    fn test_parse_instrument_name_with_parenthesis() {
        let info = parse_instrument_name("US 500 (Mini)");
        assert_eq!(info.asset_name, "US 500");
        assert_eq!(info.strike, None);
        assert_eq!(info.option_type, None);
    }

    #[test]
    fn test_parse_instrument_name_special_format() {
        let info = parse_instrument_name("Weekly Germany 40 (Wed)27500 PUT");
        assert_eq!(info.asset_name, "Germany 40");
        assert_eq!(info.strike, Some("27500".to_string()));
        assert_eq!(info.option_type, Some("PUT".to_string()));
    }

    #[test]
    fn test_parse_instrument_name_daily_prefix() {
        let info = parse_instrument_name("Daily Germany 40 24225 CALL");
        assert_eq!(info.asset_name, "Germany 40");
        assert_eq!(info.strike, Some("24225".to_string()));
        assert_eq!(info.option_type, Some("CALL".to_string()));
    }

    #[test]
    fn test_parse_instrument_name_weekly_prefix() {
        let info = parse_instrument_name("Weekly US Tech 100 19200 CALL");
        assert_eq!(info.asset_name, "US Tech 100");
        assert_eq!(info.strike, Some("19200".to_string()));
        assert_eq!(info.option_type, Some("CALL".to_string()));
    }

    #[test]
    fn test_parse_instrument_name_end_of_month_prefix() {
        let info = parse_instrument_name("End of Month EU Stocks 50 4575 PUT");
        assert_eq!(info.asset_name, "EU Stocks 50");
        assert_eq!(info.strike, Some("4575".to_string()));
        assert_eq!(info.option_type, Some("PUT".to_string()));
    }

    #[test]
    fn test_parse_instrument_name_end_of_month_suffix() {
        let info = parse_instrument_name("US 500 (End of Month) 3200 PUT");
        assert_eq!(info.asset_name, "US 500");
        assert_eq!(info.strike, Some("3200".to_string()));
        assert_eq!(info.option_type, Some("PUT".to_string()));
    }

    #[test]
    fn test_parse_instrument_name_quarterly_prefix() {
        let info = parse_instrument_name("Quarterly GBPUSD 10000 PUT ($1)");
        assert_eq!(info.asset_name, "GBPUSD");
        assert_eq!(info.strike, Some("10000".to_string()));
        assert_eq!(info.option_type, Some("PUT".to_string()));
    }

    #[test]
    fn test_parse_instrument_name_weekly_with_day() {
        let info = parse_instrument_name("Weekly Germany 40 (Mon) 18500 PUT");
        assert_eq!(info.asset_name, "Germany 40");
        assert_eq!(info.strike, Some("18500".to_string()));
        assert_eq!(info.option_type, Some("PUT".to_string()));
    }

    #[test]
    fn test_parse_instrument_name_incomplete_parenthesis() {
        let info = parse_instrument_name("Weekly USDJPY 12950 CALL (Y100");
        assert_eq!(info.asset_name, "USDJPY");
        assert_eq!(info.strike, Some("12950".to_string()));
        assert_eq!(info.option_type, Some("CALL".to_string()));
    }

    #[test]
    fn test_parse_instrument_name_with_accents() {
        let info = parse_instrument_name("Japón 225 18500 CALL");
        assert_eq!(info.asset_name, "Japan 225");
        assert_eq!(info.strike, Some("18500".to_string()));
        assert_eq!(info.option_type, Some("CALL".to_string()));
    }
}