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}