Skip to main content

rustrade_execution/client/ibkr/
account.rs

1use crate::{
2    balance::{AssetBalance, Balance},
3    error::UnindexedClientError,
4    position::{Position, PositionReport},
5};
6use chrono::{DateTime, Utc};
7use fnv::FnvHashMap;
8use ibapi::{accounts::AccountSummaryTags, contracts::SecurityType};
9use rust_decimal::Decimal;
10use rustrade_instrument::{
11    asset::name::AssetNameExchange, instrument::name::InstrumentNameExchange,
12};
13use smol_str::SmolStr;
14use std::{
15    collections::{BTreeMap, BTreeSet},
16    str::FromStr,
17};
18use tracing::warn;
19
20/// The account-summary tags [`BalanceAggregator`] reads: `AvailableFunds`, and IB's per-currency
21/// ledger for every currency of every account in the group.
22pub(crate) const BALANCE_SUMMARY_TAGS: &[&str] = &[
23    AccountSummaryTags::AVAILABLE_FUNDS,
24    AccountSummaryTags::LEDGER_ALL,
25];
26
27/// The ledger's row for its totals converted into base currency. It is a sum of the other rows,
28/// not a currency anyone holds.
29const LEDGER_BASE: &str = "BASE";
30
31/// Per-currency balances from an account-summary listing of `AvailableFunds` and `$LEDGER:ALL`.
32///
33/// One balance per currency in IB's ledger (`$LEDGER:ALL`), summed over every account in the
34/// group:
35/// - `total` is the currency's `TotalCashBalance`, or its `CashBalance` when that is missing.
36///   IB defines both, like the account-level `TotalCashValue`, as cash recognized at the time of
37///   trade plus futures PnL.
38/// - `accrued` is the currency's `AccruedCash`: interest accrued this month and not yet posted.
39/// - `free` is `total`, capped at the account's `AvailableFunds` converted into the currency at
40///   the account's ledger `ExchangeRate`. `AvailableFunds` is account-level and in base currency
41///   only, so every currency of an account is capped by the same funds and the `free` values do
42///   not add up. Without `AvailableFunds`, or without a rate for the currency, `free` is `total`.
43///   `free` is negative when `total` is.
44///
45/// Each account is capped by its own funds and rates, since accounts can differ in base currency,
46/// and the accounts are then summed per currency. The ledger's `BASE` row is skipped.
47#[derive(Debug, Default)]
48pub struct BalanceAggregator {
49    /// Ledger rows by account, then currency. SmolStr avoids heap allocation for short codes.
50    ledger: FnvHashMap<(SmolStr, SmolStr), LedgerCurrency>,
51    /// `AvailableFunds` per account, in its account's base currency.
52    available_funds: FnvHashMap<SmolStr, Decimal>,
53}
54
55#[derive(Debug, Default, Clone)]
56struct LedgerCurrency {
57    total_cash_balance: Option<Decimal>,
58    cash_balance: Option<Decimal>,
59    accrued_cash: Option<Decimal>,
60    /// The account's base-currency units per unit of this currency.
61    exchange_rate: Option<Decimal>,
62}
63
64impl BalanceAggregator {
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// Process an AccountSummary event.
70    pub fn process(&mut self, summary: &ibapi::accounts::AccountSummary) {
71        let field: fn(&mut LedgerCurrency) -> &mut Option<Decimal> = match summary.tag.as_str() {
72            AccountSummaryTags::AVAILABLE_FUNDS => {
73                if let Some(value) = parse_summary_value(summary) {
74                    self.available_funds
75                        .insert(SmolStr::new(&summary.account), value);
76                }
77                return;
78            }
79            "TotalCashBalance" => |c| &mut c.total_cash_balance,
80            "CashBalance" => |c| &mut c.cash_balance,
81            AccountSummaryTags::ACCRUED_CASH => |c| &mut c.accrued_cash,
82            "ExchangeRate" => |c| &mut c.exchange_rate,
83            _ => return,
84        };
85        if let Some(value) = parse_summary_value(summary) {
86            let currency = self
87                .ledger
88                .entry((
89                    SmolStr::new(&summary.account),
90                    SmolStr::new(&summary.currency),
91                ))
92                .or_default();
93            *field(currency) = Some(value);
94        }
95    }
96
97    /// Convert aggregated data to rustrade AssetBalance list, ordered by currency.
98    pub fn to_balances(&self) -> Vec<AssetBalance<AssetNameExchange>> {
99        let now = Utc::now();
100        let mut balances = BTreeMap::<&str, Balance>::new();
101        // Currencies whose `free` is left uncapped though their account reported `AvailableFunds`.
102        let mut unconverted = BTreeSet::<&str>::new();
103
104        for ((account, currency), ledger) in &self.ledger {
105            if currency.as_str() == LEDGER_BASE {
106                continue;
107            }
108            let Some(total) = ledger.total_cash_balance.or(ledger.cash_balance) else {
109                warn!(%account, %currency, "IB's ledger lists the currency without a cash balance; skipped");
110                continue;
111            };
112            let rate = ledger.exchange_rate.filter(|rate| *rate > Decimal::ZERO);
113            let free = match (self.available_funds.get(account), rate) {
114                (Some(funds), Some(rate)) => funds
115                    .checked_div(rate)
116                    .map_or(total, |funds| total.min(funds)),
117                (Some(_), None) => {
118                    unconverted.insert(currency);
119                    total
120                }
121                (None, _) => total,
122            };
123
124            let sum = balances.entry(currency).or_default();
125            sum.total += total;
126            sum.free += free;
127            sum.accrued = match (sum.accrued, ledger.accrued_cash) {
128                (Some(sum), Some(accrued)) => Some(sum + accrued),
129                (sum, accrued) => sum.or(accrued),
130            };
131        }
132
133        if !unconverted.is_empty() {
134            warn!(
135                currencies = ?unconverted,
136                "IB's ledger gives no exchange rate to convert AvailableFunds into these \
137                 currencies; their free balance is not capped"
138            );
139        }
140
141        balances
142            .into_iter()
143            .map(|(currency, balance)| AssetBalance {
144                asset: AssetNameExchange::from(currency),
145                balance,
146                time_exchange: now,
147            })
148            .collect()
149    }
150
151    /// Clear all aggregated data.
152    pub fn clear(&mut self) {
153        self.ledger.clear();
154        self.available_funds.clear();
155    }
156}
157
158/// Parse an account-summary value, warning if it is not a number.
159fn parse_summary_value(summary: &ibapi::accounts::AccountSummary) -> Option<Decimal> {
160    // Parse directly to Decimal to preserve precision
161    Decimal::from_str(&summary.value)
162        .inspect_err(|e| {
163            warn!(tag = %summary.tag, value = %summary.value, error = %e, "Failed to parse balance")
164        })
165        .ok()
166}
167
168/// Collects IB position reports into at most one [`Position`] per instrument.
169///
170/// IB reports each position per account. For each account and instrument, the latest report
171/// wins.
172///
173/// When more than one account holds the same instrument, the first account to report a non-zero
174/// quantity, in IB's reporting order, is kept and the others are dropped with a warning. Summing them would report a
175/// position that no single account holds.
176#[derive(Debug, Default)]
177pub(crate) struct PositionAggregator {
178    /// Latest report per account and instrument, in the order each pair was first reported.
179    reports: Vec<(InstrumentNameExchange, ibapi::accounts::Position)>,
180    index: FnvHashMap<(String, InstrumentNameExchange), usize>,
181    /// Whether IB marked the end of its listing (`PositionEnd`), so every position it holds has
182    /// been reported.
183    listed_all: bool,
184}
185
186impl PositionAggregator {
187    /// Note that IB marked the end of its listing (`PositionEnd`).
188    pub(crate) fn listing_ended(&mut self) {
189        self.listed_all = true;
190    }
191
192    /// Record a report for `instrument`, replacing any earlier one from the same account.
193    pub(crate) fn process(
194        &mut self,
195        instrument: InstrumentNameExchange,
196        position: ibapi::accounts::Position,
197    ) {
198        let key = (position.account.clone(), instrument);
199        match self.index.get(&key) {
200            Some(&i) => self.reports[i].1 = position,
201            None => {
202                self.index.insert(key.clone(), self.reports.len());
203                self.reports.push((key.1, position));
204            }
205        }
206    }
207
208    /// The [`PositionReport`] of each instrument in an `account_snapshot`.
209    ///
210    /// Each reported instrument is [`PositionReport::Open`], or [`PositionReport::Flat`] when
211    /// every account reported zero, as IB does for a position closed today. `requested` are the
212    /// requested instruments that IB's reports can be attributed to. Those IB did not report are
213    /// [`PositionReport::Flat`] when the read ended on a complete listing (see
214    /// [`Self::listing_ended`]). Otherwise the listing may be incomplete, so they are left out,
215    /// with a warning.
216    ///
217    /// # Errors
218    ///
219    /// As [`Self::into_positions`].
220    pub(crate) fn into_reports<'a>(
221        self,
222        now: DateTime<Utc>,
223        requested: impl IntoIterator<Item = &'a InstrumentNameExchange>,
224    ) -> Result<Vec<(InstrumentNameExchange, PositionReport)>, UnindexedClientError> {
225        let listed_all = self.listed_all;
226        let mut reports: Vec<_> = self
227            .into_positions(now)?
228            .into_iter()
229            .map(|(instrument, position)| {
230                let report = position.map_or(PositionReport::Flat, PositionReport::Open);
231                (instrument, report)
232            })
233            .collect();
234        let unlisted: Vec<_> = requested
235            .into_iter()
236            .filter(|instrument| !reports.iter().any(|(listed, _)| listed == *instrument))
237            .cloned()
238            .collect();
239        if listed_all {
240            reports.extend(
241                unlisted
242                    .into_iter()
243                    .map(|instrument| (instrument, PositionReport::Flat)),
244            );
245        } else if !unlisted.is_empty() {
246            warn!(
247                unlisted = unlisted.len(),
248                "IB did not finish listing positions during the read; requested instruments it \
249                 did not list are left out rather than reported flat"
250            );
251        }
252        Ok(reports)
253    }
254
255    /// One entry per reported instrument, in the order instruments were first reported.
256    ///
257    /// The [`Position`] is `None` when every account reported a zero quantity.
258    ///
259    /// # Errors
260    ///
261    /// [`UnindexedClientError::Internal`] if a quantity does not convert to a [`Decimal`].
262    pub(crate) fn into_positions(
263        self,
264        now: DateTime<Utc>,
265    ) -> Result<Vec<(InstrumentNameExchange, Option<Position>)>, UnindexedClientError> {
266        let mut positions: Vec<(InstrumentNameExchange, Option<Position>)> = Vec::new();
267        let mut by_instrument = FnvHashMap::default();
268        for (instrument, report) in self.reports {
269            let position = convert_position(&report, now)?;
270            match by_instrument.get(&instrument) {
271                None => {
272                    by_instrument.insert(instrument.clone(), positions.len());
273                    positions.push((instrument, position));
274                }
275                Some(&i) => {
276                    let kept = &mut positions[i].1;
277                    match (kept.is_some(), position) {
278                        // A zero quantity in another account adds nothing.
279                        (_, None) => {}
280                        (false, position) => *kept = position,
281                        (true, Some(_)) => warn!(
282                            instrument = %instrument,
283                            account = %report.account,
284                            "IB reports a position in this instrument in more than one account; \
285                             keeping the first account's and dropping this one"
286                        ),
287                    }
288                }
289            }
290        }
291        Ok(positions)
292    }
293}
294
295/// Convert one IB position report to a [`Position`], or `None` if its quantity is zero.
296///
297/// - `quantity`: IB's signed position, negative when short. `ibapi` hands it over as an
298///   `f64`, converted with `Decimal::try_from`, which rounds to the float's precision of about 15
299///   significant digits (0.1 stays 0.1) rather than keeping its exact binary expansion.
300/// - `entry_price`: see [`entry_price`].
301/// - `time_exchange`: `now`, since IB does not timestamp positions.
302fn convert_position(
303    report: &ibapi::accounts::Position,
304    now: DateTime<Utc>,
305) -> Result<Option<Position>, UnindexedClientError> {
306    let quantity = Decimal::try_from(report.position).map_err(|e| {
307        UnindexedClientError::Internal(format!(
308            "IB position quantity {} for contract {} is not a decimal: {e}",
309            report.position, report.contract.contract_id
310        ))
311    })?;
312    if quantity.is_zero() {
313        return Ok(None);
314    }
315    Ok(Some(Position::new(
316        quantity,
317        entry_price(report),
318        None,
319        None,
320        None,
321        None,
322        None,
323        now,
324    )))
325}
326
327/// IB's average cost, quoted as orders are priced.
328///
329/// IB's average cost is per contract, so for a future or an option it is the price multiplied by
330/// the contract multiplier; it is divided back out here. It also includes commissions.
331///
332/// `None`, logged unless the cost is simply absent, when:
333/// - the average cost is zero, which is how `ibapi` decodes an unset one;
334/// - it does not convert to a [`Decimal`];
335/// - IB sends no positive multiplier for a contract other than a stock or a forex pair, whose cost
336///   is already per unit.
337fn entry_price(report: &ibapi::accounts::Position) -> Option<Decimal> {
338    if report.average_cost == 0.0 {
339        return None;
340    }
341    let contract = &report.contract;
342    let average_cost = Decimal::try_from(report.average_cost)
343        .inspect_err(|e| {
344            warn!(
345                contract_id = contract.contract_id,
346                average_cost = report.average_cost,
347                error = %e,
348                "IB average cost is not a decimal; leaving the entry price unset"
349            )
350        })
351        .ok()?;
352    let multiplier = Decimal::from_str(&contract.multiplier)
353        .ok()
354        .filter(|m| m.is_sign_positive() && !m.is_zero());
355    match (multiplier, &contract.security_type) {
356        (Some(multiplier), _) => average_cost.checked_div(multiplier),
357        (None, SecurityType::Stock | SecurityType::ForexPair) => Some(average_cost),
358        (None, security_type) => {
359            warn!(
360                contract_id = contract.contract_id,
361                %security_type,
362                multiplier = %contract.multiplier,
363                "IB sent no contract multiplier; leaving the entry price unset"
364            );
365            None
366        }
367    }
368}
369
370#[cfg(test)]
371#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
372mod tests {
373    use super::*;
374    use ibapi::accounts::AccountSummary;
375    use rust_decimal_macros::dec;
376
377    fn mock_account_summary(tag: &str, value: &str, currency: &str) -> AccountSummary {
378        account_summary("DU123456", tag, value, currency)
379    }
380
381    fn account_summary(account: &str, tag: &str, value: &str, currency: &str) -> AccountSummary {
382        AccountSummary {
383            account: account.to_string(),
384            tag: tag.to_string(),
385            value: value.to_string(),
386            currency: currency.to_string(),
387        }
388    }
389
390    /// Feed `agg` one account's `AvailableFunds` and a ledger row per `(currency, cash,
391    /// accrued, rate)`, as `$LEDGER:ALL` lists them.
392    fn ledger(
393        agg: &mut BalanceAggregator,
394        available_funds: Option<(&str, &str)>,
395        rows: &[(&str, &str, &str, &str)],
396    ) {
397        account_ledger(agg, "DU123456", available_funds, rows);
398    }
399
400    /// [`ledger`] for the account `account`.
401    fn account_ledger(
402        agg: &mut BalanceAggregator,
403        account: &str,
404        available_funds: Option<(&str, &str)>,
405        rows: &[(&str, &str, &str, &str)],
406    ) {
407        if let Some((currency, value)) = available_funds {
408            agg.process(&account_summary(account, "AvailableFunds", value, currency));
409        }
410        for &(currency, cash, accrued, rate) in rows {
411            for (tag, value) in [
412                ("Currency", currency),
413                ("CashBalance", cash),
414                ("TotalCashBalance", cash),
415                ("AccruedCash", accrued),
416                ("ExchangeRate", rate),
417                ("RealCurrency", currency),
418            ] {
419                agg.process(&account_summary(account, tag, value, currency));
420            }
421        }
422    }
423
424    fn balance_of<'a>(
425        balances: &'a [AssetBalance<AssetNameExchange>],
426        currency: &str,
427    ) -> &'a Balance {
428        &balances
429            .iter()
430            .find(|b| b.asset.as_ref() == currency)
431            .unwrap()
432            .balance
433    }
434
435    /// The shape of a paper account's ledger: base SGD with some USD. Each currency is its own
436    /// balance with its accrual, and the BASE row is not one.
437    #[test]
438    fn test_balance_aggregator_reads_the_ledger_per_currency() {
439        let mut agg = BalanceAggregator::new();
440        ledger(
441            &mut agg,
442            Some(("SGD", "1001324.57")),
443            &[
444                ("SGD", "1001190.04", "123.90", "1.00"),
445                ("USD", "8.30", "0.00", "1.2801581"),
446                ("BASE", "1001200.6653", "123.90", "1.00"),
447            ],
448        );
449
450        let balances = agg.to_balances();
451        let currencies = balances
452            .iter()
453            .map(|b| b.asset.as_ref())
454            .collect::<Vec<_>>();
455        assert_eq!(currencies, ["SGD", "USD"], "ordered by currency");
456        let sgd = balance_of(&balances, "SGD");
457        assert_eq!(sgd.total, dec!(1001190.04));
458        assert_eq!(sgd.free, dec!(1001190.04), "free is capped at total");
459        assert_eq!(sgd.accrued, Some(dec!(123.90)));
460        let usd = balance_of(&balances, "USD");
461        assert_eq!(usd.total, dec!(8.30));
462        assert_eq!(usd.free, dec!(8.30));
463        assert_eq!(usd.accrued, Some(dec!(0.00)));
464    }
465
466    /// `free` is the account's `AvailableFunds` in the row's currency when that is below its cash.
467    #[test]
468    fn test_balance_aggregator_caps_free_at_available_funds() {
469        let mut agg = BalanceAggregator::new();
470        ledger(
471            &mut agg,
472            Some(("SGD", "1280")),
473            &[("SGD", "5000", "0", "1"), ("USD", "4000", "-2.5", "1.28")],
474        );
475
476        let balances = agg.to_balances();
477        assert_eq!(balance_of(&balances, "SGD").free, dec!(1280));
478        let usd = balance_of(&balances, "USD");
479        assert_eq!(usd.free, dec!(1000), "1280 SGD at 1.28 SGD per USD");
480        assert_eq!(usd.accrued, Some(dec!(-2.5)));
481    }
482
483    /// Accounts are summed per currency, each capped by its own `AvailableFunds` at its own rates:
484    /// here one account is based in SGD and the other in USD.
485    #[test]
486    fn test_balance_aggregator_sums_accounts_each_capped_by_its_own_funds() {
487        let mut agg = BalanceAggregator::new();
488        account_ledger(
489            &mut agg,
490            "DU1",
491            Some(("SGD", "1280")),
492            &[("SGD", "5000", "0", "1"), ("USD", "4000", "-2.5", "1.28")],
493        );
494        account_ledger(
495            &mut agg,
496            "DU2",
497            Some(("USD", "100")),
498            &[("USD", "500", "0.5", "1"), ("BASE", "500", "0.5", "1")],
499        );
500
501        let balances = agg.to_balances();
502        assert_eq!(balances.len(), 2, "{balances:?}");
503        let sgd = balance_of(&balances, "SGD");
504        assert_eq!((sgd.total, sgd.free), (dec!(5000), dec!(1280)));
505        let usd = balance_of(&balances, "USD");
506        assert_eq!(usd.total, dec!(4500));
507        assert_eq!(usd.free, dec!(1100), "1280 SGD at 1.28, plus DU2's 100");
508        assert_eq!(usd.accrued, Some(dec!(-2.0)));
509    }
510
511    /// An accrual is summed over the accounts that report one, and stays `None` if none does.
512    #[test]
513    fn test_balance_aggregator_sums_accruals_reported() {
514        let mut agg = BalanceAggregator::new();
515        agg.process(&account_summary("DU1", "CashBalance", "10", "EUR"));
516        agg.process(&account_summary("DU2", "CashBalance", "20", "EUR"));
517        agg.process(&account_summary("DU2", "AccruedCash", "-1", "EUR"));
518        agg.process(&account_summary("DU1", "CashBalance", "5", "JPY"));
519
520        let balances = agg.to_balances();
521        let eur = balance_of(&balances, "EUR");
522        assert_eq!((eur.total, eur.accrued), (dec!(30), Some(dec!(-1))));
523        assert_eq!(balance_of(&balances, "JPY").accrued, None);
524    }
525
526    /// Without `AvailableFunds`, or a rate to convert it, `free` is `total`; an unparseable value
527    /// is skipped.
528    #[test]
529    fn test_balance_aggregator_free_is_total_without_funds_or_rate() {
530        let mut agg = BalanceAggregator::new();
531        ledger(&mut agg, None, &[("USD", "-50", "-0.1", "1")]);
532        let usd = *balance_of(&agg.to_balances(), "USD");
533        assert_eq!((usd.total, usd.free), (dec!(-50), dec!(-50)));
534
535        let mut agg = BalanceAggregator::new();
536        ledger(&mut agg, Some(("SGD", "10")), &[("USD", "500", "0", "n/a")]);
537        assert_eq!(balance_of(&agg.to_balances(), "USD").free, dec!(500));
538    }
539
540    /// `CashBalance` stands in when `TotalCashBalance` is missing, and a currency with neither, or
541    /// without `AccruedCash`, is handled.
542    #[test]
543    fn test_balance_aggregator_cash_fallbacks() {
544        let mut agg = BalanceAggregator::new();
545        agg.process(&mock_account_summary("CashBalance", "75", "EUR"));
546        agg.process(&mock_account_summary("AccruedCash", "1", "JPY"));
547
548        let balances = agg.to_balances();
549        assert_eq!(balances.len(), 1, "JPY has no cash: {balances:?}");
550        let eur = balance_of(&balances, "EUR");
551        assert_eq!(
552            (eur.total, eur.free, eur.accrued),
553            (dec!(75), dec!(75), None)
554        );
555    }
556
557    fn report(
558        account: &str,
559        security_type: SecurityType,
560        multiplier: &str,
561        quantity: f64,
562        average_cost: f64,
563    ) -> ibapi::accounts::Position {
564        ibapi::accounts::Position {
565            account: account.to_string(),
566            contract: ibapi::contracts::Contract {
567                contract_id: 42,
568                security_type,
569                multiplier: multiplier.to_string(),
570                ..Default::default()
571            },
572            position: quantity,
573            average_cost,
574        }
575    }
576
577    fn instrument(name: &str) -> InstrumentNameExchange {
578        InstrumentNameExchange::from(name)
579    }
580
581    fn now() -> DateTime<Utc> {
582        DateTime::from_timestamp(1_700_000_000, 0).unwrap()
583    }
584
585    #[test]
586    fn test_convert_position_stock_long() {
587        let position =
588            convert_position(&report("DU1", SecurityType::Stock, "", 10.0, 150.25), now())
589                .unwrap()
590                .unwrap();
591        assert_eq!(
592            position,
593            Position::new(
594                dec!(10),
595                Some(dec!(150.25)),
596                None,
597                None,
598                None,
599                None,
600                None,
601                now()
602            )
603        );
604    }
605
606    #[test]
607    fn test_convert_position_short_keeps_sign() {
608        let position = convert_position(&report("DU1", SecurityType::Stock, "", -5.0, 20.0), now())
609            .unwrap()
610            .unwrap();
611        assert_eq!(position.quantity, dec!(-5));
612        assert_eq!(position.entry_price, Some(dec!(20)));
613    }
614
615    #[test]
616    fn test_convert_position_fractional_quantity_is_rounded_to_float_precision() {
617        // 0.3 is not exact in binary; the conversion must not keep the float's expansion.
618        let position = convert_position(&report("DU1", SecurityType::Stock, "", 0.3, 100.0), now())
619            .unwrap()
620            .unwrap();
621        assert_eq!(position.quantity, dec!(0.3));
622    }
623
624    #[test]
625    fn test_convert_position_zero_quantity_is_none() {
626        assert_eq!(
627            convert_position(&report("DU1", SecurityType::Stock, "", 0.0, 150.0), now()).unwrap(),
628            None
629        );
630    }
631
632    #[test]
633    fn test_convert_position_non_finite_quantity_is_internal_error() {
634        let result = convert_position(
635            &report("DU1", SecurityType::Stock, "", f64::NAN, 1.0),
636            now(),
637        );
638        assert!(matches!(result, Err(UnindexedClientError::Internal(_))));
639    }
640
641    #[test]
642    fn test_entry_price_divides_by_multiplier() {
643        // Option: 2.35 premium x 100 multiplier.
644        let option = report("DU1", SecurityType::Option, "100", -2.0, 235.0);
645        assert_eq!(entry_price(&option), Some(dec!(2.35)));
646        // Future: 5000.25 x 50 multiplier.
647        let future = report("DU1", SecurityType::Future, "50", 1.0, 250_012.5);
648        assert_eq!(entry_price(&future), Some(dec!(5000.25)));
649    }
650
651    #[test]
652    fn test_entry_price_without_multiplier() {
653        // A stock or forex pair's average cost is already per unit.
654        assert_eq!(
655            entry_price(&report("DU1", SecurityType::Stock, "", 1.0, 150.0)),
656            Some(dec!(150))
657        );
658        assert_eq!(
659            entry_price(&report("DU1", SecurityType::ForexPair, "0", 1.0, 1.085)),
660            Some(dec!(1.085))
661        );
662        // A derivative's cannot be scaled without one.
663        assert_eq!(
664            entry_price(&report("DU1", SecurityType::Future, "", 1.0, 250_000.0)),
665            None
666        );
667        assert_eq!(
668            entry_price(&report("DU1", SecurityType::Option, "0", 1.0, 235.0)),
669            None
670        );
671    }
672
673    #[test]
674    fn test_entry_price_unset_or_invalid_average_cost_is_none() {
675        assert_eq!(
676            entry_price(&report("DU1", SecurityType::Stock, "", 1.0, 0.0)),
677            None
678        );
679        assert_eq!(
680            entry_price(&report("DU1", SecurityType::Stock, "", 1.0, f64::MAX)),
681            None
682        );
683        assert_eq!(
684            entry_price(&report("DU1", SecurityType::Stock, "", 1.0, f64::NAN)),
685            None
686        );
687    }
688
689    #[test]
690    fn test_position_aggregator_latest_report_per_account_wins() {
691        let mut agg = PositionAggregator::default();
692        agg.process(
693            instrument("AAPL"),
694            report("DU1", SecurityType::Stock, "", 10.0, 150.0),
695        );
696        agg.process(
697            instrument("MSFT"),
698            report("DU1", SecurityType::Stock, "", 3.0, 400.0),
699        );
700        agg.process(
701            instrument("AAPL"),
702            report("DU1", SecurityType::Stock, "", 12.0, 151.0),
703        );
704
705        let positions = agg.into_positions(now()).unwrap();
706        assert_eq!(positions.len(), 2);
707        assert_eq!(positions[0].0, instrument("AAPL"));
708        assert_eq!(positions[0].1.as_ref().unwrap().quantity, dec!(12));
709        assert_eq!(positions[1].0, instrument("MSFT"));
710    }
711
712    #[test]
713    fn test_position_aggregator_closed_position_is_listed_without_position() {
714        let mut agg = PositionAggregator::default();
715        agg.process(
716            instrument("AAPL"),
717            report("DU1", SecurityType::Stock, "", 10.0, 150.0),
718        );
719        agg.process(
720            instrument("AAPL"),
721            report("DU1", SecurityType::Stock, "", 0.0, 0.0),
722        );
723
724        assert_eq!(
725            agg.into_positions(now()).unwrap(),
726            vec![(instrument("AAPL"), None)]
727        );
728    }
729
730    #[test]
731    fn test_position_aggregator_later_flat_account_and_update_keep_latest() {
732        let mut agg = PositionAggregator::default();
733        agg.process(
734            instrument("AAPL"),
735            report("DU2", SecurityType::Stock, "", 7.0, 150.0),
736        );
737        // A flat account after a held one changes nothing.
738        agg.process(
739            instrument("AAPL"),
740            report("DU1", SecurityType::Stock, "", 0.0, 0.0),
741        );
742        // DU2's newer report replaces its earlier one.
743        agg.process(
744            instrument("AAPL"),
745            report("DU2", SecurityType::Stock, "", 9.0, 151.0),
746        );
747
748        let positions = agg.into_positions(now()).unwrap();
749        assert_eq!(positions.len(), 1);
750        assert_eq!(positions[0].1.as_ref().unwrap().quantity, dec!(9));
751    }
752
753    #[test]
754    fn test_position_aggregator_non_finite_quantity_is_internal_error() {
755        let mut agg = PositionAggregator::default();
756        agg.process(
757            instrument("AAPL"),
758            report("DU1", SecurityType::Stock, "", f64::NAN, 150.0),
759        );
760
761        assert!(matches!(
762            agg.into_positions(now()),
763            Err(UnindexedClientError::Internal(_))
764        ));
765    }
766
767    #[test]
768    fn test_position_aggregator_multi_account_keeps_first_non_zero() {
769        let mut agg = PositionAggregator::default();
770        // DU1 is flat, so it must not hide DU2's position.
771        agg.process(
772            instrument("AAPL"),
773            report("DU1", SecurityType::Stock, "", 0.0, 0.0),
774        );
775        agg.process(
776            instrument("AAPL"),
777            report("DU2", SecurityType::Stock, "", 7.0, 150.0),
778        );
779        // A third account's position is dropped, not summed.
780        agg.process(
781            instrument("AAPL"),
782            report("DU3", SecurityType::Stock, "", 4.0, 149.0),
783        );
784
785        let positions = agg.into_positions(now()).unwrap();
786        assert_eq!(positions.len(), 1);
787        assert_eq!(positions[0].1.as_ref().unwrap().quantity, dec!(7));
788    }
789
790    #[test]
791    fn test_balance_aggregator_clear() {
792        let mut agg = BalanceAggregator::new();
793        ledger(&mut agg, Some(("USD", "900")), &[("USD", "1000", "0", "1")]);
794        assert_eq!(agg.to_balances().len(), 1);
795
796        agg.clear();
797        assert!(agg.to_balances().is_empty());
798    }
799
800    #[test]
801    fn reports_are_open_or_flat_and_add_unlisted_instruments_only_after_a_complete_listing() {
802        let requested = [instrument("AAPL"), instrument("MSFT")];
803        let reports = |events: &[&str]| {
804            let mut agg = PositionAggregator::default();
805            for event in events {
806                match *event {
807                    "end" => agg.listing_ended(),
808                    name => {
809                        let quantity = if name == "F" { 0.0 } else { 3.0 };
810                        agg.process(
811                            instrument(name),
812                            report("DU1", SecurityType::Stock, "", quantity, 10.0),
813                        );
814                    }
815                }
816            }
817            agg.into_reports(now(), &requested).unwrap()
818        };
819        let aapl = || {
820            (
821                instrument("AAPL"),
822                PositionReport::from_position(
823                    convert_position(&report("DU1", SecurityType::Stock, "", 3.0, 10.0), now())
824                        .unwrap()
825                        .unwrap(),
826                ),
827            )
828        };
829
830        assert_eq!(
831            reports(&["AAPL", "F", "end"]),
832            vec![
833                aapl(),
834                (instrument("F"), PositionReport::Flat),
835                (instrument("MSFT"), PositionReport::Flat),
836            ]
837        );
838        // No end marker: MSFT's absence establishes nothing.
839        assert_eq!(
840            reports(&["AAPL", "F"]),
841            vec![aapl(), (instrument("F"), PositionReport::Flat)]
842        );
843    }
844}