rustrade_data/exchange/alpaca/corporate_action.rs
1//! [`StockSplitSource`] implementation for the shared Alpaca REST client.
2//!
3//! Adapts the lower-level [`AlpacaRestClient::fetch_splits_raw`](super::AlpacaRestClient::fetch_splits_raw) raw-split stream into
4//! the provider-agnostic [`CorporateAction`] sourcing descriptor, computing the split ratio via the
5//! shared [`CorporateActionKind::stock_split`] helper so Alpaca derives ratios identically to every
6//! other provider.
7
8use async_stream::try_stream;
9use futures::{Stream, StreamExt};
10use smol_str::SmolStr;
11use tracing::warn;
12
13use rustrade_instrument::corporate_action::{CorporateAction, CorporateActionKind};
14use rustrade_integration::corporate_action::{CorporateActionFilter, StockSplitSource};
15
16use super::reference::{AlpacaStockSplit, CorporateActionsQuery};
17use super::rest::{AlpacaRestClient, AlpacaRestError};
18
19impl StockSplitSource for AlpacaRestClient {
20 type Error = AlpacaRestError;
21
22 /// Fetch stock splits for `filter`'s symbols and effective-date range, mapped to
23 /// [`CorporateAction<SmolStr>`] descriptors.
24 ///
25 /// `effective_date` provenance: this impl maps it onto Alpaca's **`ex_date`** — the session on
26 /// which the share count re-bases and the price adjusts, satisfying the
27 /// [`StockSplitSource::fetch_splits`] market-execution-date contract. It deliberately does NOT
28 /// use `payable_date`: for forward splits Alpaca's `payable_date` can fall a trading day
29 /// *before* `ex_date` (sampled: `APH` ex 2024-06-12 / payable 2024-06-11; `CNQ` ex 2024-06-11 /
30 /// payable 2024-06-10) while the price re-bases on `ex_date` (observed equal to `process_date`
31 /// across the sample), so mapping `payable_date` would apply the split a day early — look-ahead
32 /// corruption. This mapping is pinned by `ex_date_provenance_ignores_payable_date` (in
33 /// [`reference`](super::reference)) and `map_split_maps_ex_date_to_effective_date` below.
34 ///
35 /// Alpaca's endpoint accepts a single comma-joined `symbols` parameter, so — unlike Massive's
36 /// per-symbol fan-out — a multi-symbol filter issues **one** paginated query. An empty `symbols`
37 /// list requests every split in the date range (subject to provider page limits). Splits whose
38 /// `new_rate` / `old_rate` yield a degenerate ratio (zero, negative, or division by zero) are
39 /// logged and skipped — see [`CorporateActionKind::stock_split`].
40 fn fetch_splits(
41 &self,
42 filter: &CorporateActionFilter,
43 ) -> impl Stream<Item = Result<CorporateAction<SmolStr>, Self::Error>> + Send {
44 // Build the owned query eagerly so the returned stream borrows only `self`, not `filter`.
45 let query = build_query(filter);
46
47 try_stream! {
48 // Calls the *inherent* `AlpacaRestClient::fetch_splits_raw` (taking `&CorporateActionsQuery`),
49 // the lower-level raw-split stream this impl adapts. Its distinct name keeps the dispatch
50 // explicit — no collision with this trait's `fetch_splits`, so no risk of recursing into it.
51 let raw = self.fetch_splits_raw(&query);
52 futures::pin_mut!(raw);
53 while let Some(split) = raw.next().await {
54 if let Some(action) = map_split(split?) {
55 yield action;
56 }
57 }
58 }
59 }
60}
61
62/// Translate a [`CorporateActionFilter`] into a single Alpaca [`CorporateActionsQuery`] (Alpaca
63/// batches symbols into one comma-joined request).
64fn build_query(filter: &CorporateActionFilter) -> CorporateActionsQuery {
65 let mut query = CorporateActionsQuery::new();
66 if !filter.symbols.is_empty() {
67 query = query.symbols(filter.symbols.iter().map(SmolStr::as_str));
68 }
69 if let Some(start) = filter.start {
70 query = query.start(start);
71 }
72 if let Some(end) = filter.end {
73 query = query.end(end);
74 }
75 query
76}
77
78/// Map a raw [`AlpacaStockSplit`] to a [`CorporateAction`] descriptor via the shared ratio helper.
79/// Returns `None` (and logs) for a degenerate ratio so the caller's stream simply omits it.
80fn map_split(split: AlpacaStockSplit) -> Option<CorporateAction<SmolStr>> {
81 match CorporateActionKind::stock_split(split.new_rate, split.old_rate) {
82 // `effective_date` maps to Alpaca's `ex_date` — the split's market execution date, per the
83 // `StockSplitSource::fetch_splits` contract (NOT `payable_date`; see the impl rustdoc).
84 Some(kind) => Some(CorporateAction::new(
85 SmolStr::from(split.symbol),
86 kind,
87 Some(split.ex_date),
88 )),
89 None => {
90 warn!(
91 symbol = %split.symbol,
92 new_rate = %split.new_rate,
93 old_rate = %split.old_rate,
94 "Alpaca stock split has a degenerate ratio; skipping",
95 );
96 None
97 }
98 }
99}
100
101#[cfg(test)]
102#[allow(clippy::unwrap_used)] // Tests should panic on unexpected values
103mod tests {
104 use super::*;
105 use chrono::NaiveDate;
106 use rust_decimal::Decimal;
107 use rustrade_instrument::corporate_action::SplitRatio;
108
109 fn split(symbol: &str, new_rate: i64, old_rate: i64, ex_date: NaiveDate) -> AlpacaStockSplit {
110 AlpacaStockSplit {
111 symbol: symbol.to_string(),
112 ex_date,
113 new_rate: Decimal::from(new_rate),
114 old_rate: Decimal::from(old_rate),
115 }
116 }
117
118 #[test]
119 fn build_query_joins_symbols_and_dates() {
120 let filter = CorporateActionFilter::new(
121 vec![SmolStr::new("NVDA"), SmolStr::new("AAPL")],
122 NaiveDate::from_ymd_opt(2024, 1, 1),
123 NaiveDate::from_ymd_opt(2024, 12, 31),
124 );
125
126 let query = build_query(&filter);
127 assert_eq!(
128 query.symbols,
129 vec![SmolStr::new("NVDA"), SmolStr::new("AAPL")]
130 );
131 assert_eq!(query.start, filter.start);
132 assert_eq!(query.end, filter.end);
133 }
134
135 #[test]
136 fn build_query_empty_symbols_is_unrestricted() {
137 let filter = CorporateActionFilter::default();
138 let query = build_query(&filter);
139 assert!(query.symbols.is_empty());
140 assert!(query.start.is_none());
141 assert!(query.end.is_none());
142 }
143
144 #[test]
145 fn map_split_forward_ratio() {
146 let action = map_split(split(
147 "NVDA",
148 10,
149 1,
150 NaiveDate::from_ymd_opt(2024, 6, 10).unwrap(),
151 ))
152 .unwrap();
153 assert_eq!(action.instrument, SmolStr::new("NVDA"));
154 assert_eq!(
155 action.kind,
156 CorporateActionKind::StockSplit {
157 ratio: SplitRatio::new(Decimal::from(10)).unwrap()
158 }
159 );
160 }
161
162 #[test]
163 fn map_split_reverse_ratio() {
164 // 1-for-25 reverse → ratio 0.04.
165 let action = map_split(split(
166 "ATRA",
167 1,
168 25,
169 NaiveDate::from_ymd_opt(2024, 6, 20).unwrap(),
170 ))
171 .unwrap();
172 assert_eq!(
173 action.kind,
174 CorporateActionKind::StockSplit {
175 ratio: SplitRatio::new(Decimal::new(4, 2)).unwrap()
176 }
177 );
178 }
179
180 #[test]
181 fn map_split_maps_ex_date_to_effective_date() {
182 // Pins the provenance: `effective_date` is the raw split's `ex_date`. Paired with the
183 // `reference::ex_date_provenance_ignores_payable_date` deser test (which proves the parsed
184 // `ex_date` is the JSON `ex_date`, not `payable_date`), this covers the full chain.
185 let ex_date = NaiveDate::from_ymd_opt(2024, 6, 12).unwrap();
186 let action = map_split(split("APH", 2, 1, ex_date)).unwrap();
187 assert_eq!(action.effective_date, Some(ex_date));
188 }
189
190 #[test]
191 fn map_split_drops_degenerate_ratio() {
192 // old_rate == 0 → division by zero → skipped.
193 assert!(
194 map_split(split(
195 "BAD",
196 1,
197 0,
198 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()
199 ))
200 .is_none()
201 );
202 }
203}