Skip to main content

finance_query/tickers/core/
fundamentals.rs

1use super::{
2    BatchFinancialsResponse, BatchNewsResponse, BatchOptionsResponse, BatchRecommendationsResponse,
3    Tickers,
4};
5use crate::constants::{Frequency, StatementType};
6use crate::error::Result;
7use crate::models::corporate::news::News;
8use crate::providers::Capability;
9use crate::providers::types::recommendation_from_similar;
10use crate::tickers::macros::batch_fetch_cached;
11use futures::stream::StreamExt;
12
13impl Tickers {
14    /// Batch fetch financial statements for all symbols
15    ///
16    /// Fetches the specified financial statement type for all symbols concurrently.
17    /// Financial statements are cached per (symbol, statement_type, frequency) tuple.
18    ///
19    /// # Arguments
20    ///
21    /// * `statement_type` - Type of statement (Income, Balance, CashFlow)
22    /// * `frequency` - Annual or Quarterly
23    ///
24    /// # Example
25    ///
26    /// ```no_run
27    /// use finance_query::{Tickers, StatementType, Frequency};
28    ///
29    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
30    /// let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await?;
31    /// let financials = tickers.financials(StatementType::Income, Frequency::Annual).await?;
32    ///
33    /// for (symbol, stmt) in &financials.financials {
34    ///     if let Some(revenue) = stmt.statement.get("TotalRevenue") {
35    ///         println!("{}: {:?}", symbol, revenue);
36    ///     }
37    /// }
38    /// # Ok(())
39    /// # }
40    /// ```
41    pub async fn financials(
42        &self,
43        statement_type: StatementType,
44        frequency: Frequency,
45    ) -> Result<BatchFinancialsResponse> {
46        batch_fetch_cached!(self;
47            cache: financials_cache,
48            guard: map(financials_fetch, (statement_type, frequency)),
49            key: |s| (s.clone(), statement_type, frequency),
50            response: BatchFinancialsResponse.financials,
51            fetch: |providers, symbol| {
52                let sym = symbol.to_string();
53                providers.fetch(Capability::FUNDAMENTALS, move |p| {
54                    let sym = sym.clone();
55                    let p = p.clone();
56                    async move {
57                        p.as_fundamentals()
58                            .ok_or_else(|| p.not_supported(crate::providers::Operation::Financials))?
59                            .fetch_financials(&sym, statement_type, frequency)
60                            .await
61                    }
62                }).await
63            },
64        )
65    }
66
67    /// Batch fetch news articles for all symbols
68    ///
69    /// Fetches recent news articles for all symbols concurrently using scrapers.
70    /// News articles are cached per symbol.
71    ///
72    /// # Example
73    ///
74    /// ```no_run
75    /// use finance_query::Tickers;
76    ///
77    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
78    /// let tickers = Tickers::new(["AAPL", "MSFT"]).await?;
79    /// let news = tickers.news().await?;
80    ///
81    /// for (symbol, articles) in &news.news {
82    ///     println!("{}: {} articles", symbol, articles.len());
83    ///     for article in articles.iter().take(3) {
84    ///         println!("  - {}", article.title);
85    ///     }
86    /// }
87    /// # Ok(())
88    /// # }
89    /// ```
90    pub async fn news(&self) -> Result<BatchNewsResponse> {
91        batch_fetch_cached!(self;
92            cache: news_cache,
93            guard: simple(news_fetch),
94            key: |s| s.clone(),
95            response: BatchNewsResponse.news,
96            fetch: |providers, symbol| {
97                let sym = symbol.to_string();
98                providers.fetch(Capability::CORPORATE, move |p| {
99                    let sym = sym.clone();
100                    let p = p.clone();
101                    async move {
102                        p.as_corporate()
103                            .ok_or_else(|| p.not_supported(crate::providers::Operation::News))?
104                            .fetch_news(&sym)
105                            .await
106                            .map(|data| data.into_iter().collect::<Vec<News>>())
107                    }
108                }).await
109            },
110        )
111    }
112
113    /// Batch fetch recommendations for all symbols
114    ///
115    /// Fetches analyst recommendations and similar stocks for all symbols concurrently.
116    /// Recommendations are cached per (symbol, limit) tuple — different limits
117    /// produce different API responses and are cached independently.
118    ///
119    /// # Arguments
120    ///
121    /// * `limit` - Maximum number of similar stocks to return per symbol
122    ///
123    /// # Example
124    ///
125    /// ```no_run
126    /// use finance_query::Tickers;
127    ///
128    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
129    /// let tickers = Tickers::new(["AAPL", "MSFT"]).await?;
130    /// let recommendations = tickers.recommendations(10).await?;
131    ///
132    /// for (symbol, rec) in &recommendations.recommendations {
133    ///     println!("{}: {} recommendations", symbol, rec.count());
134    ///     for similar in &rec.recommendations {
135    ///         println!("  - {}: score {}", similar.symbol, similar.score);
136    ///     }
137    /// }
138    /// # Ok(())
139    /// # }
140    /// ```
141    pub async fn recommendations(&self, limit: u32) -> Result<BatchRecommendationsResponse> {
142        batch_fetch_cached!(self;
143            cache: recommendations_cache,
144            guard: map(recommendations_fetch, limit),
145            key: |s| (s.clone(), limit),
146            response: BatchRecommendationsResponse.recommendations,
147            fetch: |providers, symbol| {
148                let sym = symbol.to_string();
149                providers.fetch(Capability::CORPORATE, move |p| {
150                    let sym = sym.clone();
151                    let p = p.clone();
152                    async move {
153                        let items = p
154                            .as_corporate()
155                            .ok_or_else(|| {
156                                p.not_supported(crate::providers::Operation::Recommendations)
157                            })?
158                            .fetch_similar_symbols(&sym, limit)
159                            .await?;
160                        Ok(recommendation_from_similar(
161                            sym,
162                            Some(p.id()),
163                            items,
164                            Some(limit),
165                        ))
166                    }
167                }).await
168            },
169        )
170    }
171
172    /// Batch fetch options chains for all symbols
173    ///
174    /// Fetches options chains for the specified expiration date for all symbols concurrently.
175    /// Options are cached per (symbol, date) tuple.
176    ///
177    /// # Arguments
178    ///
179    /// * `date` - Optional expiration date (Unix timestamp). If None, fetches nearest expiration.
180    ///
181    /// # Example
182    ///
183    /// ```no_run
184    /// use finance_query::Tickers;
185    ///
186    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
187    /// let tickers = Tickers::new(["AAPL", "MSFT"]).await?;
188    /// let options = tickers.options(None).await?;
189    ///
190    /// for (symbol, opts) in &options.options {
191    ///     println!("{}: {} expirations", symbol, opts.expiration_dates().len());
192    /// }
193    /// # Ok(())
194    /// # }
195    /// ```
196    pub async fn options(&self, date: Option<i64>) -> Result<BatchOptionsResponse> {
197        batch_fetch_cached!(self;
198            cache: options_cache,
199            guard: map(options_fetch, date),
200            key: |s| (s.clone(), date),
201            response: BatchOptionsResponse.options,
202            fetch: |providers, symbol| {
203                let sym = symbol.to_string();
204                providers.fetch(Capability::OPTIONS, move |p| {
205                    let sym = sym.clone();
206                    let p = p.clone();
207                    async move {
208                        p.as_options()
209                            .ok_or_else(|| p.not_supported(crate::providers::Operation::Options))?
210                            .fetch_options(&sym, date)
211                            .await
212                    }
213                }).await
214            },
215        )
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[tokio::test]
224    #[ignore = "requires network access"]
225    async fn test_tickers_financials() {
226        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
227        let result = tickers
228            .financials(StatementType::Income, Frequency::Annual)
229            .await
230            .unwrap();
231
232        assert!(result.success_count() > 0);
233
234        // Verify financial statement structure
235        for (symbol, stmt) in &result.financials {
236            assert_eq!(stmt.symbol, *symbol);
237            assert_eq!(stmt.statement_type, "income");
238            assert_eq!(stmt.frequency, "annual");
239            assert!(!stmt.statement.is_empty());
240
241            // Common income statement fields
242            if let Some(revenue) = stmt.statement.get("TotalRevenue") {
243                assert!(!revenue.is_empty());
244            }
245        }
246    }
247
248    #[tokio::test]
249    #[ignore = "requires network access"]
250    async fn test_tickers_news() {
251        let tickers = Tickers::new(["AAPL", "TSLA"]).await.unwrap();
252        let result = tickers.news().await.unwrap();
253
254        assert!(result.success_count() > 0);
255
256        // Verify news structure
257        for articles in result.news.values() {
258            if !articles.is_empty() {
259                let article = &articles[0];
260                assert!(!article.title.is_empty());
261                assert!(!article.link.is_empty());
262                assert!(!article.source.is_empty());
263            }
264        }
265    }
266
267    #[tokio::test]
268    #[ignore = "requires network access"]
269    async fn test_tickers_recommendations() {
270        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
271        let result = tickers.recommendations(5).await.unwrap();
272
273        assert!(result.success_count() > 0);
274
275        // Verify recommendations structure
276        for (symbol, rec) in &result.recommendations {
277            assert_eq!(rec.symbol, *symbol);
278            assert!(rec.count() > 0);
279            for similar in &rec.recommendations {
280                assert!(!similar.symbol.is_empty());
281            }
282        }
283    }
284
285    #[tokio::test]
286    #[ignore = "requires network access"]
287    async fn test_tickers_options() {
288        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
289        let result = tickers.options(None).await.unwrap();
290
291        assert!(result.success_count() > 0);
292
293        // Verify options structure
294        for opts in result.options.values() {
295            assert!(!opts.expiration_dates().is_empty());
296        }
297    }
298}