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
//! Activity FLEX parser

use crate::error::{ParseError, Result};
use crate::types::activity::FlexQueryResponse;
use crate::types::ActivityFlexStatement;

/// Parse an Activity FLEX XML statement, returning the **first** statement only.
///
/// # ⚠️ Multi-statement exports
///
/// A FLEX query over a date range (a backfill) returns one `<FlexStatement>` **per
/// day/period**. This function returns only the *first* and silently discards the
/// rest. For backfills — or any time the export may contain more than one statement
/// (check [`ActivityFlexStatement::period`]) — use [`parse_activity_flex_all`]
/// instead, or you will analyze a single period and miss the remaining history.
///
/// # Arguments
///
/// * `xml` - XML string from IB Activity FLEX query
///
/// # Returns
///
/// * `Ok(ActivityFlexStatement)` - The first parsed statement
/// * `Err(ParseError)` - Parse error with context
///
/// # Errors
///
/// Returns `ParseError` if XML is malformed, required fields are missing,
/// or date/decimal formats are invalid.
pub fn parse_activity_flex(xml: &str) -> Result<ActivityFlexStatement> {
    // Parse XML using quick-xml with serde
    let response: FlexQueryResponse =
        quick_xml::de::from_str(xml).map_err(|e| ParseError::XmlError {
            message: format!("Failed to parse FLEX XML: {}", e),
            location: None,
        })?;

    // Extract the first statement
    let statement = response
        .statements
        .statements
        .into_iter()
        .next()
        .ok_or_else(|| ParseError::MissingField {
            field: "FlexStatement".to_string(),
            context: "FlexQueryResponse".to_string(),
        })?;

    Ok(statement)
}

/// Parse all Activity FLEX statements from XML
///
/// FLEX queries can contain multiple statements (e.g., for different days
/// in a date range backfill). This function returns all of them.
///
/// # Arguments
///
/// * `xml` - XML string from IB Activity FLEX query
///
/// # Returns
///
/// * `Ok(Vec<ActivityFlexStatement>)` - All parsed statements
/// * `Err(ParseError)` - Parse error with context
///
/// # Errors
///
/// Returns `ParseError` if XML is malformed, required fields are missing,
/// or date/decimal formats are invalid.
pub fn parse_activity_flex_all(xml: &str) -> Result<Vec<ActivityFlexStatement>> {
    // Parse XML using quick-xml with serde
    let response: FlexQueryResponse =
        quick_xml::de::from_str(xml).map_err(|e| ParseError::XmlError {
            message: format!("Failed to parse FLEX XML: {}", e),
            location: None,
        })?;

    Ok(response.statements.statements)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_minimal_activity() {
        let xml = include_str!("../../tests/fixtures/activity_minimal.xml");
        let result = parse_activity_flex(xml);

        match &result {
            Ok(statement) => {
                assert_eq!(statement.account_id.as_deref(), Some("U1234567"));
                let trades = &statement.trades.items;
                assert!(!trades.is_empty());
                assert_eq!(trades[0].symbol.as_deref(), Some("AAPL"));
            }
            Err(e) => {
                panic!("Parse failed: {}", e);
            }
        }
    }
}