1use std::{collections::HashMap, sync::Arc};
19
20use anyhow::Context;
21use ibapi::{
22 accounts::{
23 AccountSummary, AccountSummaryResult, AccountSummaryTags,
24 types::{AccountGroup, AccountId as IbAccountId},
25 },
26 client::Client,
27 prelude::{StreamExt, SubscriptionItemStreamExt},
28};
29use nautilus_common::{
30 live::runner::get_exec_event_sender,
31 messages::{ExecutionEvent, ExecutionReport},
32};
33use nautilus_core::time::get_atomic_clock_realtime;
34use nautilus_model::{
35 enums::PositionSideSpecified,
36 identifiers::AccountId,
37 instruments::Instrument,
38 reports::PositionStatusReport,
39 types::{AccountBalance, Currency, MarginBalance, Money, Quantity},
40};
41use rust_decimal::{Decimal, prelude::ToPrimitive};
42
43pub(crate) fn raw_ib_account_code(account_id: &AccountId) -> String {
44 account_id
45 .to_string()
46 .strip_prefix("IB-")
47 .unwrap_or(account_id.as_str())
48 .to_string()
49}
50
51pub async fn subscribe_account_summary(
57 client: &Arc<Client>,
58 account_id: AccountId,
59) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
60 let raw_account_id = raw_ib_account_code(&account_id);
61 let tags = &[
63 AccountSummaryTags::NET_LIQUIDATION,
64 AccountSummaryTags::TOTAL_CASH_VALUE,
65 AccountSummaryTags::SETTLED_CASH,
66 AccountSummaryTags::BUYING_POWER,
67 AccountSummaryTags::EQUITY_WITH_LOAN_VALUE,
68 AccountSummaryTags::AVAILABLE_FUNDS,
69 AccountSummaryTags::EXCESS_LIQUIDITY,
70 AccountSummaryTags::INIT_MARGIN_REQ,
71 AccountSummaryTags::MAINT_MARGIN_REQ,
72 AccountSummaryTags::CUSHION,
73 ];
74
75 let group = AccountGroup("All".to_string());
76 let subscription = client
77 .account_summary(&group, tags)
78 .await
79 .context("Failed to subscribe to account summary")?;
80 let mut subscription = subscription.filter_data();
81
82 tracing::debug!("Subscribed to account summary for account: {}", account_id);
83
84 let mut balances: Vec<AccountBalance> = Vec::new();
88 let mut margins: Vec<MarginBalance> = Vec::new();
89
90 while let Some(result) = subscription.next().await {
91 match result {
92 Ok(AccountSummaryResult::Summary(summary)) => {
93 if summary.account != raw_account_id {
95 continue;
96 }
97
98 match parse_account_summary_to_balance(&summary) {
99 Ok(balance) => {
100 if let Some(existing) = balances
102 .iter_mut()
103 .find(|b| b.total.currency == balance.total.currency)
104 {
105 if let Some(merged) = merge_account_summary_balance(
106 existing,
107 summary.tag.as_str(),
108 &summary.value,
109 &summary.currency,
110 )? {
111 *existing = merged;
112 }
113 } else {
114 balances.push(balance);
115 }
116 }
117 Err(e) => {
118 tracing::warn!("Failed to parse account summary: {}", e);
119 }
120 }
121
122 merge_account_summary_margin(&mut margins, &summary);
127 }
128 Ok(AccountSummaryResult::End) => {
129 break;
130 }
131 Err(e) => {
132 tracing::warn!("Error receiving account summary: {}", e);
133 }
134 }
135 }
136
137 tracing::debug!(
138 "Received account summary: {} balances, {} margins",
139 balances.len(),
140 margins.len()
141 );
142
143 Ok((balances, margins))
144}
145
146fn merge_account_summary_margin(margins: &mut Vec<MarginBalance>, summary: &AccountSummary) {
147 let currency = match parse_currency(&summary.currency) {
148 Ok(currency) => currency,
149 Err(e) => {
150 tracing::warn!("Skipping margin summary with unknown currency: {}", e);
151 return;
152 }
153 };
154 let value = match parse_balance_decimal(&summary.value)
155 .and_then(|d| Money::from_decimal(d, currency).map_err(|e| anyhow::anyhow!(e.to_string())))
156 {
157 Ok(money) => money,
158 Err(e) => {
159 tracing::warn!("Failed to parse margin value '{}': {}", summary.value, e);
160 return;
161 }
162 };
163
164 let existing = margins
165 .iter_mut()
166 .find(|m| m.currency == currency && m.instrument_id.is_none());
167
168 match summary.tag.as_str() {
169 AccountSummaryTags::INIT_MARGIN_REQ => match existing {
170 Some(margin) => margin.initial = value,
171 None => margins.push(MarginBalance::new(value, Money::zero(currency), None)),
172 },
173 AccountSummaryTags::MAINT_MARGIN_REQ => match existing {
174 Some(margin) => margin.maintenance = value,
175 None => margins.push(MarginBalance::new(Money::zero(currency), value, None)),
176 },
177 _ => {}
178 }
179}
180
181fn merge_account_summary_balance(
182 existing: &AccountBalance,
183 tag: &str,
184 value: &str,
185 currency_code: &str,
186) -> anyhow::Result<Option<AccountBalance>> {
187 let currency = parse_currency(currency_code)?;
188
189 match tag {
190 AccountSummaryTags::SETTLED_CASH => {
191 let settled_cash = parse_balance_decimal(value)?;
192 Ok(Some(AccountBalance::from_total_and_locked(
193 settled_cash,
194 Decimal::ZERO,
195 currency,
196 )?))
197 }
198 AccountSummaryTags::NET_LIQUIDATION => {
199 let net_liq = parse_balance_decimal(value)?;
200 Ok(Some(AccountBalance::from_total_and_free(
201 net_liq,
202 existing.free.as_decimal(),
203 currency,
204 )?))
205 }
206 _ => Ok(None),
207 }
208}
209
210pub async fn subscribe_pnl(client: &Arc<Client>, account_id: AccountId) -> anyhow::Result<()> {
218 let account = IbAccountId(raw_ib_account_code(&account_id));
219 let subscription = client
220 .pnl(&account, None)
221 .await
222 .context("Failed to subscribe to PnL")?;
223 let mut subscription = subscription.filter_data();
224
225 tracing::debug!("Subscribed to PnL updates for account: {}", account_id);
226
227 nautilus_common::live::get_runtime().spawn(async move {
229 while let Some(result) = subscription.next().await {
230 match result {
231 Ok(pnl) => {
232 tracing::debug!(
233 "PnL update - Daily: {:.2}, Unrealized: {:?}, Realized: {:?}",
234 pnl.daily_pnl,
235 pnl.unrealized_pnl,
236 pnl.realized_pnl
237 );
238 }
242 Err(e) => {
243 tracing::warn!("Error receiving PnL update: {}", e);
244 }
245 }
246 }
247 });
248
249 Ok(())
250}
251
252pub type PositionTracker = Arc<tokio::sync::Mutex<HashMap<i32, Decimal>>>;
254
255pub fn create_position_tracker() -> PositionTracker {
257 Arc::new(tokio::sync::Mutex::new(HashMap::new()))
258}
259
260pub async fn check_external_position_change(
262 position_tracker: &PositionTracker,
263 contract_id: i32,
264 new_quantity: Decimal,
265) -> Option<(bool, Decimal)> {
266 let mut tracker = position_tracker.lock().await;
267 let known_quantity = tracker.get(&contract_id).copied().unwrap_or(Decimal::ZERO);
268
269 if new_quantity.is_zero() {
270 return (!known_quantity.is_zero()).then_some((true, known_quantity));
271 }
272
273 if known_quantity == new_quantity {
276 return None;
277 }
278
279 tracker.insert(contract_id, new_quantity);
283
284 if known_quantity != Decimal::ZERO && known_quantity != new_quantity {
286 Some((true, known_quantity))
287 } else {
288 Some((false, known_quantity))
290 }
291}
292
293pub async fn initialize_position_tracking(
302 client: &Arc<Client>,
303 account_id: AccountId,
304 position_tracker: PositionTracker,
305) -> anyhow::Result<()> {
306 let raw_account_id = raw_ib_account_code(&account_id);
307 let subscription = client
308 .positions()
309 .await
310 .context("Failed to request positions")?;
311 let mut subscription = subscription.filter_data();
312
313 tracing::debug!("Initializing position tracking for account: {}", account_id);
314
315 let mut position_count = 0;
316 let mut tracker = position_tracker.lock().await;
317
318 while let Some(result) = subscription.next().await {
319 match result {
320 Ok(ibapi::accounts::PositionUpdate::Position(position)) => {
321 if position.account != raw_account_id {
323 continue;
324 }
325
326 let contract_id = position.contract.contract_id;
327 let quantity = Decimal::from_f64_retain(position.position).unwrap_or_default();
328
329 if !quantity.is_zero() {
331 tracker.insert(contract_id, quantity);
332 position_count += 1;
333 }
334 }
335 Ok(ibapi::accounts::PositionUpdate::PositionEnd) => {
336 break;
337 }
338 Err(e) => {
339 tracing::warn!("Error receiving position update: {}", e);
340 }
341 }
342 }
343
344 tracing::debug!(
345 "Initialized tracking for {} existing positions",
346 position_count
347 );
348
349 Ok(())
350}
351
352pub async fn subscribe_positions(
361 client: &Arc<Client>,
362 account_id: AccountId,
363 position_tracker: PositionTracker,
364 instrument_provider: Arc<crate::providers::instruments::InteractiveBrokersInstrumentProvider>,
365) -> anyhow::Result<()> {
366 let raw_account_id = raw_ib_account_code(&account_id);
367 let subscription = client
368 .positions()
369 .await
370 .context("Failed to subscribe to positions")?;
371 let mut subscription = subscription.filter_data();
372
373 tracing::debug!("Subscribed to position updates for account: {}", account_id);
374
375 let exec_sender = get_exec_event_sender();
376 let clock = get_atomic_clock_realtime();
377 let client_for_instruments = Arc::clone(client);
378
379 nautilus_common::live::get_runtime().spawn(async move {
381 while let Some(result) = subscription.next().await {
382 match result {
383 Ok(ibapi::accounts::PositionUpdate::Position(position)) => {
384 if position.account != raw_account_id {
385 continue;
386 }
387
388 let contract_id = position.contract.contract_id;
389 let new_quantity =
390 Decimal::from_f64_retain(position.position).unwrap_or_default();
391
392 if let Some((is_external, old_quantity)) =
394 check_external_position_change(&position_tracker, contract_id, new_quantity)
395 .await
396 && is_external
397 {
398 tracing::warn!(
399 "External position change detected (likely option exercise): \
400 Contract ID {}, quantity change: {} -> {}",
401 contract_id,
402 old_quantity,
403 new_quantity
404 );
405
406 match instrument_provider
407 .get_instrument(&client_for_instruments, &position.contract)
408 .await
409 {
410 Ok(Some(instrument)) => {
411 let instrument_id = instrument.id();
412 let position_side = if new_quantity.is_zero() {
413 PositionSideSpecified::Flat
414 } else if new_quantity > Decimal::ZERO {
415 PositionSideSpecified::Long
416 } else {
417 PositionSideSpecified::Short
418 };
419
420 let quantity = Quantity::new(
421 new_quantity.abs().to_f64().unwrap_or(0.0),
422 instrument.size_precision(),
423 );
424
425 let avg_px_open = if position.average_cost > 0.0 {
426 let price_magnifier =
427 instrument_provider.get_price_magnifier(&instrument_id)
428 as f64;
429 let multiplier = instrument.multiplier().as_f64();
430 let converted_avg_cost =
431 position.average_cost / (multiplier * price_magnifier);
432 let price_precision = instrument.price_precision();
433 Some(
434 Decimal::from_f64_retain(converted_avg_cost)
435 .map(|d| d.round_dp(price_precision as u32))
436 .unwrap_or_default(),
437 )
438 } else {
439 None
440 };
441
442 let ts_init = clock.get_time_ns();
443
444 let report = PositionStatusReport::new(
445 account_id,
446 instrument_id,
447 position_side,
448 quantity,
449 ts_init,
450 ts_init,
451 None,
452 None,
453 avg_px_open,
454 );
455 let event = ExecutionEvent::Report(ExecutionReport::Position(
456 Box::new(report),
457 ));
458
459 if exec_sender.send(event).is_err() {
460 tracing::warn!(
461 "Failed to send position status report for external change"
462 );
463 } else {
464 if new_quantity.is_zero() {
465 position_tracker.lock().await.remove(&contract_id);
466 }
467
468 tracing::info!(
469 "Generated position status report for external change (likely option exercise)"
470 );
471 }
472 }
473 Ok(None) => {
474 tracing::warn!(
475 "Instrument not found for external position contract ID: {}",
476 contract_id
477 );
478 }
479 Err(e) => {
480 tracing::warn!(
481 "Failed to resolve external position contract ID {}: {}",
482 contract_id,
483 e
484 );
485 }
486 }
487 }
488 }
489 Ok(ibapi::accounts::PositionUpdate::PositionEnd) => {
490 break;
491 }
492 Err(e) => {
493 tracing::warn!("Error receiving position update: {}", e);
494 }
495 }
496 }
497 });
498
499 Ok(())
500}
501
502fn parse_account_summary_to_balance(summary: &AccountSummary) -> anyhow::Result<AccountBalance> {
504 let currency = parse_currency(&summary.currency)?;
505 let balance = parse_balance_decimal(&summary.value)?;
506
507 match summary.tag.as_str() {
508 AccountSummaryTags::SETTLED_CASH | AccountSummaryTags::TOTAL_CASH_VALUE => {
509 AccountBalance::from_total_and_locked(balance, Decimal::ZERO, currency)
511 .map_err(Into::into)
512 }
513 AccountSummaryTags::NET_LIQUIDATION => {
514 AccountBalance::from_total_and_locked(balance, Decimal::ZERO, currency)
517 .map_err(Into::into)
518 }
519 AccountSummaryTags::BUYING_POWER | AccountSummaryTags::AVAILABLE_FUNDS => {
520 AccountBalance::from_total_and_free(balance, balance, currency).map_err(Into::into)
522 }
523 _ => {
524 AccountBalance::from_total_and_locked(balance, Decimal::ZERO, currency)
526 .map_err(Into::into)
527 }
528 }
529}
530
531fn parse_balance_decimal(value: &str) -> anyhow::Result<Decimal> {
532 value
533 .parse::<Decimal>()
534 .context(format!("Failed to parse balance value: {}", value))
535}
536
537fn parse_currency(currency: &str) -> anyhow::Result<Currency> {
538 anyhow::ensure!(!currency.is_empty(), "Account summary currency was empty");
539 Ok(Currency::from(currency))
540}
541
542#[cfg(test)]
543mod tests {
544 use ibapi::accounts::AccountSummary;
545 use nautilus_model::types::{AccountBalance, Currency, MarginBalance, Money};
546 use rstest::rstest;
547 use rust_decimal::Decimal;
548
549 use super::{
550 AccountSummaryTags, check_external_position_change, create_position_tracker,
551 merge_account_summary_balance, merge_account_summary_margin, parse_currency,
552 };
553
554 fn margin_summary(tag: &str, value: &str, currency: &str) -> AccountSummary {
555 AccountSummary {
556 account: "DU123".to_string(),
557 tag: tag.to_string(),
558 value: value.to_string(),
559 currency: currency.to_string(),
560 }
561 }
562
563 #[rstest]
566 fn test_ib_avg_cost_to_price_conversion() {
567 let avg_cost = 100.0;
568 let multiplier = 10.0;
569 let price_magnifier = 2.0;
570 let converted = avg_cost / (multiplier * price_magnifier);
571 assert_eq!(converted, 5.0);
572
573 let avg_cost2 = 1_500_000.0;
574 let multiplier2 = 50.0;
575 let price_magnifier2 = 10;
576 let converted2 = avg_cost2 / (multiplier2 * (price_magnifier2 as f64));
577 assert_eq!(converted2, 3000.0);
578 }
579
580 #[rstest]
581 fn test_parse_currency_rejects_empty_string() {
582 let result = parse_currency("");
583 assert!(result.is_err());
584 assert_eq!(
585 result.unwrap_err().to_string(),
586 "Account summary currency was empty",
587 );
588 }
589
590 #[rstest]
591 #[tokio::test]
592 async fn test_external_position_change_reports_tracked_zero_close() {
593 let tracker = create_position_tracker();
594 tracker.lock().await.insert(42, Decimal::new(5, 0));
595
596 let change = check_external_position_change(&tracker, 42, Decimal::ZERO).await;
597
598 assert_eq!(change, Some((true, Decimal::new(5, 0))));
599 assert_eq!(
600 tracker.lock().await.get(&42).copied(),
601 Some(Decimal::new(5, 0))
602 );
603 }
604
605 #[rstest]
606 fn test_net_liquidation_merge_clamps_free_to_total() {
607 let existing = AccountBalance::from_total_and_free(
608 "120.00".parse().unwrap(),
609 "120.00".parse().unwrap(),
610 Currency::USD(),
611 )
612 .unwrap();
613
614 let merged = merge_account_summary_balance(
615 &existing,
616 AccountSummaryTags::NET_LIQUIDATION,
617 "100.00",
618 "USD",
619 )
620 .unwrap()
621 .unwrap();
622
623 assert_eq!(merged.total.as_decimal(), "100.00".parse().unwrap());
624 assert_eq!(merged.locked.as_decimal(), "0.00".parse().unwrap());
625 assert_eq!(merged.free.as_decimal(), "100.00".parse().unwrap());
626 }
627
628 #[rstest]
629 fn test_merge_account_summary_margin_combines_init_and_maint() {
630 let mut margins: Vec<MarginBalance> = Vec::new();
635
636 merge_account_summary_margin(
637 &mut margins,
638 &margin_summary(AccountSummaryTags::INIT_MARGIN_REQ, "500.00", "USD"),
639 );
640 merge_account_summary_margin(
641 &mut margins,
642 &margin_summary(AccountSummaryTags::MAINT_MARGIN_REQ, "250.00", "USD"),
643 );
644
645 assert_eq!(margins.len(), 1);
646 let margin = &margins[0];
647 assert!(margin.instrument_id.is_none());
648 assert_eq!(margin.currency, Currency::USD());
649 assert_eq!(margin.initial, Money::from("500.00 USD"));
650 assert_eq!(margin.maintenance, Money::from("250.00 USD"));
651 }
652
653 #[rstest]
654 fn test_merge_account_summary_margin_order_independent() {
655 let mut margins: Vec<MarginBalance> = Vec::new();
657
658 merge_account_summary_margin(
659 &mut margins,
660 &margin_summary(AccountSummaryTags::MAINT_MARGIN_REQ, "250.00", "USD"),
661 );
662 merge_account_summary_margin(
663 &mut margins,
664 &margin_summary(AccountSummaryTags::INIT_MARGIN_REQ, "500.00", "USD"),
665 );
666
667 assert_eq!(margins.len(), 1);
668 let margin = &margins[0];
669 assert_eq!(margin.initial, Money::from("500.00 USD"));
670 assert_eq!(margin.maintenance, Money::from("250.00 USD"));
671 }
672
673 #[rstest]
674 fn test_merge_account_summary_margin_separates_currencies() {
675 let mut margins: Vec<MarginBalance> = Vec::new();
676
677 merge_account_summary_margin(
678 &mut margins,
679 &margin_summary(AccountSummaryTags::INIT_MARGIN_REQ, "500.00", "USD"),
680 );
681 merge_account_summary_margin(
682 &mut margins,
683 &margin_summary(AccountSummaryTags::INIT_MARGIN_REQ, "400.00", "EUR"),
684 );
685
686 assert_eq!(margins.len(), 2);
687 let usd = margins
688 .iter()
689 .find(|m| m.currency == Currency::USD())
690 .unwrap();
691 let eur = margins
692 .iter()
693 .find(|m| m.currency == Currency::EUR())
694 .unwrap();
695 assert_eq!(usd.initial, Money::from("500.00 USD"));
696 assert_eq!(eur.initial, Money::from("400.00 EUR"));
697 }
698}