Skip to main content

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 &dividends.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 = &dividends[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}