Skip to main content

rustrade_data/exchange/ibkr/flex/
corporate_action.rs

1//! Faithful, raw IBKR Flex corporate-action records and the XML parser.
2//!
3//! This module turns the `<CorporateAction>` rows of an IBKR **Flex Web Service** statement
4//! (the account *Activity* report's Corporate Actions section) into a list of
5//! [`IbkrFlexCorporateAction`] records, **without** interpreting them. It is a *reconciliation*
6//! surface, not a split-ratio source:
7//!
8//! - **No ratio is derived.** Flex reports a corporate-action *type* (e.g. forward/reverse split)
9//!   plus an account-scoped share **delta**, but carries no standardised split-ratio field. Deriving
10//!   a ratio would mean parsing the unstable free-text `actionDescription`, or dividing the
11//!   post-event by the pre-event holding (account state this library deliberately does not own).
12//!   Both are silent-failure risks (a wrong-but-plausible ratio would mis-scale a position), so the
13//!   library surfaces the raw record and leaves ratio derivation/verification to the caller — e.g.
14//!   cross-referencing a market-reference split source.
15//! - **These records cannot drive a live split.** A Flex statement is *post-hoc*: its `reportDate`
16//!   is the day the broker booked the action (typically T+1 or later), **not** the market execution
17//!   date a backtest/live engine needs. Use these records for reconciliation and audit, not for
18//!   injecting split events at the right point in a timeline.
19//! - **Records are account-scoped.** `quantity_delta` is the change to *this account's* position
20//!   from the action, not a market-wide quantity. Two accounts see different deltas for the same
21//!   corporate action.
22//!
23//! The parser returns **every** reorg row faithfully (forward/reverse splits, spin-offs, dividends,
24//! mergers, …); selecting which rows matter (e.g. only `FS`/`RS`) is the caller's job.
25
26use chrono::NaiveDate;
27use rust_decimal::Decimal;
28use serde::Deserialize;
29use smol_str::SmolStr;
30use tracing::warn;
31
32use super::{IbkrFlexError, finish_parse_error, nonempty};
33
34/// A single corporate-action row from an IBKR Flex statement, surfaced verbatim.
35///
36/// Every field mirrors a Flex `<CorporateAction>` attribute with no interpretation applied beyond
37/// type coercion (string → `Decimal`/`NaiveDate`) and treating absent/empty attributes as `None`.
38///
39/// This is a **reconciliation** record, not a split-ratio source, and three limitations follow from
40/// that:
41///
42/// - **No ratio is derived.** Flex carries no standardised split-ratio field, only an action type
43///   plus an account-scoped share delta. Deriving a ratio would mean parsing the unstable free-text
44///   `action_description` or dividing post- by pre-event holdings (account state this library does
45///   not own) — both silent-failure risks, so ratio derivation is left to the caller.
46/// - **These records cannot drive a live split.** A Flex statement is *post-hoc*: `report_date` is
47///   when the broker booked the action (typically T+1 or later), not the market execution date a
48///   backtest or live engine needs.
49/// - **Records are account-scoped.** `quantity_delta` is the change to *this account's* position,
50///   not a market-wide quantity; two accounts see different deltas for the same action.
51///
52/// `#[non_exhaustive]`: IBKR may add attributes to the Flex schema; new fields are surfaced
53/// additively without a breaking change. Construct instances via [`parse_corporate_actions`], not a
54/// struct literal.
55#[derive(Debug, Clone, PartialEq, Eq, Hash)]
56#[non_exhaustive]
57pub struct IbkrFlexCorporateAction {
58    /// IBKR account the action was booked against (`accountId`). Account-scoped — see the module docs.
59    pub account_id: Option<SmolStr>,
60    /// Instrument ticker symbol (`symbol`).
61    pub symbol: Option<SmolStr>,
62    /// IBKR contract id (`conid`), surfaced as a string.
63    pub conid: Option<SmolStr>,
64    /// ISIN identifier (`isin`), when present.
65    pub isin: Option<SmolStr>,
66    /// CUSIP identifier (`cusip`), when present.
67    pub cusip: Option<SmolStr>,
68    /// IBKR asset category (`assetCategory`, e.g. `"STK"`, `"BOND"`).
69    pub asset_category: Option<SmolStr>,
70    /// The reorganisation type (`type`, aliased from `actionType`). See [`IbkrReorgType`].
71    pub action_type: IbkrReorgType,
72    /// Signed change to *this account's* share count from the action (`quantity`).
73    ///
74    /// This is an **account-scoped delta**, not a market-wide quantity, and is **not** a split
75    /// ratio: deriving a ratio from `new_qty / old_qty` would require the pre-event holding, which
76    /// this library does not track. An absent or empty `quantity` is surfaced as `0`; a **malformed**
77    /// (present-but-unparseable) `quantity` is also coerced to `0` but emits a `warn!` first, since
78    /// `0` here is a valid-looking "no share change" sentinel that would otherwise silently mask a
79    /// real reorg quantity (unlike the other fields, which faithfully surface a malformed value as
80    /// `None`).
81    pub quantity_delta: Decimal,
82    /// Free-text action description (`actionDescription`), when present.
83    ///
84    /// **Unstable / human-facing.** The wording is not a stable contract and must **not** be parsed
85    /// to extract a split ratio or other structured data — IBKR can change it at any time. Some rows
86    /// omit it entirely.
87    pub action_description: Option<SmolStr>,
88    /// The date IBKR booked the action (`reportDate`), best-effort parsed from `YYYY-MM-DD`.
89    ///
90    /// This is **not** the market execution date of the corporate action — a Flex statement is
91    /// post-hoc, so `report_date` is typically a trading day (or more) *after* the event took effect
92    /// in the market. Do not treat it as an effective date for engine injection.
93    pub report_date: Option<NaiveDate>,
94    /// Raw `dateTime` attribute, surfaced as-is.
95    ///
96    /// The Flex date/time format is query-configuration-dependent — it can be `"2025-01-15;000000"`
97    /// (date;time) or a bare `"2025-01-15"` depending on the saved query's date/time format settings
98    /// — so it is intentionally **not** parsed here. Callers that need a typed value should parse it
99    /// against the format their own Flex query is configured to emit.
100    pub date_time: Option<SmolStr>,
101    /// Booked value of the action (`value`), when present.
102    pub value: Option<Decimal>,
103    /// Cash proceeds of the action (`proceeds`), when present.
104    pub proceeds: Option<Decimal>,
105    /// Realised FIFO P&L attributed to the action (`fifoPnlRealized`), when present.
106    pub fifo_pnl_realized: Option<Decimal>,
107    /// Raw `principalAdjustFactor` attribute, when present — **surfaced, never interpreted**.
108    ///
109    /// IBKR documents this as the calculated principal-adjustment factor for **Treasury
110    /// Inflation-Protected Securities (TIPS)**, *not* a split ratio. Some synthetic third-party
111    /// fixtures show it populated on split rows in a way that resembles `split_to / split_from`, but
112    /// that has **not** been confirmed against live broker output and may be an artefact of those
113    /// fixtures. It is surfaced here only so the raw record drops no schema field; it must **not** be
114    /// used as a primary source for a split ratio. A caller holding both this record and a
115    /// market-reference ratio is the right place to optionally cross-check it.
116    pub principal_adjust_factor: Option<Decimal>,
117    /// IBKR action identifier (`actionID`), when present.
118    pub action_id: Option<SmolStr>,
119    /// IBKR transaction identifier (`transactionID`), when present.
120    pub transaction_id: Option<SmolStr>,
121}
122
123/// IBKR Flex reorganisation type (the `<CorporateAction>` `type` attribute).
124///
125/// Only the split-related codes the reconciliation use-case cares about are modelled as named
126/// variants; every other code (dividends, spin-offs, mergers, tender offers, bond events, …) is
127/// preserved verbatim in [`Other`](IbkrReorgType::Other) so no information is lost.
128///
129/// `#[non_exhaustive]`: named variants may be added as more codes gain first-class handling, so
130/// downstream `match`es must include a wildcard arm.
131#[derive(Debug, Clone, PartialEq, Eq, Hash)]
132#[non_exhaustive]
133pub enum IbkrReorgType {
134    /// Forward split (`FS`).
135    ForwardSplit,
136    /// Reverse split (`RS`).
137    ReverseSplit,
138    /// Forward split issue (`FI`).
139    ForwardSplitIssue,
140    /// Contract split (`CS`).
141    ContractSplit,
142    /// Spin-off (`SO`).
143    SpinOff,
144    /// Contract spin-off (`CO`).
145    ContractSpinOff,
146    /// Any other (or absent) reorg code, preserved verbatim (e.g. `"DI"`, `"TC"`, `"CD"`).
147    Other(SmolStr),
148}
149
150impl From<&str> for IbkrReorgType {
151    /// Map an IBKR reorg code string to a variant. Unknown or empty codes become
152    /// [`Other`](IbkrReorgType::Other) (an empty code yields `Other("")`).
153    ///
154    /// This is the canonical code → variant mapping; it is total (every input maps to a variant)
155    /// so consumers parsing their own Flex records can reconstruct the type from a stored code.
156    fn from(code: &str) -> Self {
157        match code {
158            "FS" => Self::ForwardSplit,
159            "RS" => Self::ReverseSplit,
160            "FI" => Self::ForwardSplitIssue,
161            "CS" => Self::ContractSplit,
162            "SO" => Self::SpinOff,
163            "CO" => Self::ContractSpinOff,
164            other => Self::Other(SmolStr::from(other)),
165        }
166    }
167}
168
169/// Parse the `<CorporateAction>` rows out of an IBKR Flex statement XML document.
170///
171/// Walks every `<FlexStatement>` in the `<FlexQueryResponse>` and returns each `<CorporateAction>`
172/// as a faithful [`IbkrFlexCorporateAction`], in document order. **All** reorg types are returned —
173/// filtering to splits (or any other subset) is the caller's responsibility.
174///
175/// Non-corporate-action sections of the statement (trades, positions, cash transactions, …) are
176/// ignored. A statement with no Corporate Actions section yields an empty vector.
177///
178/// # Errors
179///
180/// Returns [`IbkrFlexError::Parse`] if the document is not well-formed XML or does not match the
181/// expected Flex statement shape.
182pub fn parse_corporate_actions(xml: &str) -> Result<Vec<IbkrFlexCorporateAction>, IbkrFlexError> {
183    parse_corporate_actions_scrubbed(xml, None)
184}
185
186/// [`parse_corporate_actions`], additionally redacting `token` from any error message.
187///
188/// Exists because the two callers differ in what they are able to protect. Called directly, this
189/// parser has no credential in scope and can only *bound* its messages. Called from
190/// [`IbkrFlexClient::fetch_corporate_actions`](super::IbkrFlexClient::fetch_corporate_actions) a
191/// token does exist, and the message must be redacted as well as bounded.
192///
193/// The token is threaded in *here*, rather than the caller scrubbing the returned message, because
194/// the two operations do not commute. Redaction matches the full token, so it has to run while the
195/// message is still unbounded: bounding first can leave a straddling credential present only as a
196/// prefix fragment, which a subsequent full-token match would miss. See
197/// [`sanitize_error_body`](super::sanitize_error_body), which enforces the same ordering internally.
198pub(super) fn parse_corporate_actions_scrubbed(
199    xml: &str,
200    token: Option<&str>,
201) -> Result<Vec<IbkrFlexCorporateAction>, IbkrFlexError> {
202    // Assert the document is actually a `<FlexQueryResponse>` statement BEFORE deserializing.
203    // quick-xml's serde path does not verify the root element, and the raw structs use
204    // `#[serde(default)]`, so an IBKR error/status envelope (`<FlexStatementResponse>`, e.g. an
205    // "Invalid token" `ErrorCode 1003`) would otherwise deserialize into an all-defaults
206    // (empty) `RawFlexQueryResponse` and return `Ok(vec![])` — indistinguishable from a genuine
207    // statement with no Corporate Actions section, and contradicting this function's `# Errors`
208    // contract. Reuse the same root-element helper the blessed `GetStatement` path uses
209    // (`super::root_element_name`, which `super::classify_get_statement` also wraps).
210    match super::root_element_name(xml).as_deref() {
211        Some("FlexQueryResponse") => {}
212        Some(other) => {
213            // `other` is a tag name lifted verbatim out of the supplied document, so an adversarial
214            // or corrupt statement could otherwise inflate the error to the size of the input.
215            // `finish_parse_error` applies the same cap the fetch-response parse path applies —
216            // after redaction, never before it.
217            return Err(finish_parse_error(
218                format!("expected Flex statement root <FlexQueryResponse>, found <{other}>"),
219                token,
220            ));
221        }
222        None => {
223            // Routed through the same finaliser as the branches above even though this message is a
224            // fixed literal with nothing to redact or bound. Uniformity is the point: "every `Parse`
225            // is built by `finish_parse_error`" is a property a reader can check locally, whereas
226            // "every `Parse` that *could* carry a credential is" requires re-auditing each branch
227            // whenever a message gains an interpolated value.
228            return Err(finish_parse_error(
229                "empty or unreadable Flex statement document".to_owned(),
230                token,
231            ));
232        }
233    }
234
235    // Bounded for the same reason: a `DeError` embeds the deserialiser's rendering of the offending
236    // input (quick-xml's `UnexpectedStart` carries the raw tag bytes it choked on), so the message
237    // length tracks the document, not the failure.
238    let response: RawFlexQueryResponse = quick_xml::de::from_str(xml).map_err(|e| {
239        finish_parse_error(format!("failed to parse Flex statement XML: {e}"), token)
240    })?;
241
242    Ok(response
243        .flex_statements
244        .statements
245        .into_iter()
246        .flat_map(|statement| statement.corporate_actions.actions)
247        .map(RawCorporateAction::into_corporate_action)
248        .collect())
249}
250
251// ============================================================================
252// Raw deserialisation layer
253// ============================================================================
254//
255// quick-xml's serde support maps XML *attributes* to fields renamed with a leading `@`. Every
256// attribute is captured as `Option<String>` first, then coerced in `into_corporate_action`, so that
257// empty (`foo=""`) and absent attributes both collapse to `None` and a malformed numeric/date
258// attribute degrades to `None`/`0` rather than failing the whole parse.
259
260#[derive(Debug, Deserialize)]
261struct RawFlexQueryResponse {
262    #[serde(rename = "FlexStatements", default)]
263    flex_statements: RawFlexStatements,
264}
265
266#[derive(Debug, Default, Deserialize)]
267struct RawFlexStatements {
268    #[serde(rename = "FlexStatement", default)]
269    statements: Vec<RawFlexStatement>,
270}
271
272#[derive(Debug, Deserialize)]
273struct RawFlexStatement {
274    #[serde(rename = "CorporateActions", default)]
275    corporate_actions: RawCorporateActions,
276}
277
278#[derive(Debug, Default, Deserialize)]
279struct RawCorporateActions {
280    #[serde(rename = "CorporateAction", default)]
281    actions: Vec<RawCorporateAction>,
282}
283
284#[derive(Debug, Deserialize)]
285struct RawCorporateAction {
286    #[serde(rename = "@accountId", default)]
287    account_id: Option<String>,
288    #[serde(rename = "@symbol", default)]
289    symbol: Option<String>,
290    #[serde(rename = "@conid", default)]
291    conid: Option<String>,
292    #[serde(rename = "@isin", default)]
293    isin: Option<String>,
294    #[serde(rename = "@cusip", default)]
295    cusip: Option<String>,
296    #[serde(rename = "@assetCategory", default)]
297    asset_category: Option<String>,
298    // The reorg type lives in `type`; some statements also (or instead) carry `actionType`. Capture
299    // both separately and prefer `type`, falling back to `actionType` — serde `alias` would reject a
300    // row that carries *both* attributes as a duplicate field.
301    #[serde(rename = "@type", default)]
302    type_attr: Option<String>,
303    #[serde(rename = "@actionType", default)]
304    action_type_attr: Option<String>,
305    #[serde(rename = "@quantity", default)]
306    quantity: Option<String>,
307    #[serde(rename = "@actionDescription", default)]
308    action_description: Option<String>,
309    #[serde(rename = "@reportDate", default)]
310    report_date: Option<String>,
311    #[serde(rename = "@dateTime", default)]
312    date_time: Option<String>,
313    #[serde(rename = "@value", default)]
314    value: Option<String>,
315    #[serde(rename = "@proceeds", default)]
316    proceeds: Option<String>,
317    #[serde(rename = "@fifoPnlRealized", default)]
318    fifo_pnl_realized: Option<String>,
319    #[serde(rename = "@principalAdjustFactor", default)]
320    principal_adjust_factor: Option<String>,
321    #[serde(rename = "@actionID", default)]
322    action_id: Option<String>,
323    #[serde(rename = "@transactionID", default)]
324    transaction_id: Option<String>,
325}
326
327impl RawCorporateAction {
328    fn into_corporate_action(self) -> IbkrFlexCorporateAction {
329        let code = nonempty(self.type_attr)
330            .or_else(|| nonempty(self.action_type_attr))
331            .unwrap_or_default();
332
333        // Computed BEFORE the struct literal so the warn can still reference `symbol` (which the
334        // literal moves). See `quantity_delta_or_warn` for why this field warns and the others do not.
335        let quantity_delta = quantity_delta_or_warn(self.quantity, self.symbol.as_deref());
336
337        IbkrFlexCorporateAction {
338            account_id: opt_smol(self.account_id),
339            symbol: opt_smol(self.symbol),
340            conid: opt_smol(self.conid),
341            isin: opt_smol(self.isin),
342            cusip: opt_smol(self.cusip),
343            asset_category: opt_smol(self.asset_category),
344            action_type: IbkrReorgType::from(code.as_str()),
345            quantity_delta,
346            action_description: opt_smol(self.action_description),
347            report_date: opt_date(self.report_date),
348            date_time: opt_smol(self.date_time),
349            value: opt_decimal(self.value),
350            proceeds: opt_decimal(self.proceeds),
351            fifo_pnl_realized: opt_decimal(self.fifo_pnl_realized),
352            principal_adjust_factor: opt_decimal(self.principal_adjust_factor),
353            action_id: opt_smol(self.action_id),
354            transaction_id: opt_smol(self.transaction_id),
355        }
356    }
357}
358
359fn opt_smol(value: Option<String>) -> Option<SmolStr> {
360    nonempty(value).map(SmolStr::from)
361}
362
363/// Parse a non-empty attribute as a [`Decimal`], yielding `None` if absent, empty, or malformed.
364fn opt_decimal(value: Option<String>) -> Option<Decimal> {
365    nonempty(value).and_then(|v| v.parse().ok())
366}
367
368/// Parse the `quantity` attribute into `quantity_delta`, coercing a malformed value to
369/// `Decimal::ZERO` but **warning first**.
370///
371/// Every other field in this module is deliberately faithful — an absent, empty, or malformed
372/// attribute surfaces as `None`, so a downstream reader can tell "not reported" from "reported as
373/// X". `quantity_delta` is the one non-optional coercion: a malformed value collapses to `0`, which
374/// is a *valid-looking* "no share change" sentinel indistinguishable from a genuine zero. That
375/// silent collapse could mask a real reorg quantity, so a present-but-unparseable value is surfaced
376/// as an observable `warn!` (absent/empty stays a quiet `0` — a genuine "not reported", not a parse
377/// failure).
378fn quantity_delta_or_warn(value: Option<String>, symbol: Option<&str>) -> Decimal {
379    match nonempty(value) {
380        Some(raw) => raw.parse().unwrap_or_else(|_| {
381            warn!(
382                quantity = %raw,
383                symbol = ?symbol,
384                "IBKR flex: malformed corporate-action quantity coerced to 0 (a valid-looking \
385                 'no share change' sentinel) — a real reorg quantity may be silently lost."
386            );
387            Decimal::ZERO
388        }),
389        None => Decimal::ZERO,
390    }
391}
392
393/// Best-effort parse a `YYYY-MM-DD` attribute, yielding `None` if absent, empty, or malformed.
394fn opt_date(value: Option<String>) -> Option<NaiveDate> {
395    nonempty(value).and_then(|v| NaiveDate::parse_from_str(&v, "%Y-%m-%d").ok())
396}
397
398#[cfg(test)]
399#[allow(clippy::unwrap_used, clippy::expect_used)] // Tests should panic on unexpected values.
400mod tests {
401    use super::*;
402    // Only the assertions below reference the cap directly — `finish_parse_error` applies it.
403    use super::super::MAX_ERROR_BODY_BYTES;
404
405    #[test]
406    fn parse_errors_are_bounded() {
407        // These messages embed the offending document, so their length must track the cap rather
408        // than the input. Both input-reflecting branches are covered: the root-element mismatch and
409        // the deserialiser failure. The short "empty or unreadable" branch is a fixed literal.
410        let huge_root = format!("<{}/>", "a".repeat(8192));
411        match parse_corporate_actions(&huge_root) {
412            Err(IbkrFlexError::Parse(message)) => {
413                assert!(
414                    message.starts_with("expected Flex statement root"),
415                    "must reach the root-element branch, got: {message}"
416                );
417                assert!(
418                    message.len() <= MAX_ERROR_BODY_BYTES,
419                    "bounded to the cap, got {} bytes",
420                    message.len()
421                );
422            }
423            other => panic!("expected Parse, got {other:?}"),
424        }
425
426        // A correct root that then fails to deserialize, driving the `DeError` branch. The inner
427        // tag is left unclosed so quick-xml reports a mismatched end tag, embedding the long name.
428        // (A malformed *attribute* would not do: `quantity` is `Option<String>` and an unparseable
429        // value is coerced to `0` with a warn, so it returns `Ok` and would test nothing.)
430        let huge_body = format!(
431            "<FlexQueryResponse><{}></FlexQueryResponse>",
432            "a".repeat(8192)
433        );
434        match parse_corporate_actions(&huge_body) {
435            Err(IbkrFlexError::Parse(message)) => {
436                assert!(
437                    message.starts_with("failed to parse Flex statement XML"),
438                    "must reach the deserialiser branch, got: {message}"
439                );
440                assert!(
441                    message.len() <= MAX_ERROR_BODY_BYTES,
442                    "bounded to the cap, got {} bytes",
443                    message.len()
444                );
445            }
446            other => panic!("expected Parse, got {other:?}"),
447        }
448    }
449
450    const ACTIVITY_FIXTURE: &str =
451        include_str!("../../../../tests/fixtures/ibkr_flex/activity_corporate_actions.xml");
452    const COMPLEX_FIXTURE: &str =
453        include_str!("../../../../tests/fixtures/ibkr_flex/activity_complex_corporate_actions.xml");
454
455    fn find<'a>(
456        actions: &'a [IbkrFlexCorporateAction],
457        symbol: &str,
458    ) -> &'a IbkrFlexCorporateAction {
459        actions
460            .iter()
461            .find(|a| a.symbol.as_deref() == Some(symbol))
462            .unwrap_or_else(|| panic!("no corporate action for symbol {symbol}"))
463    }
464
465    #[test]
466    fn parses_basic_activity_fixture() {
467        let actions = parse_corporate_actions(ACTIVITY_FIXTURE).unwrap();
468        // 8 <CorporateAction> rows: DI, FS, SO, TC, RI, SD, RS, DW.
469        assert_eq!(actions.len(), 8);
470
471        // Forward split (TSLA, 2:1): the named variant + account-scoped +100 share delta.
472        let tsla = find(&actions, "TSLA");
473        assert_eq!(tsla.action_type, IbkrReorgType::ForwardSplit);
474        assert_eq!(tsla.quantity_delta, Decimal::from(100));
475        assert_eq!(tsla.account_id.as_deref(), Some("U1234567"));
476        assert_eq!(tsla.cusip.as_deref(), Some("88160R101"));
477        assert_eq!(tsla.isin.as_deref(), Some("US88160R1014"));
478        assert_eq!(tsla.asset_category.as_deref(), Some("STK"));
479        assert_eq!(tsla.report_date, NaiveDate::from_ymd_opt(2025, 1, 15));
480        // `dateTime` kept raw in the `date;time` format this query emitted.
481        assert_eq!(tsla.date_time.as_deref(), Some("2025-01-15;000000"));
482
483        // Reverse split (SPLIT, 1:10): negative (account-scoped) delta.
484        let split = find(&actions, "SPLIT");
485        assert_eq!(split.action_type, IbkrReorgType::ReverseSplit);
486        assert_eq!(split.quantity_delta, Decimal::from(-900));
487
488        // A non-split code is preserved verbatim, not dropped.
489        let dividend = find(&actions, "AAPL");
490        assert_eq!(
491            dividend.action_type,
492            IbkrReorgType::Other(SmolStr::from("DI"))
493        );
494        assert_eq!(dividend.value, Some(Decimal::from(100)));
495        assert_eq!(dividend.proceeds, Some(Decimal::from(100)));
496
497        // A merger row carries realised P&L and a negative delta.
498        let merger = find(&actions, "ACQUIRED");
499        assert_eq!(
500            merger.action_type,
501            IbkrReorgType::Other(SmolStr::from("TC"))
502        );
503        assert_eq!(merger.quantity_delta, Decimal::from(-100));
504        assert_eq!(merger.fifo_pnl_realized, Some(Decimal::from(1500)));
505    }
506
507    #[test]
508    fn principal_adjust_factor_is_surfaced_raw_not_derived() {
509        // The field is surfaced verbatim (a faithful record drops no attribute) but is NOT a split
510        // ratio — see the field rustdoc. These assertions pin that we read it, nothing more.
511        let actions = parse_corporate_actions(ACTIVITY_FIXTURE).unwrap();
512        assert_eq!(
513            find(&actions, "TSLA").principal_adjust_factor,
514            // A TIPS-style inflation factor (deliberately NOT a round split-ratio-looking value),
515            // reinforcing that this field is surfaced raw and is not the split ratio.
516            Some(Decimal::new(10023, 4)) // 1.0023
517        );
518        assert_eq!(
519            find(&actions, "SPLIT").principal_adjust_factor,
520            Some(Decimal::new(9977, 4)) // 0.9977 (again a TIPS factor, not a ratio)
521        );
522        // Empty `principalAdjustFactor=""` collapses to None.
523        assert_eq!(find(&actions, "AAPL").principal_adjust_factor, None);
524    }
525
526    #[test]
527    fn parses_complex_fixture_with_type_and_actiontype_aliasing() {
528        // Every row in this fixture carries BOTH `type` and `actionType` (equal values) plus a bare
529        // `dateTime` (no time component) — exercising the alias coalesce and the second date format.
530        let actions = parse_corporate_actions(COMPLEX_FIXTURE).unwrap();
531        assert_eq!(actions.len(), 10);
532
533        let choice_dividend = &actions[0];
534        assert_eq!(choice_dividend.symbol.as_deref(), Some("XYZ"));
535        assert_eq!(
536            choice_dividend.action_type,
537            IbkrReorgType::Other(SmolStr::from("CD"))
538        );
539        assert_eq!(
540            choice_dividend.action_description.as_deref(),
541            Some("Choice Dividend")
542        );
543        // Bare-date `dateTime` form is kept raw, not coerced.
544        assert_eq!(choice_dividend.date_time.as_deref(), Some("2025-01-15"));
545
546        // A BOND-category row survives intact.
547        let bond = find(&actions, "DEF");
548        assert_eq!(bond.asset_category.as_deref(), Some("BOND"));
549    }
550
551    #[test]
552    fn empty_corporate_actions_section_yields_no_rows() {
553        let xml = r#"<?xml version="1.0"?>
554            <FlexQueryResponse queryName="Activity" type="AF">
555              <FlexStatements count="1">
556                <FlexStatement accountId="U1" fromDate="2025-01-15" toDate="2025-01-15">
557                  <CorporateActions />
558                </FlexStatement>
559              </FlexStatements>
560            </FlexQueryResponse>"#;
561        assert!(parse_corporate_actions(xml).unwrap().is_empty());
562    }
563
564    #[test]
565    fn missing_corporate_actions_section_yields_no_rows() {
566        let xml = r#"<?xml version="1.0"?>
567            <FlexQueryResponse queryName="Activity" type="AF">
568              <FlexStatements count="1">
569                <FlexStatement accountId="U1" fromDate="2025-01-15" toDate="2025-01-15">
570                  <Trades />
571                </FlexStatement>
572              </FlexStatements>
573            </FlexQueryResponse>"#;
574        assert!(parse_corporate_actions(xml).unwrap().is_empty());
575    }
576
577    #[test]
578    fn collects_rows_across_multiple_statements() {
579        let xml = r#"<?xml version="1.0"?>
580            <FlexQueryResponse queryName="Activity" type="AF">
581              <FlexStatements count="2">
582                <FlexStatement accountId="U1">
583                  <CorporateActions>
584                    <CorporateAction accountId="U1" symbol="AAA" type="FS" quantity="10" />
585                  </CorporateActions>
586                </FlexStatement>
587                <FlexStatement accountId="U2">
588                  <CorporateActions>
589                    <CorporateAction accountId="U2" symbol="BBB" type="RS" quantity="-5" />
590                  </CorporateActions>
591                </FlexStatement>
592              </FlexStatements>
593            </FlexQueryResponse>"#;
594
595        let actions = parse_corporate_actions(xml).unwrap();
596        assert_eq!(actions.len(), 2);
597        assert_eq!(actions[0].symbol.as_deref(), Some("AAA"));
598        assert_eq!(actions[0].action_type, IbkrReorgType::ForwardSplit);
599        assert_eq!(actions[1].symbol.as_deref(), Some("BBB"));
600        assert_eq!(actions[1].action_type, IbkrReorgType::ReverseSplit);
601    }
602
603    #[test]
604    fn unknown_type_maps_to_other() {
605        let xml = r#"<FlexQueryResponse><FlexStatements><FlexStatement>
606            <CorporateActions>
607              <CorporateAction symbol="ZZ" type="ZZ" quantity="1" />
608            </CorporateActions>
609        </FlexStatement></FlexStatements></FlexQueryResponse>"#;
610        let actions = parse_corporate_actions(xml).unwrap();
611        assert_eq!(
612            actions[0].action_type,
613            IbkrReorgType::Other(SmolStr::from("ZZ"))
614        );
615    }
616
617    #[test]
618    fn actiontype_used_when_type_absent() {
619        let xml = r#"<FlexQueryResponse><FlexStatements><FlexStatement>
620            <CorporateActions>
621              <CorporateAction symbol="AAA" actionType="FS" quantity="1" />
622            </CorporateActions>
623        </FlexStatement></FlexStatements></FlexQueryResponse>"#;
624        let actions = parse_corporate_actions(xml).unwrap();
625        assert_eq!(actions[0].action_type, IbkrReorgType::ForwardSplit);
626    }
627
628    #[test]
629    fn empty_type_falls_back_to_actiontype() {
630        // An empty `type=""` must not shadow a populated `actionType`.
631        let xml = r#"<FlexQueryResponse><FlexStatements><FlexStatement>
632            <CorporateActions>
633              <CorporateAction symbol="AAA" type="" actionType="RS" quantity="1" />
634            </CorporateActions>
635        </FlexStatement></FlexStatements></FlexQueryResponse>"#;
636        let actions = parse_corporate_actions(xml).unwrap();
637        assert_eq!(actions[0].action_type, IbkrReorgType::ReverseSplit);
638    }
639
640    #[test]
641    fn sparse_row_degrades_gracefully() {
642        // Only `type` present: quantity defaults to 0, every optional field is None, and a missing
643        // `type` (here for a second row) yields `Other("")` rather than failing the parse.
644        let xml = r#"<FlexQueryResponse><FlexStatements><FlexStatement>
645            <CorporateActions>
646              <CorporateAction type="FS" />
647              <CorporateAction symbol="X" cusip="" reportDate="not-a-date" value="" quantity="oops" />
648            </CorporateActions>
649        </FlexStatement></FlexStatements></FlexQueryResponse>"#;
650        let actions = parse_corporate_actions(xml).unwrap();
651        assert_eq!(actions.len(), 2);
652
653        let first = &actions[0];
654        assert_eq!(first.action_type, IbkrReorgType::ForwardSplit);
655        assert_eq!(first.quantity_delta, Decimal::ZERO);
656        assert!(first.symbol.is_none());
657        assert!(first.value.is_none());
658
659        let second = &actions[1];
660        assert_eq!(second.action_type, IbkrReorgType::Other(SmolStr::from("")));
661        assert!(second.cusip.is_none(), "empty cusip must be None");
662        assert!(
663            second.report_date.is_none(),
664            "unparseable date must be None"
665        );
666        assert!(second.value.is_none(), "empty value must be None");
667        assert_eq!(
668            second.quantity_delta,
669            Decimal::ZERO,
670            "unparseable quantity defaults to 0"
671        );
672    }
673
674    #[test]
675    fn reorg_code_mapping_is_total() {
676        assert_eq!(IbkrReorgType::from("FS"), IbkrReorgType::ForwardSplit);
677        assert_eq!(IbkrReorgType::from("RS"), IbkrReorgType::ReverseSplit);
678        assert_eq!(IbkrReorgType::from("FI"), IbkrReorgType::ForwardSplitIssue);
679        assert_eq!(IbkrReorgType::from("CS"), IbkrReorgType::ContractSplit);
680        assert_eq!(IbkrReorgType::from("SO"), IbkrReorgType::SpinOff);
681        assert_eq!(IbkrReorgType::from("CO"), IbkrReorgType::ContractSpinOff);
682        assert_eq!(
683            IbkrReorgType::from("anything"),
684            IbkrReorgType::Other(SmolStr::from("anything"))
685        );
686    }
687
688    #[test]
689    fn malformed_xml_is_an_error() {
690        assert!(matches!(
691            parse_corporate_actions("<FlexQueryResponse><not-closed>"),
692            Err(IbkrFlexError::Parse(_))
693        ));
694    }
695
696    /// An IBKR error/status envelope is a `<FlexStatementResponse>`, NOT a `<FlexQueryResponse>`
697    /// statement. Because `quick-xml` does not verify the root element and the raw structs use
698    /// `#[serde(default)]`, this previously deserialized into an all-defaults `RawFlexQueryResponse`
699    /// and returned `Ok(vec![])` — indistinguishable from a genuine statement with an empty Corporate
700    /// Actions section. It must now be a `Parse` error (honoring the `# Errors` contract), so a
701    /// caller re-parsing a persisted envelope standalone cannot mistake "invalid token" for "no
702    /// corporate actions".
703    #[test]
704    fn error_status_envelope_is_an_error_not_empty_vec() {
705        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
706            <FlexStatementResponse timestamp="18 April, 2026 08:00 AM EDT">
707                <Status>Fail</Status>
708                <ErrorCode>1003</ErrorCode>
709                <ErrorMessage>Statement could not be generated at this time. Invalid token.</ErrorMessage>
710            </FlexStatementResponse>"#;
711        assert!(
712            matches!(parse_corporate_actions(xml), Err(IbkrFlexError::Parse(_))),
713            "a <FlexStatementResponse> error envelope must be a Parse error, not Ok(vec![])"
714        );
715    }
716
717    /// An empty / unreadable document is a `Parse` error, not a silent empty `Vec`.
718    #[test]
719    fn empty_document_is_an_error() {
720        assert!(matches!(
721            parse_corporate_actions(""),
722            Err(IbkrFlexError::Parse(_))
723        ));
724    }
725}