Skip to main content

nautilus_interactive_brokers/execution/
account.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Account management for Interactive Brokers execution client.
17
18use 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
51/// Subscribe to account summary and parse to balances and margins.
52///
53/// # Errors
54///
55/// Returns an error if subscription fails.
56pub 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    // Request key account summary tags (includes TotalCashValue to match Python account summary info dict).
62    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    // Process initial account summary snapshot
85    // We collect all summary items until the API sends AccountSummaryResult::End, so the
86    // returned balances/margins are complete (matches Python behavior of waiting for all tags).
87    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                // Filter for the specific account
94                if summary.account != raw_account_id {
95                    continue;
96                }
97
98                match parse_account_summary_to_balance(&summary) {
99                    Ok(balance) => {
100                        // Check if balance already exists for this currency
101                        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                // Accumulate margin requirements by currency. IB reports INIT_MARGIN_REQ
123                // and MAINT_MARGIN_REQ as separate summary entries; merge them into one
124                // `MarginBalance` per currency so neither half overwrites the other when
125                // the account-wide margin store keys by `Currency`.
126                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
210/// Subscribe to PnL updates for the account.
211///
212/// This spawns a background task to handle PnL updates.
213///
214/// # Errors
215///
216/// Returns an error if subscription fails.
217pub 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    // Process PnL updates in background task
228    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                    // Note: Account state updates are handled by position updates and account summary
239                    // PnL is informational and tracked separately. If needed, account state can be
240                    // generated by subscribing to account summary which includes updated balances.
241                }
242                Err(e) => {
243                    tracing::warn!("Error receiving PnL update: {}", e);
244                }
245            }
246        }
247    });
248
249    Ok(())
250}
251
252/// Track known positions for detecting external changes (e.g., option exercises).
253pub type PositionTracker = Arc<tokio::sync::Mutex<HashMap<i32, Decimal>>>;
254
255/// Create a new position tracker.
256pub fn create_position_tracker() -> PositionTracker {
257    Arc::new(tokio::sync::Mutex::new(HashMap::new()))
258}
259
260/// Check if a position update represents an external change (e.g., option exercise).
261pub 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    // Check if this is an external position change
274    // If quantities match, this is likely from normal trading - not external
275    if known_quantity == new_quantity {
276        return None;
277    }
278
279    // This is a change - determine if it's external
280    // External changes occur when position changes without a corresponding execution
281    // Update tracked position
282    tracker.insert(contract_id, new_quantity);
283
284    // If we had a known position and it changed, it's likely external
285    if known_quantity != Decimal::ZERO && known_quantity != new_quantity {
286        Some((true, known_quantity))
287    } else {
288        // New position or first time seeing it
289        Some((false, known_quantity))
290    }
291}
292
293/// Initialize position tracking with existing positions.
294///
295/// This fetches all current positions and initializes the position tracker
296/// to avoid processing duplicates from execDetails.
297///
298/// # Errors
299///
300/// Returns an error if position request fails.
301pub 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                // Filter for the specific account
322                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                // Only track non-zero positions
330                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
352/// Subscribe to real-time position updates for detecting external position changes (e.g., option exercises).
353///
354/// This spawns a background task to track position changes and generate position status reports
355/// for external changes.
356///
357/// # Errors
358///
359/// Returns an error if subscription fails.
360pub 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    // Spawn background task to handle position updates
380    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                    // Check if this is an external position change
393                    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
502/// Parse IB account summary to Nautilus AccountBalance.
503fn 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            // Cash balance - free equals total for settled cash
510            AccountBalance::from_total_and_locked(balance, Decimal::ZERO, currency)
511                .map_err(Into::into)
512        }
513        AccountSummaryTags::NET_LIQUIDATION => {
514            // Net liquidation - represents total equity
515            // Free would be calculated from available funds
516            AccountBalance::from_total_and_locked(balance, Decimal::ZERO, currency)
517                .map_err(Into::into)
518        }
519        AccountSummaryTags::BUYING_POWER | AccountSummaryTags::AVAILABLE_FUNDS => {
520            // Available funds - this is the free amount
521            AccountBalance::from_total_and_free(balance, balance, currency).map_err(Into::into)
522        }
523        _ => {
524            // Default: treat as total balance
525            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    /// Verifies the IB avg cost to Nautilus price conversion formula used in position parsing.
564    /// Python: converted_avg_cost = avg_cost / (multiplier * price_magnifier)
565    #[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        // Regression: `INIT_MARGIN_REQ` and `MAINT_MARGIN_REQ` arrive as separate
631        // summary entries. The merge must land in a single `MarginBalance` per
632        // currency so neither half overwrites the other once the account-wide
633        // store keys by `Currency`.
634        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        // Arrival order should not matter.
656        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}