ib-flex 0.2.3

Type-safe parser for Interactive Brokers (IBKR) FLEX XML statements: trades, positions, cash flows, corporate actions, and P&L.
Documentation
//! Smoke test against a real (private) IB FLEX statement.
//!
//! This test is **skipped unless** the `IB_FLEX_REAL_XML` environment variable
//! points at a real Activity FLEX XML file. Real statements contain account
//! data and are never committed, so CI simply skips this test. To run it
//! locally against your own data:
//!
//! ```sh
//! IB_FLEX_REAL_XML=tmp/backfill-to-2026-01-13.xml cargo test --test real_data_smoke -- --nocapture
//! ```
//!
//! It verifies the parser handles a large, real, multi-statement document
//! without error and that the attributes fixed/added in the coverage work
//! actually populate from real output (not just synthetic fixtures).

use ib_flex::parse_activity_flex_all;
use ib_flex::types::common::{CashTransactionType, LevelOfDetail};
use rust_decimal::Decimal;

#[test]
fn real_statement_parses_and_populates() {
    let Ok(path) = std::env::var("IB_FLEX_REAL_XML") else {
        eprintln!("skipping: set IB_FLEX_REAL_XML to a real Activity FLEX file to run");
        return;
    };
    let xml = std::fs::read_to_string(&path).expect("read real XML file");

    let statements = parse_activity_flex_all(&xml)
        .unwrap_or_else(|e| panic!("real statement failed to parse: {e}"));
    assert!(
        !statements.is_empty(),
        "expected at least one FlexStatement"
    );

    // Tallies across every statement.
    let mut trades = 0usize;
    let mut lots = 0usize;
    let mut orders = 0usize;
    let mut symbol_summaries = 0usize;
    let mut trades_with_price = 0usize;
    let mut trades_with_ib_order_id = 0usize;
    let mut trades_with_net_cash_in_base = 0usize;
    let mut positions = 0usize;
    let mut conversion_rates = 0usize;
    let mut corporate_actions = 0usize;

    for s in &statements {
        trades += s.trades.items.len();
        lots += s.trades.lots.len();
        orders += s.trades.orders.len();
        symbol_summaries += s.trades.symbol_summaries.len();
        positions += s.positions.items.len();
        conversion_rates += s.conversion_rates.items.len();
        corporate_actions += s.corporate_actions.items.len();
        for t in &s.trades.items {
            if t.price.is_some() {
                trades_with_price += 1;
            }
            if t.ib_order_id.is_some() {
                trades_with_ib_order_id += 1;
            }
            if t.net_cash_in_base.is_some() {
                trades_with_net_cash_in_base += 1;
            }
        }
    }

    eprintln!(
        "real-data smoke: {} statements | {trades} trades ({trades_with_price} w/price, \
         {trades_with_ib_order_id} w/ibOrderID, {trades_with_net_cash_in_base} w/netCashInBase) | \
         {lots} lots | {orders} orders | {symbol_summaries} symbolSummaries | \
         {positions} positions | {conversion_rates} convRates | {corporate_actions} corpActions",
        statements.len()
    );

    // The whole point of the coverage work: real trades carry a price, and the
    // ibOrderID / netCashInBase rename+additions actually populate on real data.
    if trades > 0 {
        assert!(
            trades_with_price * 100 >= trades * 90,
            "expected >=90% of trades to carry a tradePrice, got {trades_with_price}/{trades}"
        );
        assert!(
            trades_with_ib_order_id > 0,
            "expected ibOrderID to populate on at least some real trades"
        );
    }
}

/// Validates the analytics-coverage additions: securities-lending income,
/// dividend-lifecycle keys, typed cash classification, and the level-of-detail
/// enum. Skipped unless `IB_FLEX_REAL_XML` is set.
#[test]
fn real_lending_and_income_fields_populate() {
    let Ok(path) = std::env::var("IB_FLEX_REAL_XML") else {
        eprintln!("skipping: set IB_FLEX_REAL_XML to a real Activity FLEX file to run");
        return;
    };
    let xml = std::fs::read_to_string(&path).expect("read real XML file");
    let statements = parse_activity_flex_all(&xml).expect("parse");

    let mut slb_fee_rows = 0usize;
    let mut sum_net_lend_fee = Decimal::ZERO;
    let mut sum_gross_lend_fee = Decimal::ZERO;
    let mut sum_lending_income = Decimal::ZERO;
    let mut managed_loans = 0usize;

    let mut lod_resolved = 0usize; // real level-of-detail values now map to a real variant
    let mut lod_unknown = 0usize;

    let mut pil_count = 0usize; // payment-in-lieu, classified via the typed accessor
    let mut withholding_count = 0usize;

    let mut div_accruals = 0usize;
    let mut div_accruals_with_action_id = 0usize;

    for s in &statements {
        for f in &s.slb_fees.items {
            slb_fee_rows += 1;
            sum_net_lend_fee += f.net_lend_fee.unwrap_or_default();
            sum_gross_lend_fee += f.gross_lend_fee.unwrap_or_default();
            sum_lending_income += f.lending_income().unwrap_or_default();
            if f.slb_type.as_deref() == Some("ManagedLoan") {
                managed_loans += 1;
            }
        }
        for t in &s.trades.items {
            match t.level_of_detail {
                Some(LevelOfDetail::Unknown) => lod_unknown += 1,
                Some(_) => lod_resolved += 1,
                None => {}
            }
        }
        for c in &s.cash_transactions.items {
            match c.transaction_type_kind() {
                Some(CashTransactionType::PaymentInLieuOfDividends) => pil_count += 1,
                Some(CashTransactionType::WithholdingTax) => withholding_count += 1,
                _ => {}
            }
        }
        for d in &s.open_dividend_accruals.items {
            div_accruals += 1;
            if d.action_id.is_some() {
                div_accruals_with_action_id += 1;
            }
        }
    }

    eprintln!(
        "lending/income smoke: {slb_fee_rows} SLBFee rows ({managed_loans} ManagedLoan) | \
         Σnet_lend_fee={sum_net_lend_fee} Σgross_lend_fee={sum_gross_lend_fee} \
         Σlending_income={sum_lending_income} | LOD resolved={lod_resolved} unknown={lod_unknown} | \
         PIL={pil_count} withholding={withholding_count} | \
         divAccruals={div_accruals} w/actionID={div_accruals_with_action_id}"
    );

    // The headline fix: managed-loan lending income is no longer dropped.
    if slb_fee_rows > 0 {
        assert!(
            sum_net_lend_fee != Decimal::ZERO,
            "expected non-zero Σnet_lend_fee once lending economics are captured"
        );
        assert!(
            sum_lending_income != Decimal::ZERO,
            "SLBFee::lending_income() should surface managed-loan income"
        );
    }
    // The LevelOfDetail enum must resolve real SCREAMING_SNAKE values, not bucket
    // everything into Unknown.
    if lod_resolved + lod_unknown > 0 {
        assert!(
            lod_resolved > lod_unknown,
            "expected most trade level_of_detail values to resolve, got \
             {lod_resolved} resolved vs {lod_unknown} unknown"
        );
    }
    // Dividend accruals must carry the actionID join key.
    if div_accruals > 0 {
        assert!(
            div_accruals_with_action_id > 0,
            "expected OpenDividendAccrual.action_id to populate on real data"
        );
    }
}