use crate::{
balance::{AssetBalance, Balance},
error::UnindexedClientError,
position::{Position, PositionReport},
};
use chrono::{DateTime, Utc};
use fnv::FnvHashMap;
use ibapi::{accounts::AccountSummaryTags, contracts::SecurityType};
use rust_decimal::Decimal;
use rustrade_instrument::{
asset::name::AssetNameExchange, instrument::name::InstrumentNameExchange,
};
use smol_str::SmolStr;
use std::{
collections::{BTreeMap, BTreeSet},
str::FromStr,
};
use tracing::warn;
pub(crate) const BALANCE_SUMMARY_TAGS: &[&str] = &[
AccountSummaryTags::AVAILABLE_FUNDS,
AccountSummaryTags::LEDGER_ALL,
];
const LEDGER_BASE: &str = "BASE";
#[derive(Debug, Default)]
pub struct BalanceAggregator {
ledger: FnvHashMap<(SmolStr, SmolStr), LedgerCurrency>,
available_funds: FnvHashMap<SmolStr, Decimal>,
}
#[derive(Debug, Default, Clone)]
struct LedgerCurrency {
total_cash_balance: Option<Decimal>,
cash_balance: Option<Decimal>,
accrued_cash: Option<Decimal>,
exchange_rate: Option<Decimal>,
}
impl BalanceAggregator {
pub fn new() -> Self {
Self::default()
}
pub fn process(&mut self, summary: &ibapi::accounts::AccountSummary) {
let field: fn(&mut LedgerCurrency) -> &mut Option<Decimal> = match summary.tag.as_str() {
AccountSummaryTags::AVAILABLE_FUNDS => {
if let Some(value) = parse_summary_value(summary) {
self.available_funds
.insert(SmolStr::new(&summary.account), value);
}
return;
}
"TotalCashBalance" => |c| &mut c.total_cash_balance,
"CashBalance" => |c| &mut c.cash_balance,
AccountSummaryTags::ACCRUED_CASH => |c| &mut c.accrued_cash,
"ExchangeRate" => |c| &mut c.exchange_rate,
_ => return,
};
if let Some(value) = parse_summary_value(summary) {
let currency = self
.ledger
.entry((
SmolStr::new(&summary.account),
SmolStr::new(&summary.currency),
))
.or_default();
*field(currency) = Some(value);
}
}
pub fn to_balances(&self) -> Vec<AssetBalance<AssetNameExchange>> {
let now = Utc::now();
let mut balances = BTreeMap::<&str, Balance>::new();
let mut unconverted = BTreeSet::<&str>::new();
for ((account, currency), ledger) in &self.ledger {
if currency.as_str() == LEDGER_BASE {
continue;
}
let Some(total) = ledger.total_cash_balance.or(ledger.cash_balance) else {
warn!(%account, %currency, "IB's ledger lists the currency without a cash balance; skipped");
continue;
};
let rate = ledger.exchange_rate.filter(|rate| *rate > Decimal::ZERO);
let free = match (self.available_funds.get(account), rate) {
(Some(funds), Some(rate)) => funds
.checked_div(rate)
.map_or(total, |funds| total.min(funds)),
(Some(_), None) => {
unconverted.insert(currency);
total
}
(None, _) => total,
};
let sum = balances.entry(currency).or_default();
sum.total += total;
sum.free += free;
sum.accrued = match (sum.accrued, ledger.accrued_cash) {
(Some(sum), Some(accrued)) => Some(sum + accrued),
(sum, accrued) => sum.or(accrued),
};
}
if !unconverted.is_empty() {
warn!(
currencies = ?unconverted,
"IB's ledger gives no exchange rate to convert AvailableFunds into these \
currencies; their free balance is not capped"
);
}
balances
.into_iter()
.map(|(currency, balance)| AssetBalance {
asset: AssetNameExchange::from(currency),
balance,
time_exchange: now,
})
.collect()
}
pub fn clear(&mut self) {
self.ledger.clear();
self.available_funds.clear();
}
}
fn parse_summary_value(summary: &ibapi::accounts::AccountSummary) -> Option<Decimal> {
Decimal::from_str(&summary.value)
.inspect_err(|e| {
warn!(tag = %summary.tag, value = %summary.value, error = %e, "Failed to parse balance")
})
.ok()
}
#[derive(Debug, Default)]
pub(crate) struct PositionAggregator {
reports: Vec<(InstrumentNameExchange, ibapi::accounts::Position)>,
index: FnvHashMap<(String, InstrumentNameExchange), usize>,
listed_all: bool,
}
impl PositionAggregator {
pub(crate) fn listing_ended(&mut self) {
self.listed_all = true;
}
pub(crate) fn process(
&mut self,
instrument: InstrumentNameExchange,
position: ibapi::accounts::Position,
) {
let key = (position.account.clone(), instrument);
match self.index.get(&key) {
Some(&i) => self.reports[i].1 = position,
None => {
self.index.insert(key.clone(), self.reports.len());
self.reports.push((key.1, position));
}
}
}
pub(crate) fn into_reports<'a>(
self,
now: DateTime<Utc>,
requested: impl IntoIterator<Item = &'a InstrumentNameExchange>,
) -> Result<Vec<(InstrumentNameExchange, PositionReport)>, UnindexedClientError> {
let listed_all = self.listed_all;
let mut reports: Vec<_> = self
.into_positions(now)?
.into_iter()
.map(|(instrument, position)| {
let report = position.map_or(PositionReport::Flat, PositionReport::Open);
(instrument, report)
})
.collect();
let unlisted: Vec<_> = requested
.into_iter()
.filter(|instrument| !reports.iter().any(|(listed, _)| listed == *instrument))
.cloned()
.collect();
if listed_all {
reports.extend(
unlisted
.into_iter()
.map(|instrument| (instrument, PositionReport::Flat)),
);
} else if !unlisted.is_empty() {
warn!(
unlisted = unlisted.len(),
"IB did not finish listing positions during the read; requested instruments it \
did not list are left out rather than reported flat"
);
}
Ok(reports)
}
pub(crate) fn into_positions(
self,
now: DateTime<Utc>,
) -> Result<Vec<(InstrumentNameExchange, Option<Position>)>, UnindexedClientError> {
let mut positions: Vec<(InstrumentNameExchange, Option<Position>)> = Vec::new();
let mut by_instrument = FnvHashMap::default();
for (instrument, report) in self.reports {
let position = convert_position(&report, now)?;
match by_instrument.get(&instrument) {
None => {
by_instrument.insert(instrument.clone(), positions.len());
positions.push((instrument, position));
}
Some(&i) => {
let kept = &mut positions[i].1;
match (kept.is_some(), position) {
(_, None) => {}
(false, position) => *kept = position,
(true, Some(_)) => warn!(
instrument = %instrument,
account = %report.account,
"IB reports a position in this instrument in more than one account; \
keeping the first account's and dropping this one"
),
}
}
}
}
Ok(positions)
}
}
fn convert_position(
report: &ibapi::accounts::Position,
now: DateTime<Utc>,
) -> Result<Option<Position>, UnindexedClientError> {
let quantity = Decimal::try_from(report.position).map_err(|e| {
UnindexedClientError::Internal(format!(
"IB position quantity {} for contract {} is not a decimal: {e}",
report.position, report.contract.contract_id
))
})?;
if quantity.is_zero() {
return Ok(None);
}
Ok(Some(Position::new(
quantity,
entry_price(report),
None,
None,
None,
None,
None,
now,
)))
}
fn entry_price(report: &ibapi::accounts::Position) -> Option<Decimal> {
if report.average_cost == 0.0 {
return None;
}
let contract = &report.contract;
let average_cost = Decimal::try_from(report.average_cost)
.inspect_err(|e| {
warn!(
contract_id = contract.contract_id,
average_cost = report.average_cost,
error = %e,
"IB average cost is not a decimal; leaving the entry price unset"
)
})
.ok()?;
let multiplier = Decimal::from_str(&contract.multiplier)
.ok()
.filter(|m| m.is_sign_positive() && !m.is_zero());
match (multiplier, &contract.security_type) {
(Some(multiplier), _) => average_cost.checked_div(multiplier),
(None, SecurityType::Stock | SecurityType::ForexPair) => Some(average_cost),
(None, security_type) => {
warn!(
contract_id = contract.contract_id,
%security_type,
multiplier = %contract.multiplier,
"IB sent no contract multiplier; leaving the entry price unset"
);
None
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use ibapi::accounts::AccountSummary;
use rust_decimal_macros::dec;
fn mock_account_summary(tag: &str, value: &str, currency: &str) -> AccountSummary {
account_summary("DU123456", tag, value, currency)
}
fn account_summary(account: &str, tag: &str, value: &str, currency: &str) -> AccountSummary {
AccountSummary {
account: account.to_string(),
tag: tag.to_string(),
value: value.to_string(),
currency: currency.to_string(),
}
}
fn ledger(
agg: &mut BalanceAggregator,
available_funds: Option<(&str, &str)>,
rows: &[(&str, &str, &str, &str)],
) {
account_ledger(agg, "DU123456", available_funds, rows);
}
fn account_ledger(
agg: &mut BalanceAggregator,
account: &str,
available_funds: Option<(&str, &str)>,
rows: &[(&str, &str, &str, &str)],
) {
if let Some((currency, value)) = available_funds {
agg.process(&account_summary(account, "AvailableFunds", value, currency));
}
for &(currency, cash, accrued, rate) in rows {
for (tag, value) in [
("Currency", currency),
("CashBalance", cash),
("TotalCashBalance", cash),
("AccruedCash", accrued),
("ExchangeRate", rate),
("RealCurrency", currency),
] {
agg.process(&account_summary(account, tag, value, currency));
}
}
}
fn balance_of<'a>(
balances: &'a [AssetBalance<AssetNameExchange>],
currency: &str,
) -> &'a Balance {
&balances
.iter()
.find(|b| b.asset.as_ref() == currency)
.unwrap()
.balance
}
#[test]
fn test_balance_aggregator_reads_the_ledger_per_currency() {
let mut agg = BalanceAggregator::new();
ledger(
&mut agg,
Some(("SGD", "1001324.57")),
&[
("SGD", "1001190.04", "123.90", "1.00"),
("USD", "8.30", "0.00", "1.2801581"),
("BASE", "1001200.6653", "123.90", "1.00"),
],
);
let balances = agg.to_balances();
let currencies = balances
.iter()
.map(|b| b.asset.as_ref())
.collect::<Vec<_>>();
assert_eq!(currencies, ["SGD", "USD"], "ordered by currency");
let sgd = balance_of(&balances, "SGD");
assert_eq!(sgd.total, dec!(1001190.04));
assert_eq!(sgd.free, dec!(1001190.04), "free is capped at total");
assert_eq!(sgd.accrued, Some(dec!(123.90)));
let usd = balance_of(&balances, "USD");
assert_eq!(usd.total, dec!(8.30));
assert_eq!(usd.free, dec!(8.30));
assert_eq!(usd.accrued, Some(dec!(0.00)));
}
#[test]
fn test_balance_aggregator_caps_free_at_available_funds() {
let mut agg = BalanceAggregator::new();
ledger(
&mut agg,
Some(("SGD", "1280")),
&[("SGD", "5000", "0", "1"), ("USD", "4000", "-2.5", "1.28")],
);
let balances = agg.to_balances();
assert_eq!(balance_of(&balances, "SGD").free, dec!(1280));
let usd = balance_of(&balances, "USD");
assert_eq!(usd.free, dec!(1000), "1280 SGD at 1.28 SGD per USD");
assert_eq!(usd.accrued, Some(dec!(-2.5)));
}
#[test]
fn test_balance_aggregator_sums_accounts_each_capped_by_its_own_funds() {
let mut agg = BalanceAggregator::new();
account_ledger(
&mut agg,
"DU1",
Some(("SGD", "1280")),
&[("SGD", "5000", "0", "1"), ("USD", "4000", "-2.5", "1.28")],
);
account_ledger(
&mut agg,
"DU2",
Some(("USD", "100")),
&[("USD", "500", "0.5", "1"), ("BASE", "500", "0.5", "1")],
);
let balances = agg.to_balances();
assert_eq!(balances.len(), 2, "{balances:?}");
let sgd = balance_of(&balances, "SGD");
assert_eq!((sgd.total, sgd.free), (dec!(5000), dec!(1280)));
let usd = balance_of(&balances, "USD");
assert_eq!(usd.total, dec!(4500));
assert_eq!(usd.free, dec!(1100), "1280 SGD at 1.28, plus DU2's 100");
assert_eq!(usd.accrued, Some(dec!(-2.0)));
}
#[test]
fn test_balance_aggregator_sums_accruals_reported() {
let mut agg = BalanceAggregator::new();
agg.process(&account_summary("DU1", "CashBalance", "10", "EUR"));
agg.process(&account_summary("DU2", "CashBalance", "20", "EUR"));
agg.process(&account_summary("DU2", "AccruedCash", "-1", "EUR"));
agg.process(&account_summary("DU1", "CashBalance", "5", "JPY"));
let balances = agg.to_balances();
let eur = balance_of(&balances, "EUR");
assert_eq!((eur.total, eur.accrued), (dec!(30), Some(dec!(-1))));
assert_eq!(balance_of(&balances, "JPY").accrued, None);
}
#[test]
fn test_balance_aggregator_free_is_total_without_funds_or_rate() {
let mut agg = BalanceAggregator::new();
ledger(&mut agg, None, &[("USD", "-50", "-0.1", "1")]);
let usd = *balance_of(&agg.to_balances(), "USD");
assert_eq!((usd.total, usd.free), (dec!(-50), dec!(-50)));
let mut agg = BalanceAggregator::new();
ledger(&mut agg, Some(("SGD", "10")), &[("USD", "500", "0", "n/a")]);
assert_eq!(balance_of(&agg.to_balances(), "USD").free, dec!(500));
}
#[test]
fn test_balance_aggregator_cash_fallbacks() {
let mut agg = BalanceAggregator::new();
agg.process(&mock_account_summary("CashBalance", "75", "EUR"));
agg.process(&mock_account_summary("AccruedCash", "1", "JPY"));
let balances = agg.to_balances();
assert_eq!(balances.len(), 1, "JPY has no cash: {balances:?}");
let eur = balance_of(&balances, "EUR");
assert_eq!(
(eur.total, eur.free, eur.accrued),
(dec!(75), dec!(75), None)
);
}
fn report(
account: &str,
security_type: SecurityType,
multiplier: &str,
quantity: f64,
average_cost: f64,
) -> ibapi::accounts::Position {
ibapi::accounts::Position {
account: account.to_string(),
contract: ibapi::contracts::Contract {
contract_id: 42,
security_type,
multiplier: multiplier.to_string(),
..Default::default()
},
position: quantity,
average_cost,
}
}
fn instrument(name: &str) -> InstrumentNameExchange {
InstrumentNameExchange::from(name)
}
fn now() -> DateTime<Utc> {
DateTime::from_timestamp(1_700_000_000, 0).unwrap()
}
#[test]
fn test_convert_position_stock_long() {
let position =
convert_position(&report("DU1", SecurityType::Stock, "", 10.0, 150.25), now())
.unwrap()
.unwrap();
assert_eq!(
position,
Position::new(
dec!(10),
Some(dec!(150.25)),
None,
None,
None,
None,
None,
now()
)
);
}
#[test]
fn test_convert_position_short_keeps_sign() {
let position = convert_position(&report("DU1", SecurityType::Stock, "", -5.0, 20.0), now())
.unwrap()
.unwrap();
assert_eq!(position.quantity, dec!(-5));
assert_eq!(position.entry_price, Some(dec!(20)));
}
#[test]
fn test_convert_position_fractional_quantity_is_rounded_to_float_precision() {
let position = convert_position(&report("DU1", SecurityType::Stock, "", 0.3, 100.0), now())
.unwrap()
.unwrap();
assert_eq!(position.quantity, dec!(0.3));
}
#[test]
fn test_convert_position_zero_quantity_is_none() {
assert_eq!(
convert_position(&report("DU1", SecurityType::Stock, "", 0.0, 150.0), now()).unwrap(),
None
);
}
#[test]
fn test_convert_position_non_finite_quantity_is_internal_error() {
let result = convert_position(
&report("DU1", SecurityType::Stock, "", f64::NAN, 1.0),
now(),
);
assert!(matches!(result, Err(UnindexedClientError::Internal(_))));
}
#[test]
fn test_entry_price_divides_by_multiplier() {
let option = report("DU1", SecurityType::Option, "100", -2.0, 235.0);
assert_eq!(entry_price(&option), Some(dec!(2.35)));
let future = report("DU1", SecurityType::Future, "50", 1.0, 250_012.5);
assert_eq!(entry_price(&future), Some(dec!(5000.25)));
}
#[test]
fn test_entry_price_without_multiplier() {
assert_eq!(
entry_price(&report("DU1", SecurityType::Stock, "", 1.0, 150.0)),
Some(dec!(150))
);
assert_eq!(
entry_price(&report("DU1", SecurityType::ForexPair, "0", 1.0, 1.085)),
Some(dec!(1.085))
);
assert_eq!(
entry_price(&report("DU1", SecurityType::Future, "", 1.0, 250_000.0)),
None
);
assert_eq!(
entry_price(&report("DU1", SecurityType::Option, "0", 1.0, 235.0)),
None
);
}
#[test]
fn test_entry_price_unset_or_invalid_average_cost_is_none() {
assert_eq!(
entry_price(&report("DU1", SecurityType::Stock, "", 1.0, 0.0)),
None
);
assert_eq!(
entry_price(&report("DU1", SecurityType::Stock, "", 1.0, f64::MAX)),
None
);
assert_eq!(
entry_price(&report("DU1", SecurityType::Stock, "", 1.0, f64::NAN)),
None
);
}
#[test]
fn test_position_aggregator_latest_report_per_account_wins() {
let mut agg = PositionAggregator::default();
agg.process(
instrument("AAPL"),
report("DU1", SecurityType::Stock, "", 10.0, 150.0),
);
agg.process(
instrument("MSFT"),
report("DU1", SecurityType::Stock, "", 3.0, 400.0),
);
agg.process(
instrument("AAPL"),
report("DU1", SecurityType::Stock, "", 12.0, 151.0),
);
let positions = agg.into_positions(now()).unwrap();
assert_eq!(positions.len(), 2);
assert_eq!(positions[0].0, instrument("AAPL"));
assert_eq!(positions[0].1.as_ref().unwrap().quantity, dec!(12));
assert_eq!(positions[1].0, instrument("MSFT"));
}
#[test]
fn test_position_aggregator_closed_position_is_listed_without_position() {
let mut agg = PositionAggregator::default();
agg.process(
instrument("AAPL"),
report("DU1", SecurityType::Stock, "", 10.0, 150.0),
);
agg.process(
instrument("AAPL"),
report("DU1", SecurityType::Stock, "", 0.0, 0.0),
);
assert_eq!(
agg.into_positions(now()).unwrap(),
vec![(instrument("AAPL"), None)]
);
}
#[test]
fn test_position_aggregator_later_flat_account_and_update_keep_latest() {
let mut agg = PositionAggregator::default();
agg.process(
instrument("AAPL"),
report("DU2", SecurityType::Stock, "", 7.0, 150.0),
);
agg.process(
instrument("AAPL"),
report("DU1", SecurityType::Stock, "", 0.0, 0.0),
);
agg.process(
instrument("AAPL"),
report("DU2", SecurityType::Stock, "", 9.0, 151.0),
);
let positions = agg.into_positions(now()).unwrap();
assert_eq!(positions.len(), 1);
assert_eq!(positions[0].1.as_ref().unwrap().quantity, dec!(9));
}
#[test]
fn test_position_aggregator_non_finite_quantity_is_internal_error() {
let mut agg = PositionAggregator::default();
agg.process(
instrument("AAPL"),
report("DU1", SecurityType::Stock, "", f64::NAN, 150.0),
);
assert!(matches!(
agg.into_positions(now()),
Err(UnindexedClientError::Internal(_))
));
}
#[test]
fn test_position_aggregator_multi_account_keeps_first_non_zero() {
let mut agg = PositionAggregator::default();
agg.process(
instrument("AAPL"),
report("DU1", SecurityType::Stock, "", 0.0, 0.0),
);
agg.process(
instrument("AAPL"),
report("DU2", SecurityType::Stock, "", 7.0, 150.0),
);
agg.process(
instrument("AAPL"),
report("DU3", SecurityType::Stock, "", 4.0, 149.0),
);
let positions = agg.into_positions(now()).unwrap();
assert_eq!(positions.len(), 1);
assert_eq!(positions[0].1.as_ref().unwrap().quantity, dec!(7));
}
#[test]
fn test_balance_aggregator_clear() {
let mut agg = BalanceAggregator::new();
ledger(&mut agg, Some(("USD", "900")), &[("USD", "1000", "0", "1")]);
assert_eq!(agg.to_balances().len(), 1);
agg.clear();
assert!(agg.to_balances().is_empty());
}
#[test]
fn reports_are_open_or_flat_and_add_unlisted_instruments_only_after_a_complete_listing() {
let requested = [instrument("AAPL"), instrument("MSFT")];
let reports = |events: &[&str]| {
let mut agg = PositionAggregator::default();
for event in events {
match *event {
"end" => agg.listing_ended(),
name => {
let quantity = if name == "F" { 0.0 } else { 3.0 };
agg.process(
instrument(name),
report("DU1", SecurityType::Stock, "", quantity, 10.0),
);
}
}
}
agg.into_reports(now(), &requested).unwrap()
};
let aapl = || {
(
instrument("AAPL"),
PositionReport::from_position(
convert_position(&report("DU1", SecurityType::Stock, "", 3.0, 10.0), now())
.unwrap()
.unwrap(),
),
)
};
assert_eq!(
reports(&["AAPL", "F", "end"]),
vec![
aapl(),
(instrument("F"), PositionReport::Flat),
(instrument("MSFT"), PositionReport::Flat),
]
);
assert_eq!(
reports(&["AAPL", "F"]),
vec![aapl(), (instrument("F"), PositionReport::Flat)]
);
}
}