rustrade_data/exchange/massive/
corporate_action.rs1use async_stream::try_stream;
10use futures::{Stream, StreamExt};
11use smol_str::SmolStr;
12use tracing::warn;
13
14use rustrade_instrument::corporate_action::{CorporateAction, CorporateActionKind};
15use rustrade_integration::corporate_action::{CorporateActionFilter, StockSplitSource};
16
17use super::error::MassiveError;
18use super::reference::{SplitQuery, StockSplit};
19use super::rest::MassiveRestClient;
20
21impl StockSplitSource for MassiveRestClient {
22 type Error = MassiveError;
23
24 fn fetch_splits(
37 &self,
38 filter: &CorporateActionFilter,
39 ) -> impl Stream<Item = Result<CorporateAction<SmolStr>, Self::Error>> + Send {
40 let queries = build_split_queries(filter);
42
43 try_stream! {
44 for query in &queries {
45 let raw = self.fetch_splits_raw(query);
49 futures::pin_mut!(raw);
50 while let Some(split) = raw.next().await {
51 if let Some(action) = map_split(split?) {
52 yield action;
53 }
54 }
55 }
56 }
57 }
58}
59
60fn build_split_queries(filter: &CorporateActionFilter) -> Vec<SplitQuery> {
63 let with_dates = |mut query: SplitQuery| {
67 if let Some(start) = filter.start {
68 query = query.execution_date_gte(start);
69 }
70 if let Some(end) = filter.end {
71 query = query.execution_date_lte(end);
72 }
73 query
74 };
75
76 if filter.symbols.is_empty() {
77 vec![with_dates(SplitQuery::new())]
78 } else {
79 filter
80 .symbols
81 .iter()
82 .map(|symbol| with_dates(SplitQuery::new().ticker(symbol.as_str())))
83 .collect()
84 }
85}
86
87fn map_split(split: StockSplit) -> Option<CorporateAction<SmolStr>> {
90 match CorporateActionKind::stock_split(split.split_to, split.split_from) {
91 Some(kind) => Some(CorporateAction::new(
94 SmolStr::from(split.ticker),
95 kind,
96 Some(split.execution_date),
97 )),
98 None => {
99 warn!(
100 ticker = %split.ticker,
101 split_to = %split.split_to,
102 split_from = %split.split_from,
103 "Massive stock split has a degenerate ratio; skipping",
104 );
105 None
106 }
107 }
108}
109
110#[cfg(test)]
111#[allow(clippy::unwrap_used)] mod tests {
113 use super::*;
114 use chrono::NaiveDate;
115 use rust_decimal::Decimal;
116 use rustrade_instrument::corporate_action::SplitRatio;
117
118 #[test]
119 fn build_split_queries_fans_out_per_symbol() {
120 let filter = CorporateActionFilter::new(
121 vec![SmolStr::new("AAPL"), SmolStr::new("NVDA")],
122 NaiveDate::from_ymd_opt(2020, 1, 1),
123 NaiveDate::from_ymd_opt(2024, 12, 31),
124 );
125
126 let queries = build_split_queries(&filter);
127 assert_eq!(queries.len(), 2);
128 assert_eq!(queries[0].ticker.as_deref(), Some("AAPL"));
129 assert_eq!(queries[1].ticker.as_deref(), Some("NVDA"));
130 assert_eq!(queries[0].execution_date_gte, filter.start);
132 assert_eq!(queries[0].execution_date_lte, filter.end);
133 }
134
135 #[test]
136 fn build_split_queries_empty_symbols_is_one_unfiltered_query() {
137 let filter = CorporateActionFilter::new(vec![], NaiveDate::from_ymd_opt(2023, 1, 1), None);
138
139 let queries = build_split_queries(&filter);
140 assert_eq!(queries.len(), 1);
141 assert_eq!(queries[0].ticker, None);
142 assert_eq!(queries[0].execution_date_gte, filter.start);
143 assert_eq!(queries[0].execution_date_lte, None);
144 }
145
146 #[test]
147 fn map_split_computes_forward_ratio() {
148 let split = StockSplit {
149 ticker: "AAPL".to_string(),
150 execution_date: NaiveDate::from_ymd_opt(2020, 8, 31).unwrap(),
151 split_to: Decimal::from(4),
152 split_from: Decimal::from(1),
153 };
154
155 let action = map_split(split).unwrap();
156 assert_eq!(action.instrument, SmolStr::new("AAPL"));
157 assert_eq!(
158 action.kind,
159 CorporateActionKind::StockSplit {
160 ratio: SplitRatio::new(Decimal::from(4)).unwrap()
161 }
162 );
163 assert_eq!(action.effective_date, NaiveDate::from_ymd_opt(2020, 8, 31));
165 }
166
167 #[test]
168 fn map_split_drops_degenerate_ratio() {
169 let split = StockSplit {
170 ticker: "BAD".to_string(),
171 execution_date: NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
172 split_to: Decimal::from(1),
173 split_from: Decimal::ZERO,
174 };
175 assert!(map_split(split).is_none());
176 }
177
178 #[tokio::test]
179 async fn fetch_splits_via_trait_object_is_stream_of_descriptors() {
180 struct OneSplit;
182 impl StockSplitSource for OneSplit {
183 type Error = std::convert::Infallible;
184 fn fetch_splits(
185 &self,
186 _filter: &CorporateActionFilter,
187 ) -> impl Stream<Item = Result<CorporateAction<SmolStr>, Self::Error>> + Send
188 {
189 futures::stream::iter([Ok(CorporateAction::new(
190 SmolStr::new("TSLA"),
191 CorporateActionKind::StockSplit {
192 ratio: SplitRatio::new(Decimal::from(3)).unwrap(),
193 },
194 NaiveDate::from_ymd_opt(2022, 8, 25),
195 ))])
196 }
197 }
198
199 let actions: Vec<_> = OneSplit
200 .fetch_splits(&CorporateActionFilter::default())
201 .collect()
202 .await;
203 assert_eq!(actions.len(), 1);
204 assert_eq!(
205 actions[0].as_ref().unwrap().instrument,
206 SmolStr::new("TSLA")
207 );
208 }
209}