Skip to main content

rustrade_execution/client/ibkr/
account.rs

1use crate::balance::{AssetBalance, Balance};
2use chrono::Utc;
3use fnv::FnvHashMap;
4use rust_decimal::Decimal;
5use rustrade_instrument::asset::name::AssetNameExchange;
6use smol_str::SmolStr;
7use std::str::FromStr;
8use tracing::warn;
9
10/// Aggregated balance data per currency from AccountSummary events.
11#[derive(Debug, Default)]
12pub struct BalanceAggregator {
13    // SmolStr avoids heap allocation for short currency codes (USD, EUR, etc.)
14    balances: FnvHashMap<SmolStr, CurrencyBalance>,
15}
16
17#[derive(Debug, Default, Clone)]
18struct CurrencyBalance {
19    total_cash: Option<Decimal>,
20    available_funds: Option<Decimal>,
21}
22
23impl BalanceAggregator {
24    pub fn new() -> Self {
25        Self::default()
26    }
27
28    /// Process an AccountSummary event.
29    pub fn process(&mut self, summary: &ibapi::accounts::AccountSummary) {
30        let currency = SmolStr::new(&summary.currency);
31        let entry = self.balances.entry(currency).or_default();
32
33        match summary.tag.as_str() {
34            "TotalCashValue" => {
35                // Parse directly to Decimal to preserve precision
36                match Decimal::from_str(&summary.value) {
37                    Ok(val) => entry.total_cash = Some(val),
38                    Err(e) => {
39                        warn!(tag = %summary.tag, value = %summary.value, error = %e, "Failed to parse balance")
40                    }
41                }
42            }
43            "AvailableFunds" => match Decimal::from_str(&summary.value) {
44                Ok(val) => entry.available_funds = Some(val),
45                Err(e) => {
46                    warn!(tag = %summary.tag, value = %summary.value, error = %e, "Failed to parse balance")
47                }
48            },
49            _ => {}
50        }
51    }
52
53    /// Convert aggregated data to rustrade AssetBalance list.
54    pub fn to_balances(&self) -> Vec<AssetBalance<AssetNameExchange>> {
55        let now = Utc::now();
56        self.balances
57            .iter()
58            .filter_map(|(currency, bal)| {
59                let total = bal.total_cash?;
60                let free = bal.available_funds.unwrap_or(total);
61
62                Some(AssetBalance {
63                    asset: AssetNameExchange::from(currency.as_str()),
64                    balance: Balance::new(total, free),
65                    time_exchange: now,
66                })
67            })
68            .collect()
69    }
70
71    /// Clear all aggregated data.
72    pub fn clear(&mut self) {
73        self.balances.clear();
74    }
75}
76
77#[cfg(test)]
78#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
79mod tests {
80    use super::*;
81    use ibapi::accounts::AccountSummary;
82    use std::str::FromStr;
83
84    fn mock_account_summary(tag: &str, value: &str, currency: &str) -> AccountSummary {
85        AccountSummary {
86            account: "DU123456".to_string(),
87            tag: tag.to_string(),
88            value: value.to_string(),
89            currency: currency.to_string(),
90        }
91    }
92
93    #[test]
94    fn test_balance_aggregator() {
95        let mut agg = BalanceAggregator::new();
96
97        agg.process(&mock_account_summary("TotalCashValue", "10000.50", "USD"));
98        agg.process(&mock_account_summary("AvailableFunds", "8000.25", "USD"));
99        agg.process(&mock_account_summary("TotalCashValue", "5000.00", "EUR"));
100
101        let balances = agg.to_balances();
102        assert_eq!(balances.len(), 2);
103
104        let usd = balances.iter().find(|b| b.asset.as_ref() == "USD").unwrap();
105        assert_eq!(usd.balance.total, Decimal::from_str("10000.50").unwrap());
106        assert_eq!(usd.balance.free, Decimal::from_str("8000.25").unwrap());
107
108        let eur = balances.iter().find(|b| b.asset.as_ref() == "EUR").unwrap();
109        assert_eq!(eur.balance.total, Decimal::from_str("5000.00").unwrap());
110        assert_eq!(eur.balance.free, Decimal::from_str("5000.00").unwrap());
111    }
112
113    #[test]
114    fn test_balance_aggregator_clear() {
115        let mut agg = BalanceAggregator::new();
116        agg.process(&mock_account_summary("TotalCashValue", "1000", "USD"));
117        assert_eq!(agg.to_balances().len(), 1);
118
119        agg.clear();
120        assert!(agg.to_balances().is_empty());
121    }
122}