investments/broker_statement/ib/
common.rs1use 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#[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") }
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 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 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"), case("1086", "1086"), case("U.UN", "U.UN"), case("RDS B", "RDS-B"), case("CBL PRD", "CBL-PRD"), )]
315 fn symbol_parsing(value: &str, expected: &str) {
316 assert_eq!(parse_symbol(value).unwrap(), expected);
317 }
318}