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