investments/broker_statement/ib/
common.rs

1use std::iter::Iterator;
2use std::str::FromStr;
3
4use csv::StringRecord;
5use cusip::CUSIP;
6use isin::ISIN;
7use lazy_static::lazy_static;
8use regex::Regex;
9
10use crate::broker_statement::ib::StatementParser;
11use crate::core::{EmptyResult, GenericResult, GenericError};
12use crate::currency::Cash;
13use crate::time;
14use crate::types::{Date, DateTime, Decimal};
15use crate::util::{self, DecimalRestrictions};
16
17pub const STOCK_SYMBOL_REGEX: &str = r"(?:[A-Z][A-Z0-9]*[a-z]*|\d+)(?:[ .][A-Z]+)??";
18pub const OLD_SYMBOL_SUFFIX: &str = ".OLD";
19
20// IB uses the following identifier types as security ID:
21// * ISIN (it seems that IB uses only this type in broker statements since 2020)
22// * CUSIP - US standard which in most cases may be converted to ISIN, but not always (see
23//   https://stackoverflow.com/questions/30545239/convert-9-digit-cusip-codes-into-isin-codes)
24// * conid (contract ID) - an internal IB's instrument UID
25#[derive(Debug)]
26pub enum SecurityID {
27    Isin(ISIN),
28    Cusip(CUSIP),
29    Conid(
30        #[allow(dead_code)]
31        u32
32    ),
33}
34
35impl SecurityID {
36    pub const REGEX: &'static str = "[A-Z0-9]+";
37}
38
39impl FromStr for SecurityID {
40    type Err = GenericError;
41
42    fn from_str(value: &str) -> Result<Self, Self::Err> {
43        Ok(if let Ok(isin) = value.parse() {
44            SecurityID::Isin(isin)
45        } else if let Ok(cusip) = value.parse() {
46            SecurityID::Cusip(cusip)
47        } else if let Ok(conid) = value.parse() {
48            SecurityID::Conid(conid)
49        } else {
50            return Err!("Unsupported security ID: {:?}", value);
51        })
52    }
53}
54
55pub struct RecordSpec<'a> {
56    pub name: &'a str,
57    fields: Vec<&'a str>,
58    offset: usize,
59}
60
61impl<'a> RecordSpec<'a> {
62    pub fn new(name: &'a str, fields: Vec<&'a str>, offset: usize) -> RecordSpec<'a> {
63        RecordSpec {name, fields, offset}
64    }
65
66    pub fn has_field(&self, field: &str) -> bool {
67        self.field_index(field).is_some()
68    }
69
70    fn field_index(&self, field: &str) -> Option<usize> {
71        self.fields.iter().position(|other: &&str| *other == field)
72    }
73}
74
75pub struct Record<'a> {
76    pub spec: &'a RecordSpec<'a>,
77    pub values: &'a StringRecord,
78}
79
80impl<'a> Record<'a> {
81    pub fn new(spec: &'a RecordSpec<'a>, values: &'a StringRecord) -> Record<'a> {
82        Record {spec, values}
83    }
84
85    pub fn get_value(&self, field: &str) -> GenericResult<&str> {
86        if let Some(index) = self.spec.field_index(field) {
87            if let Some(value) = self.values.get(self.spec.offset + index) {
88                return Ok(value);
89            }
90        }
91
92        Err!("{:?} record doesn't have {:?} field", self.spec.name, field)
93    }
94
95    pub fn check_value(&self, field: &str, value: &str) -> EmptyResult {
96        self.check_values(&[(field, value)])
97    }
98
99    pub fn check_values(&self, values: &[(&str, &str)]) -> EmptyResult {
100        for (field, expected_value) in values {
101            let value = self.get_value(field)?;
102            if value != *expected_value {
103                return Err!("Got an unexpected {:?} field value: {:?}", field, value);
104            }
105        }
106
107        Ok(())
108    }
109
110    #[allow(dead_code)]
111    pub fn parse_value<T: FromStr>(&self, field: &str) -> GenericResult<T> {
112        let value = self.get_value(field)?;
113        Ok(value.parse().map_err(|_| format!(
114            "{field:?} field has an invalid value: {value:?}"))?)
115    }
116
117    pub fn parse_date(&self, field: &str) -> GenericResult<Date> {
118        parse_date(self.get_value(field)?)
119    }
120
121    pub fn parse_date_time(&self, field: &str) -> GenericResult<DateTime> {
122        parse_date_time(self.get_value(field)?)
123    }
124
125    pub fn parse_symbol(&self, field: &str) -> GenericResult<String> {
126        parse_symbol(self.get_value(field)?)
127    }
128
129    pub fn parse_quantity(&self, field: &str, restrictions: DecimalRestrictions) -> GenericResult<Decimal> {
130        let quantity = parse_quantity(self.get_value(field)?)?;
131        util::validate_named_decimal("quantity", quantity, restrictions)
132    }
133
134    pub fn parse_amount(&self, field: &str, restrictions: DecimalRestrictions) -> GenericResult<Decimal> {
135        let value = self.get_value(field)?;
136        let amount = parse_quantity(value).map_err(|_| format!("Invalid amount: {value:?}"))?;
137        util::validate_named_decimal("amount", amount, restrictions)
138    }
139
140    pub fn parse_cash(&self, field: &str, currency: &str, restrictions: DecimalRestrictions) -> GenericResult<Cash> {
141        Ok(Cash::new(currency, self.parse_amount(field, restrictions)?))
142    }
143}
144
145pub trait RecordParser {
146    fn data_types(&self) -> Option<&'static [&'static str]> { Some(&["Data"]) }
147    fn skip_data_types(&self) -> Option<&'static [&'static str]> { None }
148    fn skip_totals(&self) -> bool { false }
149    fn allow_multiple(&self) -> bool { false }
150    fn parse(&mut self, parser: &mut StatementParser, record: &Record) -> EmptyResult;
151}
152
153pub struct UnknownRecordParser {}
154
155impl RecordParser for UnknownRecordParser {
156    fn data_types(&self) -> Option<&'static [&'static str]> {
157        None
158    }
159
160    fn allow_multiple(&self) -> bool {
161        true
162    }
163
164    fn parse(&mut self, _parser: &mut StatementParser, _record: &Record) -> EmptyResult {
165        Ok(())
166    }
167}
168
169pub fn check_volume(quantity: Decimal, price: Cash, volume: Cash) -> EmptyResult {
170    if !cfg!(debug_assertions) {
171        return Ok(());
172    }
173
174    let expected_volume = price * quantity;
175
176    for precision in 2..=8 {
177        if expected_volume.round_to(precision) == volume {
178            return Ok(());
179        }
180    }
181
182    Err!("Got an unexpected volume: {} vs {}", volume, expected_volume)
183}
184
185pub fn is_header_field(value: &str) -> bool {
186    matches!(value, "Header" | "Headers") // https://github.com/KonishchevDmitry/investments/issues/81
187}
188
189pub fn format_record<'a, I>(iter: I) -> String
190    where I: IntoIterator<Item = &'a str> {
191
192    iter.into_iter()
193        .map(|value| format!("{value:?}"))
194        .collect::<Vec<_>>()
195        .join(", ")
196}
197
198pub fn format_error_record(record: &StringRecord) -> String {
199    let mut human = format!("({})", format_record(record));
200
201    if let Some(position) = record.position() {
202        human = format!("{} ({} line)", human, position.line());
203    }
204
205    human
206}
207
208pub fn parse_date(date: &str) -> GenericResult<Date> {
209    time::parse_date(date, "%Y-%m-%d")
210}
211
212pub fn parse_date_time(date_time: &str) -> GenericResult<DateTime> {
213    time::parse_date_time(date_time, "%Y-%m-%d, %H:%M:%S").or_else(|_|
214        time::parse_date_time(date_time, "%Y-%m-%d %H:%M:%S"))
215}
216
217pub fn parse_symbol(symbol: &str) -> GenericResult<String> {
218    lazy_static! {
219        static ref SYMBOL_REGEX: Regex = Regex::new(&format!(
220            r"^{STOCK_SYMBOL_REGEX}$")).unwrap();
221    }
222
223    if !SYMBOL_REGEX.is_match(symbol) || symbol.ends_with(OLD_SYMBOL_SUFFIX) {
224        return Err!("Got a stock symbol with an unsupported format: {:?}", symbol);
225    }
226
227    Ok(symbol.replace(' ', "-").to_uppercase())
228}
229
230fn parse_quantity(quantity: &str) -> GenericResult<Decimal> {
231    // See https://github.com/KonishchevDmitry/investments/issues/34
232
233    lazy_static! {
234        static ref DECIMAL_SEPARATOR_REGEX: Regex = Regex::new(
235            r"([1-9]0*),(\d{3})(,|\.|$)").unwrap();
236    }
237    let mut stripped = quantity.to_owned();
238
239    while stripped.contains(',') {
240        let new = DECIMAL_SEPARATOR_REGEX.replace_all(&stripped, "$1$2$3");
241        if new == stripped {
242            return Err!("Invalid quantity: {:?}", quantity)
243        }
244        stripped = new.into_owned();
245    }
246
247    Ok(Decimal::from_str(&stripped).map_err(|_| format!("Invalid quantity: {quantity:?}"))?)
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    use matches::assert_matches;
255    use rstest::rstest;
256
257    #[test]
258    fn date_parsing() {
259        assert_eq!(parse_date("2018-06-22").unwrap(), date!(2018, 6, 22));
260    }
261
262    #[rstest(value,
263        case("2018-07-31 13:09:47"),
264        case("2018-07-31, 13:09:47"),
265    )]
266    fn time_parsing(value: &str) {
267        assert_eq!(parse_date_time(value).unwrap(), date_time!(2018, 7, 31, 13, 9, 47));
268    }
269
270    #[rstest(value, expected,
271        case("1020", Some(dec!(1020))),
272        case("1,020", Some(dec!(1020))),
273        case("1,020,304.05", Some(dec!(1_020_304.05))),
274        case("-1,020,304.05", Some(dec!(-1_020_304.05))),
275        case("1,000,000.05", Some(dec!(1_000_000.05))),
276        case("-1,000,000.05", Some(dec!(-1_000_000.05))),
277
278        case(",102", None),
279        case("102,", None),
280        case("0,102", None),
281        case("10,20", None),
282        case("10,20.3", None),
283        case("1,0203", None),
284    )]
285    fn quantity_parsing(value: &str, expected: Option<Decimal>) {
286        if let Some(expected) = expected {
287            assert_eq!(parse_quantity(value).unwrap(), expected);
288        } else {
289            assert_eq!(
290                parse_quantity(value).unwrap_err().to_string(),
291                format!("Invalid quantity: {value:?}"),
292            );
293        }
294    }
295
296    #[test]
297    fn security_id_parsing() {
298        let parse = |s| SecurityID::from_str(s).unwrap();
299
300        // BND
301        assert_matches!(parse("US9219378356"), SecurityID::Isin(_));
302        assert_matches!(parse("921937835"), SecurityID::Cusip(_));
303        assert_matches!(parse("43645828"), SecurityID::Conid(conid) if conid == 43645828);
304    }
305
306    #[rstest(value, expected,
307        case("T",       "T"),
308        case("VTI",     "VTI"),
309        case("TKAd",    "TKAD"),    // https://github.com/KonishchevDmitry/investments/issues/64
310        case("1086",    "1086"),    // https://github.com/KonishchevDmitry/investments/issues/64
311        case("U.UN",    "U.UN"),    // https://github.com/KonishchevDmitry/investments/issues/62
312        case("RDS B",   "RDS-B"),   // https://github.com/KonishchevDmitry/investments/issues/28
313        case("CBL PRD", "CBL-PRD"), // https://github.com/KonishchevDmitry/investments/issues/42
314    )]
315    fn symbol_parsing(value: &str, expected: &str) {
316        assert_eq!(parse_symbol(value).unwrap(), expected);
317    }
318}