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}