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