ibkr_flex_statement/
trade.rs

1use crate::{node_utils::NodeWrapper, statement_section::StatementSectionWithTimezone};
2
3use super::currency::Currency;
4use anyhow::Result;
5use chrono::{NaiveDateTime, TimeZone};
6use chrono_tz::Tz;
7use std::collections::HashMap;
8
9#[derive(Debug, PartialEq)]
10pub enum TradeSide {
11    Buy,
12    Sell,
13}
14
15#[derive(Debug, PartialEq)]
16pub enum OpenCloseIndicator {
17    Close,
18    CloseOpen,
19    Open,
20}
21
22#[derive(Debug, PartialEq)]
23pub enum OrderType {
24    Limit,
25}
26
27#[derive(Debug, PartialEq)]
28pub struct Trade {
29    pub account_id: String,
30    pub conid: u32,
31    pub currency: Currency,
32    pub execution_exchange: String,
33    pub execution_id: String,
34    pub execution_timestamp_ms: i64,
35    pub commission: f64,
36    pub listing_exchange: String,
37    pub open_close_indicator: OpenCloseIndicator,
38    pub order_id: String,
39    pub order_type: OrderType,
40    pub price: f64,
41    pub quantity: f64,
42    pub side: TradeSide,
43    pub ticker: String,
44}
45
46impl<'a> TryFrom<&'a str> for OpenCloseIndicator {
47    type Error = anyhow::Error;
48    fn try_from(s: &'a str) -> Result<Self> {
49        match s {
50            "C" => Ok(Self::Close),
51            "C;O" => Ok(Self::CloseOpen),
52            "O" => Ok(OpenCloseIndicator::Open),
53            _ => Err(anyhow::Error::msg(format!(
54                "unknown openClose indicator {}",
55                s
56            ))),
57        }
58    }
59}
60
61impl<'a> TryFrom<&'a str> for OrderType {
62    type Error = anyhow::Error;
63    fn try_from(s: &'a str) -> Result<Self> {
64        match s {
65            "LMT" => Ok(Self::Limit),
66            _ => Err(anyhow::Error::msg(format!("unknown order type {}", s))),
67        }
68    }
69}
70
71impl<'a> TryFrom<&'a str> for TradeSide {
72    type Error = anyhow::Error;
73
74    fn try_from(s: &'a str) -> Result<Self> {
75        match s {
76            "BUY" => Ok(Self::Buy),
77            "SELL" => Ok(Self::Sell),
78            _ => Err(anyhow::Error::msg(format!("unknown trade side {}", s))),
79        }
80    }
81}
82
83fn try_parse_trade_execution_time_ms(tz_map: &HashMap<String, Tz>, s: &str) -> Result<i64> {
84    let mut dt_parts = s.split(" ");
85    let datetime_str = dt_parts.next().unwrap();
86
87    let short_timezone = dt_parts.next().unwrap();
88    let timezone = tz_map.get(short_timezone).unwrap();
89
90    let naive_dt = NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%d;%H:%M:%S %Z")?;
91    let tz_aware_dt = timezone.from_local_datetime(&naive_dt).unwrap();
92
93    // println!("tz_aware_dt: {:?}, timestamp: {}", tz_aware_dt, tz_aware_dt.timestamp());
94
95    Ok(tz_aware_dt.timestamp() * 1000)
96}
97
98impl StatementSectionWithTimezone for Trade {
99    fn from_node(node: &NodeWrapper, tz_map: &HashMap<String, Tz>) -> Result<Trade> {
100        Ok(Trade {
101            account_id: node.get_attribute("accountId")?,
102            commission: node.parse_attribute("ibCommission")?,
103            conid: node.parse_attribute("conid")?,
104            currency: Currency::try_from(node.node.attribute("currency").unwrap())?,
105            execution_exchange: node.get_attribute("exchange")?,
106            execution_id: node.get_attribute("ibExecID")?,
107            execution_timestamp_ms: try_parse_trade_execution_time_ms(
108                tz_map,
109                node.node.attribute("dateTime").unwrap(),
110            )?,
111            listing_exchange: node.get_attribute("listingExchange")?,
112            open_close_indicator: OpenCloseIndicator::try_from(
113                node.node.attribute("openCloseIndicator").unwrap(),
114            )?,
115            order_id: node.get_attribute("brokerageOrderID")?,
116            order_type: OrderType::try_from(node.node.attribute("orderType").unwrap())?,
117            price: node.parse_attribute("tradePrice")?,
118            quantity: node.parse_attribute("quantity")?,
119            side: TradeSide::try_from(node.node.attribute("buySell").unwrap())?,
120            ticker: node.get_attribute("symbol")?,
121        })
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::Parser;
129    use anyhow::Result;
130
131    const FULL_STATEMENT_EXAMPLE: &str = r##"
132        <FlexQueryResponse queryName="example-query" type="AF">
133            <FlexStatements count="1">
134                <FlexStatement accountId="U1234567" fromDate="2025-04-25" toDate="2025-04-25" period="LastBusinessDay" whenGenerated="2025-04-26;13:34:28 EDT">
135                    <AccountInformation accountId="U1234567" accountType="Individual" customerType="Individual" accountCapabilities="Portfolio Margin" tradingPermissions="Stocks,Options,Warrants,Forex,Futures,Crypto Currencies,Mutual Funds,Fully Paid Stock Loan" />
136                    <Trades>
137                        <Trade accountId="U1234567" 
138                               currency="USD"
139                               symbol="ARGX"
140                               conid="276343981"
141                               listingExchange="NASDAQ"
142                               tradeID="7587063231"
143                               reportDate="2025-04-25"
144                               dateTime="2025-04-25;10:19:55 EDT"
145                               tradeDate="2025-04-25"
146                               transactionType="ExchTrade"
147                               exchange="BYX"
148                               quantity="1"
149                               tradePrice="606.57"
150                               tradeMoney="606.57"
151                               proceeds="-606.57"
152                               ibCommission="-1.000035"
153                               ibCommissionCurrency="USD"
154                               netCash="-607.570035"
155                               closePrice="614.76"
156                               openCloseIndicator="O"
157                               cost="607.570035"
158                               fifoPnlRealized="0"
159                               mtmPnl="8.19"
160                               origTradePrice="0"
161                               origTradeDate=""
162                               origTradeID=""
163                               origOrderID="0"
164                               origTransactionID="0"
165                               buySell="BUY"
166                               ibOrderID="4015030800"
167                               transactionID="32580112485"
168                               ibExecID="0000edae.680b59d1.01.01"
169                               orderTime="2025-04-25;10:19:55 EDT"
170                               openDateTime=""
171                               holdingPeriodDateTime=""
172                               whenRealized=""
173                               whenReopened=""
174                               orderType="LMT"
175                               accruedInt="0"
176                               assetCategory="STK"
177                               brokerageOrderID="002ce642.00014b44.680b0ed6.0001"
178                               orderReference=""
179                               isAPIOrder="N"
180                               initialInvestment="" />
181                        <Trade accountId="U1234567" 
182                               currency="USD"
183                               symbol="GEO"
184                               conid="158655765"
185                               listingExchange="NYSE"
186                               tradeID="7587946875"
187                               reportDate="2025-04-25"
188                               dateTime="2025-04-25;11:24:28 EDT"
189                               tradeDate="2025-04-25"
190                               transactionType="ExchTrade"
191                               exchange="NYSE"
192                               quantity="1000"
193                               tradePrice="30.85"
194                               tradeMoney="30850"
195                               proceeds="-30850"
196                               ibCommission="-5.035"
197                               ibCommissionCurrency="USD"
198                               netCash="-30855.035"
199                               closePrice="30.58"
200                               openCloseIndicator="O"
201                               cost="30855.035"
202                               fifoPnlRealized="0"
203                               mtmPnl="-270"
204                               origTradePrice="0"
205                               origTradeDate=""
206                               origTradeID=""
207                               origOrderID="0"
208                               origTransactionID="0"
209                               buySell="BUY"
210                               ibOrderID="4015577648"
211                               transactionID="32582764875"
212                               ibExecID="00012e0e.680b7717.01.01"
213                               orderTime="2025-04-25;11:24:26 EDT"
214                               openDateTime=""
215                               holdingPeriodDateTime=""
216                               whenRealized=""
217                               whenReopened=""
218                               orderType="LMT"
219                               accruedInt="0"
220                               assetCategory="STK"
221                               brokerageOrderID="002ce642.00014b44.680b0fbf.0001"
222                               orderReference=""
223                               isAPIOrder="N"
224                               initialInvestment="" />
225                    </Trades>
226                </FlexStatement>
227            </FlexStatements>
228         </FlexQueryResponse>
229        "##;
230
231    #[test]
232    fn trades_parse() -> Result<()> {
233        let result = Parser::new()?.parse_statement_contents(FULL_STATEMENT_EXAMPLE)?;
234
235        // Ensure we got two trades.
236        assert_eq!(result.trades.len(), 2);
237
238        // Ensure the first trade matches.
239        assert_eq!(
240            result.trades[0],
241            Trade {
242                account_id: "U1234567".to_string(),
243                commission: -1.000035,
244                conid: 276343981,
245                currency: Currency::USD,
246                execution_exchange: "BYX".to_string(),
247                execution_id: "0000edae.680b59d1.01.01".to_string(),
248                execution_timestamp_ms: result.trades[0].execution_timestamp_ms,
249                open_close_indicator: OpenCloseIndicator::Open,
250                order_id: "002ce642.00014b44.680b0ed6.0001".to_string(),
251                order_type: OrderType::Limit,
252                price: 606.57,
253                quantity: 1.0,
254                side: TradeSide::Buy,
255                ticker: "ARGX".to_string(),
256                listing_exchange: "NASDAQ".to_string(),
257            }
258        );
259
260        // Ensure the first trade matches.
261        assert_eq!(
262            result.trades[1],
263            Trade {
264                account_id: "U1234567".to_string(),
265                commission: -5.035,
266                conid: 158655765,
267                currency: Currency::USD,
268                execution_exchange: "NYSE".to_string(),
269                execution_id: "00012e0e.680b7717.01.01".to_string(),
270                execution_timestamp_ms: result.trades[1].execution_timestamp_ms,
271                open_close_indicator: OpenCloseIndicator::Open,
272                order_id: "002ce642.00014b44.680b0fbf.0001".to_string(),
273                order_type: OrderType::Limit,
274                price: 30.85,
275                quantity: 1000.0,
276                side: TradeSide::Buy,
277                ticker: "GEO".to_string(),
278                listing_exchange: "NYSE".to_string(),
279            }
280        );
281        Ok(())
282    }
283}