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