Skip to main content

finance_query/tickers/core/
quotes.rs

1use super::{BatchQuotesResponse, Tickers};
2use crate::error::{FinanceError, Result};
3use crate::format::Both;
4use crate::models::format::Format;
5use crate::models::quote::{Quote, QuoteSummaryResponse};
6use crate::providers::Capability;
7use futures::stream::{self, StreamExt};
8use std::collections::HashMap;
9use std::sync::Arc;
10
11impl Tickers {
12    /// Batch fetch quotes for all symbols.
13    ///
14    /// Dispatches through the configured provider set. When logos are enabled,
15    /// fetches logo URLs in parallel via the Yahoo client.
16    ///
17    /// Use [`TickersBuilder::logo()`](super::TickersBuilder::logo) to enable logo fetching
18    /// for this tickers instance.
19    pub async fn quotes(&self) -> Result<BatchQuotesResponse> {
20        // Fast path: check if all symbols are cached
21        {
22            let cache = self.quote_cache.read().await;
23            if self.all_cached(&cache, self.symbols.iter().cloned()) {
24                let mut response = BatchQuotesResponse::with_capacity(self.symbols.len());
25                for symbol in &self.symbols {
26                    if let Some(entry) = cache.get(symbol) {
27                        response
28                            .quotes
29                            .insert(symbol.to_string(), entry.value.clone());
30                    }
31                }
32                return Ok(response);
33            }
34        }
35
36        let _fetch_guard = self.quotes_fetch.lock().await;
37
38        // Double-check: another task may have fetched while we waited
39        {
40            let cache = self.quote_cache.read().await;
41            if self.all_cached(&cache, self.symbols.iter().cloned()) {
42                let mut response = BatchQuotesResponse::with_capacity(self.symbols.len());
43                for symbol in &self.symbols {
44                    if let Some(entry) = cache.get(symbol) {
45                        response
46                            .quotes
47                            .insert(symbol.to_string(), entry.value.clone());
48                    }
49                }
50                return Ok(response);
51            }
52        }
53
54        let symbol_strings: Vec<String> = self.symbols.iter().map(|s| s.to_string()).collect();
55        let mut response = BatchQuotesResponse::with_capacity(self.symbols.len());
56
57        let (quote_data, logos) = if self.include_logo {
58            // Fire logo fetch in parallel with quote fetch; logos are Yahoo-only
59            let providers_logo = Arc::clone(&self.providers);
60            let syms_logo = symbol_strings.clone();
61            let logo_future = async move {
62                if let Ok(client) = providers_logo.first_yahoo() {
63                    let syms_ref: Vec<&str> = syms_logo.iter().map(String::as_str).collect();
64                    crate::adapters::yahoo::quote::quotes::fetch_with_fields(
65                        &client,
66                        &syms_ref,
67                        Some(&["logoUrl", "companyLogoUrl"]),
68                        true,
69                        true,
70                    )
71                    .await
72                    .ok()
73                } else {
74                    None
75                }
76            };
77
78            let providers_quote = Arc::clone(&self.providers);
79            let syms_quote = symbol_strings.clone();
80            let quote_future = async move {
81                providers_quote
82                    .fetch(Capability::QUOTE, |p| {
83                        let syms = syms_quote.clone();
84                        let p = p.clone();
85                        async move {
86                            let syms_ref: Vec<&str> = syms.iter().map(String::as_str).collect();
87                            p.as_quote()
88                                .ok_or_else(|| {
89                                    p.not_supported(crate::providers::Operation::QuotesBatch)
90                                })?
91                                .fetch_quotes_batch(&syms_ref)
92                                .await
93                        }
94                    })
95                    .await
96            };
97
98            let (batch_result, logo_result) = tokio::join!(quote_future, logo_future);
99            let quote_data = match batch_result {
100                Ok(data) => data,
101                Err(_) => {
102                    self.fetch_quotes_per_symbol(&symbol_strings, &mut response)
103                        .await
104                }
105            };
106            (quote_data, logo_result)
107        } else {
108            let providers = Arc::clone(&self.providers);
109            let syms = symbol_strings.clone();
110            let batch_result = providers
111                .fetch(Capability::QUOTE, |p| {
112                    let syms = syms.clone();
113                    let p = p.clone();
114                    async move {
115                        let syms_ref: Vec<&str> = syms.iter().map(String::as_str).collect();
116                        p.as_quote()
117                            .ok_or_else(|| {
118                                p.not_supported(crate::providers::Operation::QuotesBatch)
119                            })?
120                            .fetch_quotes_batch(&syms_ref)
121                            .await
122                    }
123                })
124                .await;
125            let data = match batch_result {
126                Ok(data) => data,
127                Err(_) => {
128                    self.fetch_quotes_per_symbol(&symbol_strings, &mut response)
129                        .await
130                }
131            };
132            (data, None)
133        };
134
135        let logo_map: HashMap<String, (Option<String>, Option<String>)> = logos
136            .and_then(|l| l.get("quoteResponse")?.get("result")?.as_array().cloned())
137            .map(|results| {
138                results
139                    .iter()
140                    .filter_map(|r| {
141                        let symbol = r.get("symbol")?.as_str()?.to_string();
142                        let logo_url = r.get("logoUrl").and_then(|v| v.as_str()).map(String::from);
143                        let company_logo_url = r
144                            .get("companyLogoUrl")
145                            .and_then(|v| v.as_str())
146                            .map(String::from);
147                        Some((symbol, (logo_url, company_logo_url)))
148                    })
149                    .collect()
150            })
151            .unwrap_or_default();
152
153        let mut parsed_quotes: Vec<(String, Quote)> = Vec::new();
154
155        for (symbol, summary) in quote_data {
156            let logo_url = logo_map.get(&symbol).and_then(|(l, _)| l.clone());
157            let company_logo_url = logo_map.get(&symbol).and_then(|(_, c)| c.clone());
158            let quote = Quote::from_response(&summary, logo_url, company_logo_url);
159            parsed_quotes.push((symbol, quote));
160        }
161
162        for (symbol, quote) in parsed_quotes {
163            response.quotes.insert(symbol, quote);
164        }
165
166        // Translate before caching so cached quotes are already localized
167        // and repeat reads don't re-run the translation backend.
168        #[cfg(feature = "translation")]
169        self.translate_response(&mut response).await?;
170
171        if self.cache_mode.enabled() {
172            let mut cache = self.quote_cache.write().await;
173            for (symbol, quote) in &response.quotes {
174                self.cache_insert(&mut cache, symbol.as_str().into(), quote.clone());
175            }
176        }
177
178        // Track missing symbols
179        for symbol in &self.symbols {
180            let s = &**symbol;
181            if !response.quotes.contains_key(s) && !response.errors.contains_key(s) {
182                response.errors.insert(
183                    symbol.to_string(),
184                    "Symbol not found in response".to_string(),
185                );
186            }
187        }
188
189        Ok(response)
190    }
191
192    /// Fallback for when no provider supports `fetch_quotes_batch`.
193    /// Fetches each symbol individually; failures go into `response.errors`.
194    async fn fetch_quotes_per_symbol(
195        &self,
196        symbols: &[String],
197        response: &mut BatchQuotesResponse,
198    ) -> Vec<(String, QuoteSummaryResponse)> {
199        let futures: Vec<_> = symbols
200            .iter()
201            .map(|sym| {
202                let providers = Arc::clone(&self.providers);
203                let sym = sym.clone();
204                async move {
205                    let result = providers
206                        .fetch(Capability::QUOTE, |p| {
207                            let sym = sym.clone();
208                            let p = p.clone();
209                            async move {
210                                p.as_quote()
211                                    .ok_or_else(|| {
212                                        p.not_supported(crate::providers::Operation::Quote)
213                                    })?
214                                    .fetch_quote(&sym)
215                                    .await
216                            }
217                        })
218                        .await;
219                    (sym, result)
220                }
221            })
222            .collect();
223
224        let results: Vec<_> = stream::iter(futures)
225            .buffer_unordered(self.max_concurrency)
226            .collect()
227            .await;
228
229        let mut successes = Vec::new();
230        for (sym, result) in results {
231            match result {
232                Ok(resp) => successes.push((sym, resp)),
233                Err(e) => {
234                    response.errors.insert(sym, e.to_string());
235                }
236            }
237        }
238        successes
239    }
240
241    /// Get a specific quote by symbol (from cache or fetch all)
242    pub async fn quote<F>(&self, symbol: &str) -> Result<Quote<F>>
243    where
244        F: Format,
245        Quote<Both>: Into<Quote<F>>,
246    {
247        {
248            let cache = self.quote_cache.read().await;
249            if let Some(entry) = cache.get(symbol)
250                && self.is_cache_fresh(Some(entry))
251            {
252                return Ok(entry.value.clone().into());
253            }
254        }
255
256        let response = self.quotes().await?;
257
258        response
259            .quotes
260            .get(symbol)
261            .cloned()
262            .map(Into::into)
263            .ok_or_else(|| FinanceError::SymbolNotFound {
264                symbol: Some(symbol.to_string()),
265                context: response
266                    .errors
267                    .get(symbol)
268                    .cloned()
269                    .unwrap_or_else(|| "Symbol not found".to_string()),
270            })
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[tokio::test]
279    #[ignore = "requires network access"]
280    async fn test_tickers_quotes() {
281        let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await.unwrap();
282        let result = tickers.quotes().await.unwrap();
283
284        assert!(result.success_count() > 0);
285    }
286}