use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::Deserialize;
use smol_str::SmolStr;
use tracing::warn;
use super::{IbkrFlexError, finish_parse_error, nonempty};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct IbkrFlexCorporateAction {
pub account_id: Option<SmolStr>,
pub symbol: Option<SmolStr>,
pub conid: Option<SmolStr>,
pub isin: Option<SmolStr>,
pub cusip: Option<SmolStr>,
pub asset_category: Option<SmolStr>,
pub action_type: IbkrReorgType,
pub quantity_delta: Decimal,
pub action_description: Option<SmolStr>,
pub report_date: Option<NaiveDate>,
pub date_time: Option<SmolStr>,
pub value: Option<Decimal>,
pub proceeds: Option<Decimal>,
pub fifo_pnl_realized: Option<Decimal>,
pub principal_adjust_factor: Option<Decimal>,
pub action_id: Option<SmolStr>,
pub transaction_id: Option<SmolStr>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum IbkrReorgType {
ForwardSplit,
ReverseSplit,
ForwardSplitIssue,
ContractSplit,
SpinOff,
ContractSpinOff,
Other(SmolStr),
}
impl From<&str> for IbkrReorgType {
fn from(code: &str) -> Self {
match code {
"FS" => Self::ForwardSplit,
"RS" => Self::ReverseSplit,
"FI" => Self::ForwardSplitIssue,
"CS" => Self::ContractSplit,
"SO" => Self::SpinOff,
"CO" => Self::ContractSpinOff,
other => Self::Other(SmolStr::from(other)),
}
}
}
pub fn parse_corporate_actions(xml: &str) -> Result<Vec<IbkrFlexCorporateAction>, IbkrFlexError> {
parse_corporate_actions_scrubbed(xml, None)
}
pub(super) fn parse_corporate_actions_scrubbed(
xml: &str,
token: Option<&str>,
) -> Result<Vec<IbkrFlexCorporateAction>, IbkrFlexError> {
match super::root_element_name(xml).as_deref() {
Some("FlexQueryResponse") => {}
Some(other) => {
return Err(finish_parse_error(
format!("expected Flex statement root <FlexQueryResponse>, found <{other}>"),
token,
));
}
None => {
return Err(finish_parse_error(
"empty or unreadable Flex statement document".to_owned(),
token,
));
}
}
let response: RawFlexQueryResponse = quick_xml::de::from_str(xml).map_err(|e| {
finish_parse_error(format!("failed to parse Flex statement XML: {e}"), token)
})?;
Ok(response
.flex_statements
.statements
.into_iter()
.flat_map(|statement| statement.corporate_actions.actions)
.map(RawCorporateAction::into_corporate_action)
.collect())
}
#[derive(Debug, Deserialize)]
struct RawFlexQueryResponse {
#[serde(rename = "FlexStatements", default)]
flex_statements: RawFlexStatements,
}
#[derive(Debug, Default, Deserialize)]
struct RawFlexStatements {
#[serde(rename = "FlexStatement", default)]
statements: Vec<RawFlexStatement>,
}
#[derive(Debug, Deserialize)]
struct RawFlexStatement {
#[serde(rename = "CorporateActions", default)]
corporate_actions: RawCorporateActions,
}
#[derive(Debug, Default, Deserialize)]
struct RawCorporateActions {
#[serde(rename = "CorporateAction", default)]
actions: Vec<RawCorporateAction>,
}
#[derive(Debug, Deserialize)]
struct RawCorporateAction {
#[serde(rename = "@accountId", default)]
account_id: Option<String>,
#[serde(rename = "@symbol", default)]
symbol: Option<String>,
#[serde(rename = "@conid", default)]
conid: Option<String>,
#[serde(rename = "@isin", default)]
isin: Option<String>,
#[serde(rename = "@cusip", default)]
cusip: Option<String>,
#[serde(rename = "@assetCategory", default)]
asset_category: Option<String>,
#[serde(rename = "@type", default)]
type_attr: Option<String>,
#[serde(rename = "@actionType", default)]
action_type_attr: Option<String>,
#[serde(rename = "@quantity", default)]
quantity: Option<String>,
#[serde(rename = "@actionDescription", default)]
action_description: Option<String>,
#[serde(rename = "@reportDate", default)]
report_date: Option<String>,
#[serde(rename = "@dateTime", default)]
date_time: Option<String>,
#[serde(rename = "@value", default)]
value: Option<String>,
#[serde(rename = "@proceeds", default)]
proceeds: Option<String>,
#[serde(rename = "@fifoPnlRealized", default)]
fifo_pnl_realized: Option<String>,
#[serde(rename = "@principalAdjustFactor", default)]
principal_adjust_factor: Option<String>,
#[serde(rename = "@actionID", default)]
action_id: Option<String>,
#[serde(rename = "@transactionID", default)]
transaction_id: Option<String>,
}
impl RawCorporateAction {
fn into_corporate_action(self) -> IbkrFlexCorporateAction {
let code = nonempty(self.type_attr)
.or_else(|| nonempty(self.action_type_attr))
.unwrap_or_default();
let quantity_delta = quantity_delta_or_warn(self.quantity, self.symbol.as_deref());
IbkrFlexCorporateAction {
account_id: opt_smol(self.account_id),
symbol: opt_smol(self.symbol),
conid: opt_smol(self.conid),
isin: opt_smol(self.isin),
cusip: opt_smol(self.cusip),
asset_category: opt_smol(self.asset_category),
action_type: IbkrReorgType::from(code.as_str()),
quantity_delta,
action_description: opt_smol(self.action_description),
report_date: opt_date(self.report_date),
date_time: opt_smol(self.date_time),
value: opt_decimal(self.value),
proceeds: opt_decimal(self.proceeds),
fifo_pnl_realized: opt_decimal(self.fifo_pnl_realized),
principal_adjust_factor: opt_decimal(self.principal_adjust_factor),
action_id: opt_smol(self.action_id),
transaction_id: opt_smol(self.transaction_id),
}
}
}
fn opt_smol(value: Option<String>) -> Option<SmolStr> {
nonempty(value).map(SmolStr::from)
}
fn opt_decimal(value: Option<String>) -> Option<Decimal> {
nonempty(value).and_then(|v| v.parse().ok())
}
fn quantity_delta_or_warn(value: Option<String>, symbol: Option<&str>) -> Decimal {
match nonempty(value) {
Some(raw) => raw.parse().unwrap_or_else(|_| {
warn!(
quantity = %raw,
symbol = ?symbol,
"IBKR flex: malformed corporate-action quantity coerced to 0 (a valid-looking \
'no share change' sentinel) — a real reorg quantity may be silently lost."
);
Decimal::ZERO
}),
None => Decimal::ZERO,
}
}
fn opt_date(value: Option<String>) -> Option<NaiveDate> {
nonempty(value).and_then(|v| NaiveDate::parse_from_str(&v, "%Y-%m-%d").ok())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
use super::*;
use super::super::MAX_ERROR_BODY_BYTES;
#[test]
fn parse_errors_are_bounded() {
let huge_root = format!("<{}/>", "a".repeat(8192));
match parse_corporate_actions(&huge_root) {
Err(IbkrFlexError::Parse(message)) => {
assert!(
message.starts_with("expected Flex statement root"),
"must reach the root-element branch, got: {message}"
);
assert!(
message.len() <= MAX_ERROR_BODY_BYTES,
"bounded to the cap, got {} bytes",
message.len()
);
}
other => panic!("expected Parse, got {other:?}"),
}
let huge_body = format!(
"<FlexQueryResponse><{}></FlexQueryResponse>",
"a".repeat(8192)
);
match parse_corporate_actions(&huge_body) {
Err(IbkrFlexError::Parse(message)) => {
assert!(
message.starts_with("failed to parse Flex statement XML"),
"must reach the deserialiser branch, got: {message}"
);
assert!(
message.len() <= MAX_ERROR_BODY_BYTES,
"bounded to the cap, got {} bytes",
message.len()
);
}
other => panic!("expected Parse, got {other:?}"),
}
}
const ACTIVITY_FIXTURE: &str =
include_str!("../../../../tests/fixtures/ibkr_flex/activity_corporate_actions.xml");
const COMPLEX_FIXTURE: &str =
include_str!("../../../../tests/fixtures/ibkr_flex/activity_complex_corporate_actions.xml");
fn find<'a>(
actions: &'a [IbkrFlexCorporateAction],
symbol: &str,
) -> &'a IbkrFlexCorporateAction {
actions
.iter()
.find(|a| a.symbol.as_deref() == Some(symbol))
.unwrap_or_else(|| panic!("no corporate action for symbol {symbol}"))
}
#[test]
fn parses_basic_activity_fixture() {
let actions = parse_corporate_actions(ACTIVITY_FIXTURE).unwrap();
assert_eq!(actions.len(), 8);
let tsla = find(&actions, "TSLA");
assert_eq!(tsla.action_type, IbkrReorgType::ForwardSplit);
assert_eq!(tsla.quantity_delta, Decimal::from(100));
assert_eq!(tsla.account_id.as_deref(), Some("U1234567"));
assert_eq!(tsla.cusip.as_deref(), Some("88160R101"));
assert_eq!(tsla.isin.as_deref(), Some("US88160R1014"));
assert_eq!(tsla.asset_category.as_deref(), Some("STK"));
assert_eq!(tsla.report_date, NaiveDate::from_ymd_opt(2025, 1, 15));
assert_eq!(tsla.date_time.as_deref(), Some("2025-01-15;000000"));
let split = find(&actions, "SPLIT");
assert_eq!(split.action_type, IbkrReorgType::ReverseSplit);
assert_eq!(split.quantity_delta, Decimal::from(-900));
let dividend = find(&actions, "AAPL");
assert_eq!(
dividend.action_type,
IbkrReorgType::Other(SmolStr::from("DI"))
);
assert_eq!(dividend.value, Some(Decimal::from(100)));
assert_eq!(dividend.proceeds, Some(Decimal::from(100)));
let merger = find(&actions, "ACQUIRED");
assert_eq!(
merger.action_type,
IbkrReorgType::Other(SmolStr::from("TC"))
);
assert_eq!(merger.quantity_delta, Decimal::from(-100));
assert_eq!(merger.fifo_pnl_realized, Some(Decimal::from(1500)));
}
#[test]
fn principal_adjust_factor_is_surfaced_raw_not_derived() {
let actions = parse_corporate_actions(ACTIVITY_FIXTURE).unwrap();
assert_eq!(
find(&actions, "TSLA").principal_adjust_factor,
Some(Decimal::new(10023, 4)) );
assert_eq!(
find(&actions, "SPLIT").principal_adjust_factor,
Some(Decimal::new(9977, 4)) );
assert_eq!(find(&actions, "AAPL").principal_adjust_factor, None);
}
#[test]
fn parses_complex_fixture_with_type_and_actiontype_aliasing() {
let actions = parse_corporate_actions(COMPLEX_FIXTURE).unwrap();
assert_eq!(actions.len(), 10);
let choice_dividend = &actions[0];
assert_eq!(choice_dividend.symbol.as_deref(), Some("XYZ"));
assert_eq!(
choice_dividend.action_type,
IbkrReorgType::Other(SmolStr::from("CD"))
);
assert_eq!(
choice_dividend.action_description.as_deref(),
Some("Choice Dividend")
);
assert_eq!(choice_dividend.date_time.as_deref(), Some("2025-01-15"));
let bond = find(&actions, "DEF");
assert_eq!(bond.asset_category.as_deref(), Some("BOND"));
}
#[test]
fn empty_corporate_actions_section_yields_no_rows() {
let xml = r#"<?xml version="1.0"?>
<FlexQueryResponse queryName="Activity" type="AF">
<FlexStatements count="1">
<FlexStatement accountId="U1" fromDate="2025-01-15" toDate="2025-01-15">
<CorporateActions />
</FlexStatement>
</FlexStatements>
</FlexQueryResponse>"#;
assert!(parse_corporate_actions(xml).unwrap().is_empty());
}
#[test]
fn missing_corporate_actions_section_yields_no_rows() {
let xml = r#"<?xml version="1.0"?>
<FlexQueryResponse queryName="Activity" type="AF">
<FlexStatements count="1">
<FlexStatement accountId="U1" fromDate="2025-01-15" toDate="2025-01-15">
<Trades />
</FlexStatement>
</FlexStatements>
</FlexQueryResponse>"#;
assert!(parse_corporate_actions(xml).unwrap().is_empty());
}
#[test]
fn collects_rows_across_multiple_statements() {
let xml = r#"<?xml version="1.0"?>
<FlexQueryResponse queryName="Activity" type="AF">
<FlexStatements count="2">
<FlexStatement accountId="U1">
<CorporateActions>
<CorporateAction accountId="U1" symbol="AAA" type="FS" quantity="10" />
</CorporateActions>
</FlexStatement>
<FlexStatement accountId="U2">
<CorporateActions>
<CorporateAction accountId="U2" symbol="BBB" type="RS" quantity="-5" />
</CorporateActions>
</FlexStatement>
</FlexStatements>
</FlexQueryResponse>"#;
let actions = parse_corporate_actions(xml).unwrap();
assert_eq!(actions.len(), 2);
assert_eq!(actions[0].symbol.as_deref(), Some("AAA"));
assert_eq!(actions[0].action_type, IbkrReorgType::ForwardSplit);
assert_eq!(actions[1].symbol.as_deref(), Some("BBB"));
assert_eq!(actions[1].action_type, IbkrReorgType::ReverseSplit);
}
#[test]
fn unknown_type_maps_to_other() {
let xml = r#"<FlexQueryResponse><FlexStatements><FlexStatement>
<CorporateActions>
<CorporateAction symbol="ZZ" type="ZZ" quantity="1" />
</CorporateActions>
</FlexStatement></FlexStatements></FlexQueryResponse>"#;
let actions = parse_corporate_actions(xml).unwrap();
assert_eq!(
actions[0].action_type,
IbkrReorgType::Other(SmolStr::from("ZZ"))
);
}
#[test]
fn actiontype_used_when_type_absent() {
let xml = r#"<FlexQueryResponse><FlexStatements><FlexStatement>
<CorporateActions>
<CorporateAction symbol="AAA" actionType="FS" quantity="1" />
</CorporateActions>
</FlexStatement></FlexStatements></FlexQueryResponse>"#;
let actions = parse_corporate_actions(xml).unwrap();
assert_eq!(actions[0].action_type, IbkrReorgType::ForwardSplit);
}
#[test]
fn empty_type_falls_back_to_actiontype() {
let xml = r#"<FlexQueryResponse><FlexStatements><FlexStatement>
<CorporateActions>
<CorporateAction symbol="AAA" type="" actionType="RS" quantity="1" />
</CorporateActions>
</FlexStatement></FlexStatements></FlexQueryResponse>"#;
let actions = parse_corporate_actions(xml).unwrap();
assert_eq!(actions[0].action_type, IbkrReorgType::ReverseSplit);
}
#[test]
fn sparse_row_degrades_gracefully() {
let xml = r#"<FlexQueryResponse><FlexStatements><FlexStatement>
<CorporateActions>
<CorporateAction type="FS" />
<CorporateAction symbol="X" cusip="" reportDate="not-a-date" value="" quantity="oops" />
</CorporateActions>
</FlexStatement></FlexStatements></FlexQueryResponse>"#;
let actions = parse_corporate_actions(xml).unwrap();
assert_eq!(actions.len(), 2);
let first = &actions[0];
assert_eq!(first.action_type, IbkrReorgType::ForwardSplit);
assert_eq!(first.quantity_delta, Decimal::ZERO);
assert!(first.symbol.is_none());
assert!(first.value.is_none());
let second = &actions[1];
assert_eq!(second.action_type, IbkrReorgType::Other(SmolStr::from("")));
assert!(second.cusip.is_none(), "empty cusip must be None");
assert!(
second.report_date.is_none(),
"unparseable date must be None"
);
assert!(second.value.is_none(), "empty value must be None");
assert_eq!(
second.quantity_delta,
Decimal::ZERO,
"unparseable quantity defaults to 0"
);
}
#[test]
fn reorg_code_mapping_is_total() {
assert_eq!(IbkrReorgType::from("FS"), IbkrReorgType::ForwardSplit);
assert_eq!(IbkrReorgType::from("RS"), IbkrReorgType::ReverseSplit);
assert_eq!(IbkrReorgType::from("FI"), IbkrReorgType::ForwardSplitIssue);
assert_eq!(IbkrReorgType::from("CS"), IbkrReorgType::ContractSplit);
assert_eq!(IbkrReorgType::from("SO"), IbkrReorgType::SpinOff);
assert_eq!(IbkrReorgType::from("CO"), IbkrReorgType::ContractSpinOff);
assert_eq!(
IbkrReorgType::from("anything"),
IbkrReorgType::Other(SmolStr::from("anything"))
);
}
#[test]
fn malformed_xml_is_an_error() {
assert!(matches!(
parse_corporate_actions("<FlexQueryResponse><not-closed>"),
Err(IbkrFlexError::Parse(_))
));
}
#[test]
fn error_status_envelope_is_an_error_not_empty_vec() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<FlexStatementResponse timestamp="18 April, 2026 08:00 AM EDT">
<Status>Fail</Status>
<ErrorCode>1003</ErrorCode>
<ErrorMessage>Statement could not be generated at this time. Invalid token.</ErrorMessage>
</FlexStatementResponse>"#;
assert!(
matches!(parse_corporate_actions(xml), Err(IbkrFlexError::Parse(_))),
"a <FlexStatementResponse> error envelope must be a Parse error, not Ok(vec![])"
);
}
#[test]
fn empty_document_is_an_error() {
assert!(matches!(
parse_corporate_actions(""),
Err(IbkrFlexError::Parse(_))
));
}
}