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
//! Tests against real-world-shaped FLEX fixtures adapted from the MIT-licensed
//! `robcohen/ibflex2` project (anonymized). These exercise edge cases that the
//! original synthetic fixtures did not: legacy comma date/time separators,
//! stock-grant activity, and crypto OTC transfers with sentinel values.

use ib_flex::parse_activity_flex;
use ib_flex::types::common::{CashTransactionType, LevelOfDetail};
use ib_flex::types::TransferType;
use rust_decimal::Decimal;
use std::str::FromStr;

#[test]
fn wild_legacy_comma_separator() {
    // Older statements separate date and time with ", " instead of ";".
    let xml = include_str!("fixtures/wild_legacy_separator.xml");
    let statement = parse_activity_flex(xml).expect("legacy-separator statement should parse");

    // whenGenerated is kept raw (comma separator and all), not dropped.
    assert!(statement
        .when_generated
        .as_deref()
        .unwrap_or("")
        .contains("2017-02-01"));

    let cash = &statement.cash_transactions.items;
    assert_eq!(cash.len(), 1);
    assert_eq!(cash[0].amount, Some(Decimal::from_str("5000.00").unwrap()));
    assert_eq!(cash[0].description.as_deref(), Some("LEGACY DEPOSIT"));
}

#[test]
fn wild_stock_grant_activity() {
    // StockGrantActivity was previously parsed-and-dropped (IgnoredSection).
    let xml = include_str!("fixtures/wild_stock_grant.xml");
    let statement = parse_activity_flex(xml).expect("stock-grant statement should parse");

    let grants = &statement.stock_grant_activities.items;
    assert_eq!(grants.len(), 1, "stock grant activity should be captured");
    let g = &grants[0];
    assert_eq!(g.symbol.as_deref(), Some("IBKR"));
    assert_eq!(g.vesting_date, date_opt("2026-06-12"));
    assert_eq!(g.quantity, Some(Decimal::from_str("2.6454").unwrap()));
    assert_eq!(g.value, Some(Decimal::from_str("541.01").unwrap()));
}

#[test]
fn wild_crypto_otc_transfer() {
    // Crypto OTC transfer: new TransferType::Otc, CRYPTO asset class, and a
    // "--" sentinel in the `company` field (must become None, not error).
    let xml = include_str!("fixtures/wild_crypto_transfer.xml");
    let statement = parse_activity_flex(xml).expect("crypto-transfer statement should parse");

    let transfers = &statement.transfers.items;
    assert_eq!(transfers.len(), 1);
    let t = &transfers[0];
    assert_eq!(t.transfer_type, Some(TransferType::OTC));
    assert_eq!(t.symbol.as_deref(), Some("BTC.USD-PAXOS"));
    // `company="--"` is a null sentinel and should deserialize to None.
    assert_eq!(t.company, None);
}

// Small helper to keep date assertions readable.
fn date_opt(s: &str) -> Option<chrono::NaiveDate> {
    Some(chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap())
}

#[test]
fn wild_lending_economics_and_analytics_fields() {
    let xml = include_str!("fixtures/wild_lending_economics.xml");
    let statement = parse_activity_flex(xml).expect("lending-economics statement should parse");

    // Multi-statement detector populates.
    assert_eq!(statement.period.as_deref(), Some("LastNCalendarDays"));

    // --- Securities-lending economics (the headline fix) ---
    let fees = &statement.slb_fees.items;
    assert_eq!(fees.len(), 1);
    let f = &fees[0];
    assert_eq!(f.slb_type.as_deref(), Some("ManagedLoan"));
    assert_eq!(f.gross_lend_fee, Some(Decimal::from_str("0.09").unwrap()));
    assert_eq!(f.net_lend_fee, Some(Decimal::from_str("0.05").unwrap()));
    assert_eq!(f.market_fee_rate, Some(Decimal::from_str("2.8").unwrap()));
    assert_eq!(f.net_lend_fee_rate, Some(Decimal::from_str("1.4").unwrap()));
    assert_eq!(f.figi.as_deref(), Some("BBG01F781N99"));
    assert_eq!(f.isin.as_deref(), Some("CA44877L1013"));
    assert_eq!(f.unique_id.as_deref(), Some("20250819109514782"));
    // `fee` is 0 for ManagedLoan; lending_income() must return the net lend fee instead.
    assert_eq!(f.fee, Some(Decimal::ZERO));
    assert_eq!(f.lending_income(), Some(Decimal::from_str("0.05").unwrap()));

    // --- LevelOfDetail resolves real SCREAMING_SNAKE values (no longer Unknown) ---
    assert_eq!(
        statement.trades.items[0].level_of_detail,
        Some(LevelOfDetail::Execution)
    );
    assert_eq!(
        statement.trades.lots[0].level_of_detail,
        Some(LevelOfDetail::ClosedLot)
    );
    assert_eq!(
        statement.trades.wash_sales[0].level_of_detail,
        Some(LevelOfDetail::WashSale)
    );
    assert_eq!(
        statement.trades.orders[0].level_of_detail,
        Some(LevelOfDetail::Order)
    );

    // --- Typed cash classification (incl. the WithholdingTax rename fix) ---
    let cash = &statement.cash_transactions.items;
    let kinds: Vec<_> = cash.iter().map(|c| c.transaction_type_kind()).collect();
    assert!(kinds.contains(&Some(CashTransactionType::Dividends)));
    assert!(kinds.contains(&Some(CashTransactionType::WithholdingTax)));
    assert!(kinds.contains(&Some(CashTransactionType::PaymentInLieuOfDividends)));
    // Raw string is preserved (lossless).
    assert!(cash
        .iter()
        .any(|c| c.transaction_type.as_deref() == Some("Withholding Tax")));

    // --- Dividend-lifecycle join key + report date ---
    let acc = &statement.open_dividend_accruals.items[0];
    assert_eq!(acc.action_id.as_deref(), Some("155097294"));
    assert_eq!(acc.report_date, date_opt("2025-01-22"));
    assert_eq!(acc.issuer_country_code.as_deref(), Some("US"));
    // Same actionID links the accrual to the realized dividend cash transaction.
    assert!(cash
        .iter()
        .any(|c| c.action_id.as_deref() == acc.action_id.as_deref()));

    // --- Hard-to-borrow identifiers ---
    let htb = &statement.hard_to_borrow_details.items[0];
    assert_eq!(htb.figi.as_deref(), Some("BBG01F781N99"));
    assert_eq!(htb.issuer_country_code.as_deref(), Some("CA"));

    // --- Container rename: ClientFeesDetail (singular) is captured via alias ---
    assert_eq!(statement.client_fees_detail.items.len(), 1);

    // --- Newly modeled elements are wired and reachable ---
    assert_eq!(statement.transaction_taxes.details.len(), 1);
    assert_eq!(
        statement.transaction_taxes.details[0].tax_amount,
        Some(Decimal::from_str("-5.00").unwrap())
    );
    assert_eq!(statement.transfers.lots.len(), 1);
    assert_eq!(
        statement.transfers.lots[0].transfer_price,
        Some(Decimal::from_str("300.00").unwrap())
    );
}