1use std::{collections::HashMap, sync::Arc, time::Duration};
19
20use anyhow::Context;
21use futures_util::Stream;
22use ibapi::{
23 accounts::{
24 AccountSummary, AccountSummaryResult, AccountSummaryTags, AccountUpdate, AccountValue,
25 types::{AccountGroup, AccountId as IbAccountId},
26 },
27 client::Client,
28 contracts::Contract,
29 orders::ExecutionSide,
30 prelude::{StreamExt, SubscriptionItemStreamExt},
31 subscriptions::{FilterDataStream, Subscription},
32};
33use indexmap::IndexMap;
34use nautilus_common::{
35 cache::fifo::FifoCache,
36 live::runner::get_exec_event_sender,
37 messages::{ExecutionEvent, ExecutionReport},
38};
39use nautilus_core::{Params, time::get_atomic_clock_realtime};
40use nautilus_live::task::TaskGroup;
41use nautilus_model::{
42 enums::PositionSide,
43 identifiers::AccountId,
44 instruments::Instrument,
45 reports::PositionStatusReport,
46 types::{AccountBalance, Currency, MarginBalance, Money, Quantity},
47};
48use rust_decimal::{Decimal, prelude::ToPrimitive};
49use ustr::Ustr;
50
51use super::reports::IbReportContext;
52
53pub(crate) fn ib_account_code(configured: Option<&str>, account_id: AccountId) -> Ustr {
60 match configured {
61 Some(code) if !code.contains('-') => Ustr::from(code),
62 _ => Ustr::from(
63 account_id
64 .as_str()
65 .rsplit_once('-')
66 .map_or(account_id.as_str(), |(_, code)| code),
67 ),
68 }
69}
70
71const ACCOUNT_SUMMARY_QUIET: Duration = Duration::from_secs(1);
73
74pub type AccountSnapshot = (Vec<AccountBalance>, Vec<MarginBalance>, Option<Params>);
75
76type AccountSummaryStream = FilterDataStream<Subscription<AccountSummaryResult>>;
77
78#[derive(Default)]
79struct AccountSummaryAccumulator {
80 balances: Vec<AccountSummaryBalance>,
81 margins: Vec<MarginBalance>,
82 info: IndexMap<String, serde_json::Value>,
83}
84
85pub async fn subscribe_account_summary(
94 client: &Arc<Client>,
95 ib_account: Ustr,
96) -> anyhow::Result<AccountSnapshot> {
97 let mut subscription = open_account_summary(client, ib_account).await?;
98 let mut accumulator = AccountSummaryAccumulator::default();
99 read_account_summary_cycle(&mut subscription, ib_account, &mut accumulator, None).await;
100 account_snapshot(client, ib_account, &accumulator).await
101}
102
103pub async fn stream_account_summary<F>(
113 client: &Arc<Client>,
114 ib_account: Ustr,
115 session_tasks: &TaskGroup,
116 on_snapshot: F,
117) -> anyhow::Result<()>
118where
119 F: Fn(AccountSnapshot) + Send + 'static,
120{
121 let mut subscription = open_account_summary(client, ib_account).await?;
122 let mut accumulator = AccountSummaryAccumulator::default();
123 read_account_summary_cycle(&mut subscription, ib_account, &mut accumulator, None).await;
124 on_snapshot(account_snapshot(client, ib_account, &accumulator).await?);
125
126 let client = Arc::clone(client);
127
128 let future = async move {
129 while read_account_summary_cycle(
130 &mut subscription,
131 ib_account,
132 &mut accumulator,
133 Some(ACCOUNT_SUMMARY_QUIET),
134 )
135 .await
136 {
137 match account_snapshot(&client, ib_account, &accumulator).await {
138 Ok(snapshot) => on_snapshot(snapshot),
139 Err(e) => tracing::warn!("Failed to build account snapshot: {}", e),
140 }
141 }
142
143 tracing::warn!(
144 "IB account summary stream ended for account: {}",
145 ib_account
146 );
147 };
148
149 session_tasks
150 .spawn(future)
151 .context("Failed to register IB account summary task")?;
152
153 Ok(())
154}
155
156async fn open_account_summary(
157 client: &Arc<Client>,
158 ib_account: Ustr,
159) -> anyhow::Result<AccountSummaryStream> {
160 let tags = &[
162 AccountSummaryTags::NET_LIQUIDATION,
163 AccountSummaryTags::TOTAL_CASH_VALUE,
164 AccountSummaryTags::SETTLED_CASH,
165 AccountSummaryTags::BUYING_POWER,
166 AccountSummaryTags::EQUITY_WITH_LOAN_VALUE,
167 AccountSummaryTags::AVAILABLE_FUNDS,
168 AccountSummaryTags::EXCESS_LIQUIDITY,
169 AccountSummaryTags::INIT_MARGIN_REQ,
170 AccountSummaryTags::MAINT_MARGIN_REQ,
171 AccountSummaryTags::CUSHION,
172 ];
173
174 let group = AccountGroup("All".to_string());
175 let subscription = client
176 .account_summary(&group, tags)
177 .await
178 .context("Failed to subscribe to account summary")?;
179
180 tracing::debug!("Subscribed to account summary for account: {}", ib_account);
181
182 Ok(subscription.filter_data())
183}
184
185async fn read_account_summary_cycle<S>(
191 subscription: &mut S,
192 ib_account: Ustr,
193 accumulator: &mut AccountSummaryAccumulator,
194 quiet: Option<Duration>,
195) -> bool
196where
197 S: Stream<Item = Result<AccountSummaryResult, ibapi::Error>> + Unpin,
198{
199 let raw_account_id = ib_account.as_str();
200 let mut received_rows = false;
201
202 loop {
203 let next = match quiet {
204 Some(quiet) if received_rows => {
205 match tokio::time::timeout(quiet, subscription.next()).await {
206 Ok(next) => next,
207 Err(_) => return true,
208 }
209 }
210 _ => subscription.next().await,
211 };
212
213 match next {
214 Some(Ok(AccountSummaryResult::Summary(summary))) => {
215 if summary.account != raw_account_id {
217 continue;
218 }
219
220 received_rows = true;
221
222 accumulator.info.insert(
226 summary.tag.clone(),
227 serde_json::Value::from(summary.value.as_str()),
228 );
229
230 if let Err(e) = merge_account_summary_balance(&mut accumulator.balances, &summary) {
231 tracing::warn!("Failed to parse account summary: {}", e);
232 }
233
234 merge_account_summary_margin(&mut accumulator.margins, &summary);
239 }
240 Some(Ok(AccountSummaryResult::End)) => return true,
241 Some(Err(e)) => {
242 tracing::warn!("Error receiving account summary: {}", e);
243 }
244 None => return false,
245 }
246 }
247}
248
249async fn account_snapshot(
250 client: &Arc<Client>,
251 ib_account: Ustr,
252 accumulator: &AccountSummaryAccumulator,
253) -> anyhow::Result<AccountSnapshot> {
254 let mut info = accumulator.info.clone();
255
256 if let Err(e) = merge_account_updates_snapshot(client, ib_account.as_str(), &mut info).await {
257 tracing::warn!("Failed to collect account updates: {}", e);
258 }
259
260 let balances = finalize_account_summary_balances(accumulator.balances.clone())?;
261 let mut margins = accumulator.margins.clone();
262 margins.sort_by(|a, b| a.currency.code.as_str().cmp(b.currency.code.as_str()));
263
264 tracing::debug!(
265 "Received account summary: {} balances, {} margins",
266 balances.len(),
267 margins.len()
268 );
269
270 Ok((
271 balances,
272 margins,
273 if info.is_empty() {
274 None
275 } else {
276 Some(Params::new(info))
277 },
278 ))
279}
280
281async fn merge_account_updates_snapshot(
284 client: &Arc<Client>,
285 raw_account_id: &str,
286 info: &mut IndexMap<String, serde_json::Value>,
287) -> anyhow::Result<()> {
288 let account = IbAccountId(raw_account_id.to_string());
289 let subscription = client
290 .account_updates(&account)
291 .await
292 .context("Failed to subscribe to account updates")?;
293 let mut subscription = subscription.filter_data();
294
295 while let Some(result) = subscription.next().await {
296 match result {
297 Ok(AccountUpdate::AccountValue(value)) => {
298 merge_account_value(info, raw_account_id, &value);
299 }
300 Ok(AccountUpdate::End) => break,
301 Ok(AccountUpdate::PortfolioValue(_) | AccountUpdate::UpdateTime(_)) => {}
302 Err(e) => {
303 tracing::warn!("Error receiving account updates: {}", e);
304 }
305 }
306 }
307
308 Ok(())
309}
310
311fn merge_account_value(
312 info: &mut IndexMap<String, serde_json::Value>,
313 raw_account_id: &str,
314 value: &AccountValue,
315) {
316 if value
317 .account
318 .as_deref()
319 .is_some_and(|account| account != raw_account_id)
320 {
321 return;
322 }
323
324 info.insert(
325 value.key.clone(),
326 serde_json::Value::from(value.value.as_str()),
327 );
328}
329
330fn merge_account_summary_margin(margins: &mut Vec<MarginBalance>, summary: &AccountSummary) {
331 let relevant = matches!(
332 summary.tag.as_str(),
333 AccountSummaryTags::INIT_MARGIN_REQ | AccountSummaryTags::MAINT_MARGIN_REQ
334 );
335
336 if !relevant {
337 return;
338 }
339
340 let currency = match parse_currency(&summary.currency) {
341 Ok(currency) => currency,
342 Err(e) => {
343 tracing::warn!("Skipping margin summary with unknown currency: {}", e);
344 return;
345 }
346 };
347
348 let value = match parse_balance_decimal(&summary.value)
349 .and_then(|d| Money::from_decimal(d, currency).map_err(|e| anyhow::anyhow!(e.to_string())))
350 {
351 Ok(money) => money,
352 Err(e) => {
353 tracing::warn!("Failed to parse margin value '{}': {}", summary.value, e);
354 return;
355 }
356 };
357
358 let existing = margins
359 .iter_mut()
360 .find(|m| m.currency == currency && m.instrument_id.is_none());
361
362 match summary.tag.as_str() {
363 AccountSummaryTags::INIT_MARGIN_REQ => match existing {
364 Some(margin) => margin.initial = value,
365 None => margins.push(MarginBalance::new(value, Money::zero(currency), None)),
366 },
367 AccountSummaryTags::MAINT_MARGIN_REQ => match existing {
368 Some(margin) => margin.maintenance = value,
369 None => margins.push(MarginBalance::new(Money::zero(currency), value, None)),
370 },
371 _ => unreachable!("relevant account summary tag was checked above"),
372 }
373}
374
375#[derive(Clone)]
376struct AccountSummaryBalance {
377 currency: Currency,
378 net_liquidation: Option<Decimal>,
379 settled_cash: Option<Decimal>,
380 total_cash_value: Option<Decimal>,
381 available_funds: Option<Decimal>,
382 buying_power: Option<Decimal>,
383}
384
385impl AccountSummaryBalance {
386 fn new(currency: Currency) -> Self {
387 Self {
388 currency,
389 net_liquidation: None,
390 settled_cash: None,
391 total_cash_value: None,
392 available_funds: None,
393 buying_power: None,
394 }
395 }
396
397 fn into_balance(self) -> anyhow::Result<Option<AccountBalance>> {
398 let Some(total) = self
399 .net_liquidation
400 .or(self.settled_cash)
401 .or(self.total_cash_value)
402 .or(self.available_funds)
403 .or(self.buying_power)
404 else {
405 return Ok(None);
406 };
407
408 let free = self
409 .available_funds
410 .or(self.buying_power)
411 .or(self.settled_cash)
412 .or(self.total_cash_value)
413 .unwrap_or(total);
414
415 Ok(Some(AccountBalance::from_total_and_free(
416 total,
417 free,
418 self.currency,
419 )?))
420 }
421}
422
423fn merge_account_summary_balance(
424 balances: &mut Vec<AccountSummaryBalance>,
425 summary: &AccountSummary,
426) -> anyhow::Result<()> {
427 let relevant = matches!(
428 summary.tag.as_str(),
429 AccountSummaryTags::NET_LIQUIDATION
430 | AccountSummaryTags::SETTLED_CASH
431 | AccountSummaryTags::TOTAL_CASH_VALUE
432 | AccountSummaryTags::AVAILABLE_FUNDS
433 | AccountSummaryTags::BUYING_POWER
434 );
435
436 if !relevant {
437 return Ok(());
438 }
439
440 let currency = parse_currency(&summary.currency)?;
441 let value = parse_balance_decimal(&summary.value)?;
442
443 let balance = match balances.iter_mut().find(|b| b.currency == currency) {
444 Some(balance) => balance,
445 None => {
446 balances.push(AccountSummaryBalance::new(currency));
447 balances.last_mut().expect("balance was just inserted")
448 }
449 };
450
451 match summary.tag.as_str() {
452 AccountSummaryTags::NET_LIQUIDATION => balance.net_liquidation = Some(value),
453 AccountSummaryTags::SETTLED_CASH => balance.settled_cash = Some(value),
454 AccountSummaryTags::TOTAL_CASH_VALUE => balance.total_cash_value = Some(value),
455 AccountSummaryTags::AVAILABLE_FUNDS => balance.available_funds = Some(value),
456 AccountSummaryTags::BUYING_POWER => balance.buying_power = Some(value),
457 _ => unreachable!("relevant account summary tag was checked above"),
458 }
459
460 Ok(())
461}
462
463fn finalize_account_summary_balances(
464 summaries: Vec<AccountSummaryBalance>,
465) -> anyhow::Result<Vec<AccountBalance>> {
466 let mut balances = summaries
467 .into_iter()
468 .map(AccountSummaryBalance::into_balance)
469 .collect::<anyhow::Result<Vec<_>>>()?
470 .into_iter()
471 .flatten()
472 .collect::<Vec<_>>();
473 balances.sort_by(|a, b| a.currency.code.as_str().cmp(b.currency.code.as_str()));
474 Ok(balances)
475}
476
477pub async fn subscribe_pnl(
485 client: &Arc<Client>,
486 ib_account: Ustr,
487 session_tasks: &TaskGroup,
488) -> anyhow::Result<()> {
489 let account = IbAccountId(ib_account.to_string());
490 let subscription = client
491 .pnl(&account, None)
492 .await
493 .context("Failed to subscribe to PnL")?;
494 let mut subscription = subscription.filter_data();
495
496 tracing::debug!("Subscribed to PnL updates for account: {}", ib_account);
497
498 let future = async move {
500 while let Some(result) = subscription.next().await {
501 match result {
502 Ok(pnl) => {
503 tracing::debug!(
504 "PnL update - Daily: {:.2}, Unrealized: {:?}, Realized: {:?}",
505 pnl.daily_pnl,
506 pnl.unrealized_pnl,
507 pnl.realized_pnl
508 );
509 }
513 Err(e) => {
514 tracing::warn!("Error receiving PnL update: {}", e);
515 }
516 }
517 }
518 };
519
520 session_tasks
521 .spawn(future)
522 .context("Failed to register IB PnL task")?;
523
524 Ok(())
525}
526
527#[derive(Debug)]
528pub struct PositionTrackerState {
529 positions: HashMap<i32, Decimal>,
530 own_fill_ids: FifoCache<String, 10_000>,
531}
532
533pub type PositionTracker = Arc<tokio::sync::Mutex<PositionTrackerState>>;
535
536pub fn create_position_tracker() -> PositionTracker {
538 Arc::new(tokio::sync::Mutex::new(PositionTrackerState {
539 positions: HashMap::new(),
540 own_fill_ids: FifoCache::new(),
541 }))
542}
543
544pub async fn record_own_fill(
545 position_tracker: &PositionTracker,
546 execution_id: &str,
547 contract_id: i32,
548 side: ExecutionSide,
549 quantity: f64,
550) -> anyhow::Result<bool> {
551 let quantity = Decimal::from_f64_retain(quantity)
552 .context("Failed to convert own fill quantity to Decimal")?;
553
554 let signed_quantity = match side {
555 ExecutionSide::Bought => quantity,
556 ExecutionSide::Sold => -quantity,
557 };
558
559 let mut tracker = position_tracker.lock().await;
560 if !tracker.own_fill_ids.insert(execution_id.to_string()) {
561 return Ok(false);
562 }
563
564 let position = tracker
565 .positions
566 .entry(contract_id)
567 .or_insert(Decimal::ZERO);
568 *position += signed_quantity;
569 if position.is_zero() {
570 tracker.positions.remove(&contract_id);
571 }
572
573 Ok(true)
574}
575
576pub async fn check_external_position_change(
578 position_tracker: &PositionTracker,
579 contract_id: i32,
580 new_quantity: Decimal,
581) -> Option<(bool, Decimal)> {
582 let mut tracker = position_tracker.lock().await;
583 let known_quantity = tracker
584 .positions
585 .get(&contract_id)
586 .copied()
587 .unwrap_or(Decimal::ZERO);
588
589 if new_quantity.is_zero() {
590 return (!known_quantity.is_zero()).then_some((true, known_quantity));
591 }
592
593 if known_quantity == new_quantity {
596 return None;
597 }
598
599 tracker.positions.insert(contract_id, new_quantity);
603
604 if known_quantity != Decimal::ZERO && known_quantity != new_quantity {
606 Some((true, known_quantity))
607 } else {
608 Some((false, known_quantity))
610 }
611}
612
613pub async fn initialize_position_tracking(
623 client: &Arc<Client>,
624 ib_account: Ustr,
625 position_tracker: PositionTracker,
626) -> anyhow::Result<Vec<Contract>> {
627 let subscription = client
628 .positions()
629 .await
630 .context("Failed to request positions")?;
631 let mut subscription = subscription.filter_data();
632
633 tracing::debug!("Initializing position tracking for account: {}", ib_account);
634
635 let mut contracts = Vec::new();
636 let mut tracker = position_tracker.lock().await;
637
638 while let Some(result) = subscription.next().await {
639 match result {
640 Ok(ibapi::accounts::PositionUpdate::Position(position)) => {
641 if position.account != ib_account.as_str() {
643 continue;
644 }
645
646 let contract_id = position.contract.contract_id;
647 let quantity = Decimal::from_f64_retain(position.position).unwrap_or_default();
648
649 if !quantity.is_zero() {
651 tracker.positions.insert(contract_id, quantity);
652 contracts.push(position.contract);
653 }
654 }
655 Ok(ibapi::accounts::PositionUpdate::PositionEnd) => {
656 break;
657 }
658 Err(e) => {
659 tracing::warn!("Error receiving position update: {}", e);
660 }
661 }
662 }
663
664 tracing::debug!(
665 "Initialized tracking for {} existing positions",
666 contracts.len()
667 );
668
669 Ok(contracts)
670}
671
672pub async fn subscribe_positions(
681 client: &Arc<Client>,
682 account_id: AccountId,
683 ib_account: Ustr,
684 position_tracker: PositionTracker,
685 instrument_provider: Arc<crate::providers::instruments::InteractiveBrokersInstrumentProvider>,
686 session_tasks: &TaskGroup,
687) -> anyhow::Result<()> {
688 let subscription = client
689 .positions()
690 .await
691 .context("Failed to subscribe to positions")?;
692 let mut subscription = subscription.filter_data();
693
694 tracing::debug!("Subscribed to position updates for account: {}", ib_account);
695
696 let exec_sender = get_exec_event_sender();
697 let clock = get_atomic_clock_realtime();
698 let client_for_instruments = Arc::clone(client);
699
700 let future = async move {
702 while let Some(result) = subscription.next().await {
703 match result {
704 Ok(ibapi::accounts::PositionUpdate::Position(position)) => {
705 if position.account != ib_account.as_str() {
706 continue;
707 }
708
709 let contract_id = position.contract.contract_id;
710 let new_quantity =
711 Decimal::from_f64_retain(position.position).unwrap_or_default();
712
713 if let Some((is_external, old_quantity)) =
715 check_external_position_change(&position_tracker, contract_id, new_quantity)
716 .await
717 && is_external
718 {
719 tracing::warn!(
720 "External position change detected (likely option exercise): \
721 Contract ID {}, quantity change: {} -> {}",
722 contract_id,
723 old_quantity,
724 new_quantity
725 );
726
727 match instrument_provider
728 .get_instrument(&client_for_instruments, &position.contract)
729 .await
730 {
731 Ok(Some(instrument)) => {
732 let instrument_id = instrument.id();
733
734 let position_side = if new_quantity.is_zero() {
735 PositionSide::Flat
736 } else if new_quantity > Decimal::ZERO {
737 PositionSide::Long
738 } else {
739 PositionSide::Short
740 };
741
742 let quantity = Quantity::new(
743 new_quantity.abs().to_f64().unwrap_or(0.0),
744 instrument.size_precision(),
745 );
746
747 let avg_px_open = IbReportContext::position_avg_px_open(
748 &instrument,
749 position.average_cost,
750 );
751
752 let ts_init = clock.get_time_ns();
753
754 let report = PositionStatusReport::new(
755 account_id,
756 instrument_id,
757 position_side,
758 quantity,
759 ts_init,
760 ts_init,
761 None,
762 None,
763 avg_px_open,
764 );
765 let event = ExecutionEvent::Report(ExecutionReport::Position(
766 Box::new(report),
767 ));
768
769 if exec_sender.send(event).is_err() {
770 tracing::warn!(
771 "Failed to send position status report for external change"
772 );
773 } else {
774 if new_quantity.is_zero() {
775 position_tracker
776 .lock()
777 .await
778 .positions
779 .remove(&contract_id);
780 }
781
782 tracing::info!(
783 "Generated position status report for external change (likely option exercise)"
784 );
785 }
786 }
787 Ok(None) => {
788 tracing::warn!(
789 "Instrument not found for external position contract ID: {}",
790 contract_id
791 );
792 }
793 Err(e) => {
794 tracing::warn!(
795 "Failed to resolve external position contract ID {}: {}",
796 contract_id,
797 e
798 );
799 }
800 }
801 }
802 }
803 Ok(ibapi::accounts::PositionUpdate::PositionEnd) => {
804 tracing::debug!("Received end of initial IB position snapshot");
805 }
806 Err(e) => {
807 tracing::warn!("Error receiving position update: {}", e);
808 }
809 }
810 }
811 };
812
813 session_tasks
814 .spawn(future)
815 .context("Failed to register IB position task")?;
816
817 Ok(())
818}
819
820fn parse_balance_decimal(value: &str) -> anyhow::Result<Decimal> {
821 value
822 .parse::<Decimal>()
823 .context(format!("Failed to parse balance value: {value}"))
824}
825
826fn parse_currency(currency: &str) -> anyhow::Result<Currency> {
827 anyhow::ensure!(!currency.is_empty(), "Account summary currency was empty");
828 Ok(Currency::from(currency))
829}
830
831#[cfg(test)]
832mod tests {
833 use std::time::Duration;
834
835 use futures_util::{StreamExt, stream};
836 use ibapi::{
837 accounts::{AccountSummary, AccountSummaryResult, AccountValue},
838 orders::ExecutionSide,
839 };
840 use indexmap::IndexMap;
841 use nautilus_core::Params;
842 use nautilus_model::{
843 identifiers::AccountId,
844 types::{AccountBalance, Currency, MarginBalance, Money},
845 };
846 use rstest::rstest;
847 use rust_decimal::Decimal;
848
849 use super::{
850 AccountSummaryAccumulator, AccountSummaryTags, check_external_position_change,
851 create_position_tracker, finalize_account_summary_balances, ib_account_code,
852 merge_account_summary_balance, merge_account_summary_margin, merge_account_value,
853 parse_currency, read_account_summary_cycle, record_own_fill,
854 };
855
856 fn margin_summary(tag: &str, value: &str, currency: &str) -> AccountSummary {
857 AccountSummary {
858 account: "DU123".to_string(),
859 tag: tag.to_string(),
860 value: value.to_string(),
861 currency: currency.to_string(),
862 }
863 }
864
865 fn account_value(account: Option<&str>, key: &str, value: &str) -> AccountValue {
866 AccountValue {
867 key: key.to_string(),
868 value: value.to_string(),
869 currency: "USD".to_string(),
870 account: account.map(str::to_string),
871 }
872 }
873
874 fn balances_from_summaries(summaries: &[AccountSummary]) -> Vec<AccountBalance> {
875 let mut balances = Vec::new();
876 for summary in summaries {
877 merge_account_summary_balance(&mut balances, summary).unwrap();
878 }
879
880 finalize_account_summary_balances(balances).unwrap()
881 }
882
883 #[rstest]
884 #[case(Some("U7654321"), "IB-TEST-U7654321", "U7654321")]
885 #[case(Some("IB_LIVE-U1234567"), "IB_LIVE-U1234567", "U1234567")]
886 #[case(Some("IB-TEST-U7654321"), "IB-TEST-U7654321", "U7654321")]
887 #[case(None, "IB-TEST-001", "001")]
888 #[case(None, "IB-001", "001")]
889 fn test_ib_account_code_prefers_configured_bare_code(
890 #[case] configured: Option<&str>,
891 #[case] account_id: &str,
892 #[case] expected: &str,
893 ) {
894 let code = ib_account_code(configured, AccountId::from(account_id));
895
896 assert_eq!(code.as_str(), expected);
897 }
898
899 #[rstest]
900 fn test_parse_currency_rejects_empty_string() {
901 let result = parse_currency("");
902 assert!(result.is_err());
903 assert_eq!(
904 result.unwrap_err().to_string(),
905 "Account summary currency was empty",
906 );
907 }
908
909 #[rstest]
910 #[tokio::test]
911 async fn test_external_position_change_reports_tracked_zero_close() {
912 let tracker = create_position_tracker();
913 tracker
914 .lock()
915 .await
916 .positions
917 .insert(42, Decimal::new(5, 0));
918
919 let change = check_external_position_change(&tracker, 42, Decimal::ZERO).await;
920
921 assert_eq!(change, Some((true, Decimal::new(5, 0))));
922 assert_eq!(
923 tracker.lock().await.positions.get(&42).copied(),
924 Some(Decimal::new(5, 0))
925 );
926 }
927
928 #[rstest]
929 #[tokio::test]
930 async fn test_own_fill_position_change_is_not_external() {
931 let tracker = create_position_tracker();
932 tracker
933 .lock()
934 .await
935 .positions
936 .insert(42, Decimal::new(5, 0));
937
938 assert!(
939 record_own_fill(&tracker, "EXEC-1", 42, ExecutionSide::Bought, 2.0)
940 .await
941 .unwrap()
942 );
943 let change = check_external_position_change(&tracker, 42, Decimal::new(7, 0)).await;
944
945 assert_eq!(change, None);
946 assert_eq!(
947 tracker.lock().await.positions.get(&42).copied(),
948 Some(Decimal::new(7, 0))
949 );
950 }
951
952 #[rstest]
953 #[tokio::test]
954 async fn test_replayed_own_fill_does_not_advance_position_twice() {
955 let tracker = create_position_tracker();
956
957 assert!(
958 record_own_fill(&tracker, "EXEC-1", 42, ExecutionSide::Sold, 2.0)
959 .await
960 .unwrap()
961 );
962 assert!(
963 !record_own_fill(&tracker, "EXEC-1", 42, ExecutionSide::Sold, 2.0)
964 .await
965 .unwrap()
966 );
967
968 assert_eq!(
969 tracker.lock().await.positions.get(&42).copied(),
970 Some(Decimal::new(-2, 0))
971 );
972 }
973
974 #[rstest]
975 fn test_net_liquidation_merge_clamps_free_to_total() {
976 let merged = balances_from_summaries(&[
977 margin_summary(AccountSummaryTags::AVAILABLE_FUNDS, "120.00", "USD"),
978 margin_summary(AccountSummaryTags::NET_LIQUIDATION, "100.00", "USD"),
979 ])
980 .remove(0);
981
982 assert_eq!(merged.total.as_decimal(), "100.00".parse().unwrap());
983 assert_eq!(merged.locked.as_decimal(), "0.00".parse().unwrap());
984 assert_eq!(merged.free.as_decimal(), "100.00".parse().unwrap());
985 }
986
987 fn net_liquidation_summary(value: &str) -> AccountSummaryResult {
988 AccountSummaryResult::Summary(margin_summary("NetLiquidation", value, "USD"))
989 }
990
991 #[rstest]
992 #[tokio::test]
993 async fn test_read_account_summary_cycle_refreshes_on_each_end() {
994 let mut subscription = stream::iter([
995 Ok(net_liquidation_summary("1000.50")),
996 Ok(AccountSummaryResult::End),
997 Ok(net_liquidation_summary("1100.25")),
998 Ok(AccountSummaryResult::End),
999 ]);
1000 let ib_account = ustr::Ustr::from("DU123");
1001 let mut accumulator = AccountSummaryAccumulator::default();
1002
1003 let first =
1004 read_account_summary_cycle(&mut subscription, ib_account, &mut accumulator, None).await;
1005 let first_balances =
1006 finalize_account_summary_balances(accumulator.balances.clone()).unwrap();
1007 let second =
1008 read_account_summary_cycle(&mut subscription, ib_account, &mut accumulator, None).await;
1009 let second_balances =
1010 finalize_account_summary_balances(accumulator.balances.clone()).unwrap();
1011 let closed =
1012 read_account_summary_cycle(&mut subscription, ib_account, &mut accumulator, None).await;
1013
1014 assert!(first);
1015 assert_eq!(
1016 first_balances,
1017 vec![
1018 AccountBalance::from_total_and_free(
1019 Decimal::new(100050, 2),
1020 Decimal::new(100050, 2),
1021 Currency::USD()
1022 )
1023 .unwrap()
1024 ]
1025 );
1026 assert!(second);
1027 assert_eq!(
1028 second_balances,
1029 vec![
1030 AccountBalance::from_total_and_free(
1031 Decimal::new(110025, 2),
1032 Decimal::new(110025, 2),
1033 Currency::USD()
1034 )
1035 .unwrap()
1036 ]
1037 );
1038 assert!(!closed);
1039 }
1040
1041 #[rstest]
1042 #[tokio::test(start_paused = true)]
1043 async fn test_read_account_summary_cycle_flushes_refresh_without_end() {
1044 let mut subscription = stream::iter([
1045 Ok(net_liquidation_summary("1000.50")),
1046 Ok(net_liquidation_summary("1100.25")),
1047 ])
1048 .chain(stream::pending());
1049 let ib_account = ustr::Ustr::from("DU123");
1050 let mut accumulator = AccountSummaryAccumulator::default();
1051
1052 let flushed = read_account_summary_cycle(
1053 &mut subscription,
1054 ib_account,
1055 &mut accumulator,
1056 Some(Duration::from_secs(1)),
1057 )
1058 .await;
1059 let balances = finalize_account_summary_balances(accumulator.balances.clone()).unwrap();
1060
1061 assert!(flushed);
1062 assert_eq!(
1063 balances,
1064 vec![
1065 AccountBalance::from_total_and_free(
1066 Decimal::new(110025, 2),
1067 Decimal::new(110025, 2),
1068 Currency::USD()
1069 )
1070 .unwrap()
1071 ]
1072 );
1073 }
1074
1075 #[rstest]
1076 fn test_account_summary_balance_merge_is_order_independent() {
1077 let summaries = [
1078 margin_summary(AccountSummaryTags::TOTAL_CASH_VALUE, "80.00", "USD"),
1079 margin_summary(AccountSummaryTags::SETTLED_CASH, "75.00", "USD"),
1080 margin_summary(AccountSummaryTags::AVAILABLE_FUNDS, "60.00", "USD"),
1081 margin_summary(AccountSummaryTags::BUYING_POWER, "120.00", "USD"),
1082 margin_summary(AccountSummaryTags::NET_LIQUIDATION, "100.00", "USD"),
1083 ];
1084 let reversed = [
1085 margin_summary(AccountSummaryTags::NET_LIQUIDATION, "100.00", "USD"),
1086 margin_summary(AccountSummaryTags::BUYING_POWER, "120.00", "USD"),
1087 margin_summary(AccountSummaryTags::AVAILABLE_FUNDS, "60.00", "USD"),
1088 margin_summary(AccountSummaryTags::SETTLED_CASH, "75.00", "USD"),
1089 margin_summary(AccountSummaryTags::TOTAL_CASH_VALUE, "80.00", "USD"),
1090 ];
1091
1092 let forward = balances_from_summaries(&summaries);
1093 let reverse = balances_from_summaries(&reversed);
1094
1095 assert_eq!(forward, reverse);
1096 assert_eq!(forward.len(), 1);
1097 assert_eq!(forward[0].total, Money::from("100.00 USD"));
1098 assert_eq!(forward[0].locked, Money::from("40.00 USD"));
1099 assert_eq!(forward[0].free, Money::from("60.00 USD"));
1100 }
1101
1102 #[rstest]
1103 fn test_account_summary_rows_without_currency_are_ignored() {
1104 let cushion = margin_summary(AccountSummaryTags::CUSHION, "0.95", "");
1107 let mut margins: Vec<MarginBalance> = Vec::new();
1108
1109 merge_account_summary_margin(&mut margins, &cushion);
1110 let balances = balances_from_summaries(&[
1111 cushion,
1112 margin_summary(AccountSummaryTags::NET_LIQUIDATION, "1000.00", "EUR"),
1113 ]);
1114
1115 assert!(margins.is_empty());
1116 assert_eq!(
1117 balances,
1118 vec![
1119 AccountBalance::from_total_and_locked(
1120 Decimal::new(100_000, 2),
1121 Decimal::ZERO,
1122 Currency::EUR(),
1123 )
1124 .unwrap()
1125 ]
1126 );
1127 }
1128
1129 #[rstest]
1130 fn test_merge_account_summary_margin_combines_init_and_maint() {
1131 let mut margins: Vec<MarginBalance> = Vec::new();
1136
1137 merge_account_summary_margin(
1138 &mut margins,
1139 &margin_summary(AccountSummaryTags::INIT_MARGIN_REQ, "500.00", "USD"),
1140 );
1141 merge_account_summary_margin(
1142 &mut margins,
1143 &margin_summary(AccountSummaryTags::MAINT_MARGIN_REQ, "250.00", "USD"),
1144 );
1145
1146 assert_eq!(margins.len(), 1);
1147 let margin = &margins[0];
1148 assert!(margin.instrument_id.is_none());
1149 assert_eq!(margin.currency, Currency::USD());
1150 assert_eq!(margin.initial, Money::from("500.00 USD"));
1151 assert_eq!(margin.maintenance, Money::from("250.00 USD"));
1152 }
1153
1154 #[rstest]
1155 fn test_merge_account_summary_margin_order_independent() {
1156 let mut margins: Vec<MarginBalance> = Vec::new();
1158
1159 merge_account_summary_margin(
1160 &mut margins,
1161 &margin_summary(AccountSummaryTags::MAINT_MARGIN_REQ, "250.00", "USD"),
1162 );
1163 merge_account_summary_margin(
1164 &mut margins,
1165 &margin_summary(AccountSummaryTags::INIT_MARGIN_REQ, "500.00", "USD"),
1166 );
1167
1168 assert_eq!(margins.len(), 1);
1169 let margin = &margins[0];
1170 assert_eq!(margin.initial, Money::from("500.00 USD"));
1171 assert_eq!(margin.maintenance, Money::from("250.00 USD"));
1172 }
1173
1174 #[rstest]
1175 fn test_merge_account_summary_margin_separates_currencies() {
1176 let mut margins: Vec<MarginBalance> = Vec::new();
1177
1178 merge_account_summary_margin(
1179 &mut margins,
1180 &margin_summary(AccountSummaryTags::INIT_MARGIN_REQ, "500.00", "USD"),
1181 );
1182 merge_account_summary_margin(
1183 &mut margins,
1184 &margin_summary(AccountSummaryTags::INIT_MARGIN_REQ, "400.00", "EUR"),
1185 );
1186
1187 assert_eq!(margins.len(), 2);
1188 let usd = margins
1189 .iter()
1190 .find(|m| m.currency == Currency::USD())
1191 .unwrap();
1192 let eur = margins
1193 .iter()
1194 .find(|m| m.currency == Currency::EUR())
1195 .unwrap();
1196 assert_eq!(usd.initial, Money::from("500.00 USD"));
1197 assert_eq!(eur.initial, Money::from("400.00 EUR"));
1198 }
1199
1200 #[rstest]
1201 #[case(Some("DU123"), true)]
1202 #[case(None, true)]
1203 #[case(Some("DU999"), false)]
1204 fn test_merge_account_value_filters_by_account(
1205 #[case] account: Option<&str>,
1206 #[case] expected_inserted: bool,
1207 ) {
1208 let mut info = IndexMap::new();
1209
1210 merge_account_value(
1211 &mut info,
1212 "DU123",
1213 &account_value(account, "PostExpirationExcess", "-326492.00"),
1214 );
1215
1216 let info = Params::new(info);
1217 assert_eq!(
1218 info.get_str("PostExpirationExcess"),
1219 expected_inserted.then_some("-326492.00"),
1220 );
1221 }
1222
1223 #[rstest]
1224 fn test_merge_account_value_keeps_summary_tags_and_overwrites_same_key() {
1225 let mut info = IndexMap::from([(
1226 AccountSummaryTags::TOTAL_CASH_VALUE.to_string(),
1227 serde_json::Value::from("1000.00"),
1228 )]);
1229
1230 merge_account_value(
1231 &mut info,
1232 "DU123",
1233 &account_value(Some("DU123"), "PostExpirationMargin", "391668.36"),
1234 );
1235 merge_account_value(
1236 &mut info,
1237 "DU123",
1238 &account_value(
1239 Some("DU123"),
1240 AccountSummaryTags::TOTAL_CASH_VALUE,
1241 "1250.50",
1242 ),
1243 );
1244
1245 let info = Params::new(info);
1246 assert_eq!(info.len(), 2);
1247 assert_eq!(info.get_str("TotalCashValue"), Some("1250.50"));
1248 assert_eq!(info.get_str("PostExpirationMargin"), Some("391668.36"));
1249 }
1250}