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 PARTIAL_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 statements = Parser::new()?.parse_flex_query_response(PARTIAL_STATEMENT_EXAMPLE)?;
234        assert_eq!(statements.len(), 1);
235        let result = &statements[0];
236
237        // Ensure we got two trades.
238        assert_eq!(result.trades.len(), 2);
239
240        // Ensure the first trade matches.
241        assert_eq!(
242            result.trades[0],
243            Trade {
244                account_id: "U1234567".to_string(),
245                commission: -1.000035,
246                conid: 276343981,
247                currency: Currency::USD,
248                execution_exchange: "BYX".to_string(),
249                execution_id: "0000edae.680b59d1.01.01".to_string(),
250                execution_timestamp_ms: result.trades[0].execution_timestamp_ms,
251                open_close_indicator: OpenCloseIndicator::Open,
252                order_id: "002ce642.00014b44.680b0ed6.0001".to_string(),
253                order_type: OrderType::Limit,
254                price: 606.57,
255                quantity: 1.0,
256                side: TradeSide::Buy,
257                ticker: "ARGX".to_string(),
258                listing_exchange: "NASDAQ".to_string(),
259            }
260        );
261
262        // Ensure the first trade matches.
263        assert_eq!(
264            result.trades[1],
265            Trade {
266                account_id: "U1234567".to_string(),
267                commission: -5.035,
268                conid: 158655765,
269                currency: Currency::USD,
270                execution_exchange: "NYSE".to_string(),
271                execution_id: "00012e0e.680b7717.01.01".to_string(),
272                execution_timestamp_ms: result.trades[1].execution_timestamp_ms,
273                open_close_indicator: OpenCloseIndicator::Open,
274                order_id: "002ce642.00014b44.680b0fbf.0001".to_string(),
275                order_type: OrderType::Limit,
276                price: 30.85,
277                quantity: 1000.0,
278                side: TradeSide::Buy,
279                ticker: "GEO".to_string(),
280                listing_exchange: "NYSE".to_string(),
281            }
282        );
283        Ok(())
284    }
285}