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
//! XML parsing utilities and custom deserializers

use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Deserializer};

/// Values Interactive Brokers uses to mean "no value" in numeric, string, and
/// boolean attributes. Mirrors ibflex's `make_optional` sentinel handling so
/// real statements that emit `-`, `--`, or `N/A` do not hard-fail parsing.
fn is_null_sentinel(s: &str) -> bool {
    matches!(s.trim(), "" | "-" | "--" | "N/A" | "n/a")
}

/// Parse a date string in either YYYY-MM-DD or YYYYMMDD format.
///
/// Some attributes IB documents as dates actually carry a time component
/// (e.g. `exDate`/`availableForTradingDate` arrive as `yyyyMMdd;HHmmss`). We
/// strip anything after the first date/time separator so date fields stay
/// robust to that variation.
fn parse_flex_date(s: &str) -> Result<NaiveDate, chrono::ParseError> {
    let date_part = s.split([';', ' ', 'T', ',']).next().unwrap_or(s).trim();
    // Try ISO format first (YYYY-MM-DD)
    if let Ok(date) = NaiveDate::parse_from_str(date_part, "%Y-%m-%d") {
        return Ok(date);
    }
    // Try compact format (YYYYMMDD)
    NaiveDate::parse_from_str(date_part, "%Y%m%d")
}

/// Deserialize a NaiveDate from either YYYY-MM-DD or YYYYMMDD format
pub fn deserialize_flex_date<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
where
    D: Deserializer<'de>,
{
    let s = String::deserialize(deserializer)?;
    parse_flex_date(&s).map_err(serde::de::Error::custom)
}

/// Deserialize an optional Decimal, treating empty strings as None
pub fn deserialize_optional_decimal<'de, D>(deserializer: D) -> Result<Option<Decimal>, D::Error>
where
    D: Deserializer<'de>,
{
    let s = Option::<String>::deserialize(deserializer)?;
    match s.as_deref() {
        None => Ok(None),
        Some(s) if is_null_sentinel(s) => Ok(None),
        Some(s) => {
            // IB sometimes emits thousands separators in numeric attributes
            // (e.g. tradeMoney="2,345,678.99"); strip them before parsing,
            // mirroring ibflex's decimal conversion.
            let cleaned = s.trim().replace(',', "");
            cleaned
                .parse::<Decimal>()
                .map(Some)
                .map_err(serde::de::Error::custom)
        }
    }
}

/// Deserialize an optional NaiveDate, treating empty strings as None
/// Supports both YYYY-MM-DD and YYYYMMDD formats
pub fn deserialize_optional_date<'de, D>(deserializer: D) -> Result<Option<NaiveDate>, D::Error>
where
    D: Deserializer<'de>,
{
    let s = Option::<String>::deserialize(deserializer)?;
    match s.as_deref() {
        None => Ok(None),
        Some(s) if is_null_sentinel(s) => Ok(None),
        // IBKR emits `MULTI` for date attributes on aggregate summary rows when
        // a single value would be misleading. A live example was a self-closing
        // `SymbolSummary` sibling inside `<Trades>` with
        // `settleDateTarget="MULTI"` for USD.TWD, meaning the aggregate spans
        // multiple settlement dates and has no single settlement date itself.
        // The detailed rows are sibling Trade/Lot records, not child elements,
        // so optional date fields should treat this sentinel as absent.
        Some(s) if s.eq_ignore_ascii_case("MULTI") => Ok(None),
        Some(s) => parse_flex_date(s)
            .map(Some)
            .map_err(serde::de::Error::custom),
    }
}

/// Deserialize an optional string, treating empty strings as None
pub fn deserialize_optional_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
    D: Deserializer<'de>,
{
    let s = Option::<String>::deserialize(deserializer)?;
    match s.as_deref() {
        None => Ok(None),
        Some(s) if is_null_sentinel(s) => Ok(None),
        Some(_) => Ok(s),
    }
}

/// Deserialize an optional boolean from IB's Y/N format
///
/// Interactive Brokers uses "Y" for true and "N" for false in XML attributes.
/// Empty strings or missing attributes are treated as None.
pub fn deserialize_optional_bool<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
where
    D: Deserializer<'de>,
{
    let s = Option::<String>::deserialize(deserializer)?;
    match s.as_deref() {
        None => Ok(None),
        Some(s) if is_null_sentinel(s) => Ok(None),
        // IB uses "Y"/"N"; some reports use "Yes"/"No". Accept both.
        Some(v) if v.eq_ignore_ascii_case("y") || v.eq_ignore_ascii_case("yes") => Ok(Some(true)),
        Some(v) if v.eq_ignore_ascii_case("n") || v.eq_ignore_ascii_case("no") => Ok(Some(false)),
        Some(other) => Err(serde::de::Error::custom(format!(
            "Invalid boolean value '{}', expected 'Y'/'N' or 'Yes'/'No'",
            other
        ))),
    }
}

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

    #[derive(Debug, Deserialize)]
    struct TestStruct {
        #[serde(
            rename = "@value",
            default,
            deserialize_with = "deserialize_optional_decimal"
        )]
        value: Option<Decimal>,

        #[serde(
            rename = "@date",
            default,
            deserialize_with = "deserialize_optional_date"
        )]
        date: Option<NaiveDate>,

        #[serde(
            rename = "@text",
            default,
            deserialize_with = "deserialize_optional_string"
        )]
        text: Option<String>,

        #[serde(
            rename = "@flag",
            default,
            deserialize_with = "deserialize_optional_bool"
        )]
        flag: Option<bool>,
    }

    #[test]
    fn test_empty_string_decimal() {
        let xml = r#"<TestStruct value="" />"#;
        let result: Result<TestStruct, _> = quick_xml::de::from_str(xml);
        assert!(result.is_ok());
        assert_eq!(result.unwrap().value, None);
    }

    #[test]
    fn test_valid_decimal() {
        let xml = r#"<TestStruct value="123.45" />"#;
        let result: Result<TestStruct, _> = quick_xml::de::from_str(xml);
        assert!(result.is_ok());
        assert_eq!(result.unwrap().value, Some(Decimal::new(12345, 2)));
    }

    #[test]
    fn test_empty_string_date() {
        let xml = r#"<TestStruct date="" />"#;
        let result: Result<TestStruct, _> = quick_xml::de::from_str(xml);
        assert!(result.is_ok());
        assert_eq!(result.unwrap().date, None);
    }

    #[test]
    fn test_multi_sentinel_date() {
        let xml = r#"<TestStruct date="MULTI" />"#;
        let result: Result<TestStruct, _> = quick_xml::de::from_str(xml);
        assert!(result.is_ok());
        assert_eq!(result.unwrap().date, None);
    }

    #[test]
    fn test_empty_string_text() {
        let xml = r#"<TestStruct text="" />"#;
        let result: Result<TestStruct, _> = quick_xml::de::from_str(xml);
        assert!(result.is_ok());
        assert_eq!(result.unwrap().text, None);
    }

    #[test]
    fn test_bool_y() {
        let xml = r#"<TestStruct flag="Y" />"#;
        let result: Result<TestStruct, _> = quick_xml::de::from_str(xml);
        assert!(result.is_ok());
        assert_eq!(result.unwrap().flag, Some(true));
    }

    #[test]
    fn test_bool_n() {
        let xml = r#"<TestStruct flag="N" />"#;
        let result: Result<TestStruct, _> = quick_xml::de::from_str(xml);
        assert!(result.is_ok());
        assert_eq!(result.unwrap().flag, Some(false));
    }

    #[test]
    fn test_bool_empty() {
        let xml = r#"<TestStruct flag="" />"#;
        let result: Result<TestStruct, _> = quick_xml::de::from_str(xml);
        assert!(result.is_ok());
        assert_eq!(result.unwrap().flag, None);
    }

    #[test]
    fn test_bool_missing() {
        let xml = r#"<TestStruct />"#;
        let result: Result<TestStruct, _> = quick_xml::de::from_str(xml);
        assert!(result.is_ok());
        assert_eq!(result.unwrap().flag, None);
    }

    #[test]
    fn test_bool_invalid() {
        let xml = r#"<TestStruct flag="X" />"#;
        let result: Result<TestStruct, _> = quick_xml::de::from_str(xml);
        assert!(result.is_err());
    }

    #[test]
    fn test_decimal_thousands_separator() {
        let xml = r#"<TestStruct value="2,345,678.99" />"#;
        let result: TestStruct = quick_xml::de::from_str(xml).unwrap();
        assert_eq!(result.value, Some(Decimal::new(234567899, 2)));
    }

    #[test]
    fn test_decimal_dash_sentinels() {
        for v in ["-", "--", "N/A", "n/a"] {
            let xml = format!(r#"<TestStruct value="{v}" />"#);
            let result: TestStruct = quick_xml::de::from_str(&xml).unwrap();
            assert_eq!(result.value, None, "sentinel {v:?} should parse to None");
        }
    }

    #[test]
    fn test_string_sentinels_to_none() {
        let xml = r#"<TestStruct text="N/A" />"#;
        let result: TestStruct = quick_xml::de::from_str(xml).unwrap();
        assert_eq!(result.text, None);
    }

    #[test]
    fn test_date_with_time_component() {
        let xml = r#"<TestStruct date="20260528;170000" />"#;
        let result: TestStruct = quick_xml::de::from_str(xml).unwrap();
        assert_eq!(result.date, NaiveDate::from_ymd_opt(2026, 5, 28));
    }

    #[test]
    fn test_bool_yes_no() {
        let yes: TestStruct = quick_xml::de::from_str(r#"<TestStruct flag="Yes" />"#).unwrap();
        assert_eq!(yes.flag, Some(true));
        let no: TestStruct = quick_xml::de::from_str(r#"<TestStruct flag="No" />"#).unwrap();
        assert_eq!(no.flag, Some(false));
    }

    #[test]
    fn test_bool_dash_sentinel() {
        let xml = r#"<TestStruct flag="--" />"#;
        let result: TestStruct = quick_xml::de::from_str(xml).unwrap();
        assert_eq!(result.flag, None);
    }
}