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