finance_query/tickers/core/corporate.rs
1use super::{BatchCapitalGainsResponse, BatchDividendsResponse, BatchSplitsResponse, Tickers};
2use crate::constants::TimeRange;
3use crate::error::Result;
4use crate::models::chart::events::ChartEvents;
5use crate::providers::Capability;
6use crate::utils::{CacheEntry, filter_by_range};
7use futures::stream::{self, StreamExt};
8use std::sync::Arc;
9
10impl Tickers {
11 /// Batch fetch dividends for all symbols
12 ///
13 /// Returns dividend history for all symbols, filtered by the specified time range.
14 /// Dividends are cached per symbol after the first chart fetch.
15 ///
16 /// # Arguments
17 ///
18 /// * `range` - Time range to filter dividends
19 ///
20 /// # Example
21 ///
22 /// ```no_run
23 /// use finance_query::{Tickers, TimeRange};
24 ///
25 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
26 /// let tickers = Tickers::new(["AAPL", "MSFT"]).await?;
27 /// let dividends = tickers.dividends(TimeRange::OneYear).await?;
28 ///
29 /// for (symbol, divs) in ÷nds.dividends {
30 /// println!("{}: {} dividends", symbol, divs.len());
31 /// }
32 /// # Ok(())
33 /// # }
34 /// ```
35 pub async fn dividends(&self, range: TimeRange) -> Result<BatchDividendsResponse> {
36 let mut response = BatchDividendsResponse::with_capacity(self.symbols.len());
37
38 // Fetch events efficiently (1-day chart request per symbol)
39 self.ensure_events_loaded().await?;
40
41 let events_cache = self.events_cache.read().await;
42
43 for symbol in &self.symbols {
44 if let Some(entry) = events_cache.get(symbol) {
45 let all_dividends = entry.value.to_dividends();
46 let filtered = filter_by_range(all_dividends, range);
47 response.dividends.insert(symbol.to_string(), filtered);
48 } else {
49 response
50 .errors
51 .insert(symbol.to_string(), "No events data available".to_string());
52 }
53 }
54
55 Ok(response)
56 }
57
58 /// Batch fetch stock splits for all symbols
59 ///
60 /// Returns stock split history for all symbols, filtered by the specified time range.
61 /// Splits are cached per symbol after the first chart fetch.
62 ///
63 /// # Arguments
64 ///
65 /// * `range` - Time range to filter splits
66 ///
67 /// # Example
68 ///
69 /// ```no_run
70 /// use finance_query::{Tickers, TimeRange};
71 ///
72 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
73 /// let tickers = Tickers::new(["NVDA", "TSLA"]).await?;
74 /// let splits = tickers.splits(TimeRange::FiveYears).await?;
75 ///
76 /// for (symbol, sp) in &splits.splits {
77 /// for split in sp {
78 /// println!("{}: {}", symbol, split.ratio);
79 /// }
80 /// }
81 /// # Ok(())
82 /// # }
83 /// ```
84 pub async fn splits(&self, range: TimeRange) -> Result<BatchSplitsResponse> {
85 let mut response = BatchSplitsResponse::with_capacity(self.symbols.len());
86
87 // Fetch events efficiently (1-day chart request per symbol)
88 self.ensure_events_loaded().await?;
89
90 let events_cache = self.events_cache.read().await;
91
92 for symbol in &self.symbols {
93 if let Some(entry) = events_cache.get(symbol) {
94 let all_splits = entry.value.to_splits();
95 let filtered = filter_by_range(all_splits, range);
96 response.splits.insert(symbol.to_string(), filtered);
97 } else {
98 response
99 .errors
100 .insert(symbol.to_string(), "No events data available".to_string());
101 }
102 }
103
104 Ok(response)
105 }
106
107 /// Batch fetch capital gains for all symbols
108 ///
109 /// Returns capital gain distribution history for all symbols, filtered by the
110 /// specified time range. This is primarily relevant for mutual funds and ETFs.
111 /// Capital gains are cached per symbol after the first chart fetch.
112 ///
113 /// # Arguments
114 ///
115 /// * `range` - Time range to filter capital gains
116 ///
117 /// # Example
118 ///
119 /// ```no_run
120 /// use finance_query::{Tickers, TimeRange};
121 ///
122 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
123 /// let tickers = Tickers::new(["VFIAX", "VTI"]).await?;
124 /// let gains = tickers.capital_gains(TimeRange::TwoYears).await?;
125 ///
126 /// for (symbol, cg) in &gains.capital_gains {
127 /// println!("{}: {} distributions", symbol, cg.len());
128 /// }
129 /// # Ok(())
130 /// # }
131 /// ```
132 pub async fn capital_gains(&self, range: TimeRange) -> Result<BatchCapitalGainsResponse> {
133 let mut response = BatchCapitalGainsResponse::with_capacity(self.symbols.len());
134
135 // Fetch events efficiently (1-day chart request per symbol)
136 self.ensure_events_loaded().await?;
137
138 let events_cache = self.events_cache.read().await;
139
140 for symbol in &self.symbols {
141 if let Some(entry) = events_cache.get(symbol) {
142 let all_gains = entry.value.to_capital_gains();
143 let filtered = filter_by_range(all_gains, range);
144 response.capital_gains.insert(symbol.to_string(), filtered);
145 } else {
146 response
147 .errors
148 .insert(symbol.to_string(), "No events data available".to_string());
149 }
150 }
151
152 Ok(response)
153 }
154
155 /// Aggregate upcoming financial events across all symbols into a single
156 /// time-sorted list.
157 ///
158 /// Merges earnings, dividend, and standard-monthly options-expiration events
159 /// for every symbol — plus, with the `fred` feature, major economic releases
160 /// (CPI, NFP, GDP, …) — within the forward window `[now, now + range]`,
161 /// sorted ascending by timestamp.
162 ///
163 /// Best-effort per symbol: a symbol whose quote or options fetch fails
164 /// simply contributes no events rather than failing the whole call.
165 ///
166 /// # Example
167 ///
168 /// ```no_run
169 /// use finance_query::{Tickers, TimeRange};
170 ///
171 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
172 /// let tickers = Tickers::new(["AAPL", "MSFT", "TSLA"]).await?;
173 /// let events = tickers.calendar(TimeRange::OneMonth).await?;
174 /// for e in &events {
175 /// println!("{} {:?} {:?}", e.date, e.symbol, e.event);
176 /// }
177 /// # Ok(())
178 /// # }
179 /// ```
180 pub async fn calendar(
181 &self,
182 range: TimeRange,
183 ) -> Result<Vec<crate::models::calendar::CalendarEvent>> {
184 let now = chrono::Utc::now().timestamp();
185 let window = (now, now + range.approx_duration_secs());
186
187 let symbol_strings: Vec<String> = self.symbols.iter().map(|s| s.to_string()).collect();
188 let providers = Arc::clone(&self.providers);
189
190 let per_symbol = symbol_strings.into_iter().map(|sym| {
191 let providers = Arc::clone(&providers);
192 async move {
193 let quote = {
194 let sym = sym.clone();
195 providers
196 .fetch(Capability::QUOTE, move |p| {
197 let sym = sym.clone();
198 let p = p.clone();
199 async move {
200 p.as_quote()
201 .ok_or_else(|| {
202 p.not_supported(crate::providers::Operation::Quote)
203 })?
204 .fetch_quote(&sym)
205 .await
206 }
207 })
208 .await
209 };
210 (sym, quote.ok().and_then(|q| q.calendar_events))
211 }
212 });
213
214 let per_symbol_fut = stream::iter(per_symbol)
215 .buffer_unordered(self.max_concurrency)
216 .collect::<Vec<_>>();
217
218 // Options go through `self.options`, so a chain already in the options
219 // cache is reused instead of refetched. The FRED economic-release fetch
220 // is independent of both, so run all of them concurrently.
221 #[cfg(feature = "fred")]
222 let (per_symbol_quotes, options_resp, releases) = tokio::join!(
223 per_symbol_fut,
224 self.options(None),
225 crate::adapters::fred::release_dates()
226 );
227 #[cfg(not(feature = "fred"))]
228 let (per_symbol_quotes, options_resp) = tokio::join!(per_symbol_fut, self.options(None));
229
230 let options_map = options_resp.map(|r| r.options).unwrap_or_default();
231
232 let mut events: Vec<crate::models::calendar::CalendarEvent> = per_symbol_quotes
233 .into_iter()
234 .flat_map(|(sym, calendar_events)| {
235 crate::models::calendar::build_symbol_events(
236 &sym,
237 calendar_events.as_ref(),
238 options_map.get(&sym),
239 window,
240 )
241 })
242 .collect();
243
244 #[cfg(feature = "fred")]
245 if let Ok(releases) = releases {
246 events.extend(crate::models::calendar::build_economic_events(
247 releases, window,
248 ));
249 }
250
251 crate::models::calendar::sort_events(&mut events);
252 Ok(events)
253 }
254
255 /// Ensures events are loaded for all symbols using chart requests.
256 ///
257 /// Fetches events concurrently for symbols that don't have cached events.
258 /// Uses `TimeRange::Max` to get full event history (Yahoo returns all
259 /// dividends/splits/capital gains regardless of chart range).
260 ///
261 /// Events are always stored regardless of the cache mode because they are
262 /// derived data (not a TTL-bounded cache), so they persist for the lifetime
263 /// of the `Tickers` instance even under [`CacheMode::Off`](crate::utils::CacheMode::Off).
264 pub(super) async fn ensure_events_loaded(&self) -> Result<()> {
265 if self.events_missing().await.is_empty() {
266 return Ok(());
267 }
268
269 let _fetch_guard = self.events_fetch.lock().await;
270
271 // Double-check: another task may have fetched while we waited
272 let symbols_to_fetch = self.events_missing().await;
273 if symbols_to_fetch.is_empty() {
274 return Ok(());
275 }
276
277 // Fetch events concurrently for all symbols that need it via provider dispatch
278 let futures: Vec<_> = symbols_to_fetch
279 .iter()
280 .map(|symbol| {
281 let providers = Arc::clone(&self.providers);
282 let symbol = Arc::clone(symbol);
283 async move {
284 let sym = symbol.to_string();
285 let result = providers
286 .fetch(Capability::CORPORATE, |p| {
287 let sym = sym.clone();
288 let p = p.clone();
289 async move {
290 p.as_corporate()
291 .ok_or_else(|| {
292 p.not_supported(crate::providers::Operation::Events)
293 })?
294 .fetch_events(&sym)
295 .await
296 }
297 })
298 .await;
299 (symbol, result)
300 }
301 })
302 .collect();
303
304 let results: Vec<_> = stream::iter(futures)
305 .buffer_unordered(self.max_concurrency)
306 .collect()
307 .await;
308
309 let mut parsed_events: Vec<(Arc<str>, ChartEvents)> = Vec::new();
310
311 for (symbol, result) in results {
312 if let Ok(events_data) = result {
313 parsed_events.push((symbol, events_data));
314 }
315 }
316
317 // Always store events — they are derived data, not TTL-bounded cache
318 if !parsed_events.is_empty() {
319 let mut events_cache = self.events_cache.write().await;
320 for (symbol, events) in parsed_events {
321 events_cache.insert(symbol, CacheEntry::new(events));
322 }
323 }
324
325 Ok(())
326 }
327
328 /// Symbols with no entry in the events cache (existence check, not TTL-based).
329 async fn events_missing(&self) -> Vec<Arc<str>> {
330 let cache = self.events_cache.read().await;
331 self.symbols
332 .iter()
333 .filter(|sym| !cache.contains_key(*sym))
334 .cloned()
335 .collect()
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[tokio::test]
344 #[ignore = "requires network access"]
345 async fn test_tickers_dividends() {
346 let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
347 let result = tickers.dividends(TimeRange::OneYear).await.unwrap();
348
349 assert!(result.success_count() > 0);
350
351 // Verify dividend data structure
352 if let Some(dividends) = result.dividends.get("AAPL")
353 && !dividends.is_empty()
354 {
355 let div = ÷nds[0];
356 assert!(div.timestamp > 0);
357 assert!(div.amount > 0.0);
358 }
359 }
360
361 #[tokio::test]
362 #[ignore = "requires network access"]
363 async fn test_tickers_splits() {
364 let tickers = Tickers::new(["NVDA", "TSLA"]).await.unwrap();
365 let result = tickers.splits(TimeRange::FiveYears).await.unwrap();
366
367 // Note: Not all symbols have splits, so we just check for successful response
368 assert!(result.success_count() > 0);
369
370 // If there are splits, verify structure
371 for splits in result.splits.values() {
372 for split in splits {
373 assert!(split.timestamp > 0);
374 assert!(split.numerator > 0.0);
375 assert!(split.denominator > 0.0);
376 assert!(!split.ratio.is_empty());
377 }
378 }
379 }
380
381 #[tokio::test]
382 #[ignore = "requires network access"]
383 async fn test_tickers_capital_gains() {
384 let tickers = Tickers::new(["VFIAX", "VTI"]).await.unwrap();
385 let result = tickers.capital_gains(TimeRange::TwoYears).await.unwrap();
386
387 // Note: Not all symbols have capital gains distributions
388 assert!(result.success_count() > 0);
389
390 // If there are capital gains, verify structure
391 for gains in result.capital_gains.values() {
392 for gain in gains {
393 assert!(gain.timestamp > 0);
394 assert!(gain.amount >= 0.0);
395 }
396 }
397 }
398}