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
20pub(crate) const BALANCE_SUMMARY_TAGS: &[&str] = &[
23 AccountSummaryTags::AVAILABLE_FUNDS,
24 AccountSummaryTags::LEDGER_ALL,
25];
26
27const LEDGER_BASE: &str = "BASE";
30
31#[derive(Debug, Default)]
48pub struct BalanceAggregator {
49 ledger: FnvHashMap<(SmolStr, SmolStr), LedgerCurrency>,
51 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 exchange_rate: Option<Decimal>,
62}
63
64impl BalanceAggregator {
65 pub fn new() -> Self {
66 Self::default()
67 }
68
69 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 pub fn to_balances(&self) -> Vec<AssetBalance<AssetNameExchange>> {
99 let now = Utc::now();
100 let mut balances = BTreeMap::<&str, Balance>::new();
101 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 pub fn clear(&mut self) {
153 self.ledger.clear();
154 self.available_funds.clear();
155 }
156}
157
158fn parse_summary_value(summary: &ibapi::accounts::AccountSummary) -> Option<Decimal> {
160 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#[derive(Debug, Default)]
177pub(crate) struct PositionAggregator {
178 reports: Vec<(InstrumentNameExchange, ibapi::accounts::Position)>,
180 index: FnvHashMap<(String, InstrumentNameExchange), usize>,
181 listed_all: bool,
184}
185
186impl PositionAggregator {
187 pub(crate) fn listing_ended(&mut self) {
189 self.listed_all = true;
190 }
191
192 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 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 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 (_, 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
295fn 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
327fn 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)] mod 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 let option = report("DU1", SecurityType::Option, "100", -2.0, 235.0);
645 assert_eq!(entry_price(&option), Some(dec!(2.35)));
646 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 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 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 agg.process(
739 instrument("AAPL"),
740 report("DU1", SecurityType::Stock, "", 0.0, 0.0),
741 );
742 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 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 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 assert_eq!(
840 reports(&["AAPL", "F"]),
841 vec![aapl(), (instrument("F"), PositionReport::Flat)]
842 );
843 }
844}