finance_query/tickers/
core.rs

1//! Tickers implementation for batch operations on multiple symbols.
2//!
3//! Optimizes data fetching by using batch endpoints and concurrent requests.
4
5use crate::client::{ClientConfig, YahooClient};
6use crate::constants::{Interval, TimeRange};
7use crate::error::{Result, YahooError};
8use crate::models::chart::Chart;
9use crate::models::chart::response::ChartResponse;
10use crate::models::chart::result::ChartResult;
11use crate::models::quote::Quote;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::Arc;
15use std::time::Duration;
16use tokio::sync::RwLock;
17
18/// Cache key for chart data: (symbol, interval, range)
19type ChartCacheKey = (String, Interval, TimeRange);
20
21/// Chart cache type
22type ChartCache = Arc<RwLock<HashMap<ChartCacheKey, ChartResult>>>;
23
24/// Quote cache type
25type QuoteCache = Arc<RwLock<HashMap<String, Quote>>>;
26
27/// Response containing quotes for multiple symbols.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30#[non_exhaustive]
31pub struct BatchQuotesResponse {
32    /// Successfully fetched quotes, keyed by symbol
33    pub quotes: HashMap<String, Quote>,
34    /// Symbols that failed to fetch, with error messages
35    pub errors: HashMap<String, String>,
36}
37
38impl BatchQuotesResponse {
39    pub(crate) fn new() -> Self {
40        Self {
41            quotes: HashMap::new(),
42            errors: HashMap::new(),
43        }
44    }
45
46    /// Number of successfully fetched quotes
47    pub fn success_count(&self) -> usize {
48        self.quotes.len()
49    }
50
51    /// Number of failed symbols
52    pub fn error_count(&self) -> usize {
53        self.errors.len()
54    }
55
56    /// Check if all symbols were successful
57    pub fn all_successful(&self) -> bool {
58        self.errors.is_empty()
59    }
60}
61
62/// Response containing charts for multiple symbols.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65#[non_exhaustive]
66pub struct BatchChartsResponse {
67    /// Successfully fetched charts, keyed by symbol
68    pub charts: HashMap<String, Chart>,
69    /// Symbols that failed to fetch, with error messages
70    pub errors: HashMap<String, String>,
71}
72
73impl BatchChartsResponse {
74    pub(crate) fn new() -> Self {
75        Self {
76            charts: HashMap::new(),
77            errors: HashMap::new(),
78        }
79    }
80
81    /// Number of successfully fetched charts
82    pub fn success_count(&self) -> usize {
83        self.charts.len()
84    }
85
86    /// Number of failed symbols
87    pub fn error_count(&self) -> usize {
88        self.errors.len()
89    }
90
91    /// Check if all symbols were successful
92    pub fn all_successful(&self) -> bool {
93        self.errors.is_empty()
94    }
95}
96
97/// Builder for Tickers
98pub struct TickersBuilder {
99    symbols: Vec<String>,
100    config: ClientConfig,
101}
102
103impl TickersBuilder {
104    fn new<S, I>(symbols: I) -> Self
105    where
106        S: Into<String>,
107        I: IntoIterator<Item = S>,
108    {
109        Self {
110            symbols: symbols.into_iter().map(|s| s.into()).collect(),
111            config: ClientConfig::default(),
112        }
113    }
114
115    /// Set the region (automatically sets correct lang and region code)
116    pub fn region(mut self, region: crate::constants::Region) -> Self {
117        self.config.lang = region.lang().to_string();
118        self.config.region = region.region().to_string();
119        self
120    }
121
122    /// Set the language code (e.g., "en-US", "ja-JP", "de-DE")
123    pub fn lang(mut self, lang: impl Into<String>) -> Self {
124        self.config.lang = lang.into();
125        self
126    }
127
128    /// Set the region code (e.g., "US", "JP", "DE")
129    pub fn region_code(mut self, region: impl Into<String>) -> Self {
130        self.config.region = region.into();
131        self
132    }
133
134    /// Set the HTTP request timeout
135    pub fn timeout(mut self, timeout: Duration) -> Self {
136        self.config.timeout = timeout;
137        self
138    }
139
140    /// Set the proxy URL
141    pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
142        self.config.proxy = Some(proxy.into());
143        self
144    }
145
146    /// Set a complete ClientConfig
147    pub fn config(mut self, config: ClientConfig) -> Self {
148        self.config = config;
149        self
150    }
151
152    /// Build the Tickers instance
153    pub async fn build(self) -> Result<Tickers> {
154        let client = Arc::new(YahooClient::new(self.config).await?);
155
156        Ok(Tickers {
157            symbols: self.symbols,
158            client,
159            quote_cache: Arc::new(RwLock::new(HashMap::new())),
160            chart_cache: Arc::new(RwLock::new(HashMap::new())),
161        })
162    }
163}
164
165/// Multi-symbol ticker for efficient batch operations.
166///
167/// `Tickers` optimizes data fetching for multiple symbols by:
168/// - Using batch endpoints where available (e.g., /v7/finance/quote)
169/// - Fetching concurrently when batch endpoints don't exist
170/// - Sharing a single authenticated client across all symbols
171/// - Caching results per symbol
172///
173/// # Example
174///
175/// ```no_run
176/// use finance_query::Tickers;
177///
178/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
179/// // Create tickers for multiple symbols
180/// let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await?;
181///
182/// // Batch fetch all quotes (single API call)
183/// let quotes = tickers.quotes(false).await?;
184/// for (symbol, quote) in &quotes.quotes {
185///     let price = quote.regular_market_price.as_ref().and_then(|v| v.raw).unwrap_or(0.0);
186///     println!("{}: ${:.2}", symbol, price);
187/// }
188///
189/// // Fetch charts concurrently
190/// use finance_query::{Interval, TimeRange};
191/// let charts = tickers.charts(Interval::OneDay, TimeRange::OneMonth).await?;
192/// # Ok(())
193/// # }
194/// ```
195pub struct Tickers {
196    symbols: Vec<String>,
197    client: Arc<YahooClient>,
198    quote_cache: QuoteCache,
199    chart_cache: ChartCache,
200}
201
202impl Tickers {
203    /// Creates new tickers with default configuration
204    ///
205    /// # Arguments
206    ///
207    /// * `symbols` - Iterable of stock symbols (e.g., `["AAPL", "MSFT"]`)
208    ///
209    /// # Example
210    ///
211    /// ```no_run
212    /// use finance_query::Tickers;
213    ///
214    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
215    /// let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await?;
216    /// # Ok(())
217    /// # }
218    /// ```
219    pub async fn new<S, I>(symbols: I) -> Result<Self>
220    where
221        S: Into<String>,
222        I: IntoIterator<Item = S>,
223    {
224        Self::builder(symbols).build().await
225    }
226
227    /// Creates a new builder for Tickers
228    pub fn builder<S, I>(symbols: I) -> TickersBuilder
229    where
230        S: Into<String>,
231        I: IntoIterator<Item = S>,
232    {
233        TickersBuilder::new(symbols)
234    }
235
236    /// Returns the symbols this tickers instance manages
237    pub fn symbols(&self) -> &[String] {
238        &self.symbols
239    }
240
241    /// Number of symbols
242    pub fn len(&self) -> usize {
243        self.symbols.len()
244    }
245
246    /// Check if empty
247    pub fn is_empty(&self) -> bool {
248        self.symbols.is_empty()
249    }
250
251    /// Batch fetch quotes for all symbols
252    ///
253    /// Uses /v7/finance/quote endpoint - fetches all symbols in a single API call.
254    /// When `include_logo` is true, makes a parallel call for logo URLs.
255    ///
256    /// # Arguments
257    ///
258    /// * `include_logo` - Whether to fetch company logo URLs
259    pub async fn quotes(&self, include_logo: bool) -> Result<BatchQuotesResponse> {
260        // Check cache
261        {
262            let cache = self.quote_cache.read().await;
263            if self.symbols.iter().all(|s| cache.contains_key(s)) {
264                let mut response = BatchQuotesResponse::new();
265                for symbol in &self.symbols {
266                    if let Some(quote) = cache.get(symbol) {
267                        response.quotes.insert(symbol.clone(), quote.clone());
268                    }
269                }
270                return Ok(response);
271            }
272        }
273
274        // Fetch batch quotes
275        let symbols_ref: Vec<&str> = self.symbols.iter().map(|s| s.as_str()).collect();
276
277        // Yahoo requires separate calls for quotes vs logos
278        // When include_logo=true, fetch both in parallel
279        let (json, logos) = if include_logo {
280            let quote_future = crate::endpoints::quotes::fetch_with_fields(
281                &self.client,
282                &symbols_ref,
283                None,  // all fields
284                true,  // formatted
285                false, // no logo params for main call
286            );
287            let logo_future = crate::endpoints::quotes::fetch_with_fields(
288                &self.client,
289                &symbols_ref,
290                Some(&["logoUrl", "companyLogoUrl"]), // only logo fields
291                true,
292                true, // include logo params
293            );
294            let (quote_result, logo_result) = tokio::join!(quote_future, logo_future);
295            (quote_result?, logo_result.ok())
296        } else {
297            let json = crate::endpoints::quotes::fetch_with_fields(
298                &self.client,
299                &symbols_ref,
300                None,
301                true,
302                false,
303            )
304            .await?;
305            (json, None)
306        };
307
308        // Build logo lookup map if we have logos
309        let logo_map: std::collections::HashMap<String, (Option<String>, Option<String>)> = logos
310            .and_then(|l| l.get("quoteResponse")?.get("result")?.as_array().cloned())
311            .map(|results| {
312                results
313                    .iter()
314                    .filter_map(|r| {
315                        let symbol = r.get("symbol")?.as_str()?.to_string();
316                        let logo_url = r
317                            .get("logoUrl")
318                            .and_then(|v| v.as_str())
319                            .map(|s| s.to_string());
320                        let company_logo_url = r
321                            .get("companyLogoUrl")
322                            .and_then(|v| v.as_str())
323                            .map(|s| s.to_string());
324                        Some((symbol, (logo_url, company_logo_url)))
325                    })
326                    .collect()
327            })
328            .unwrap_or_default();
329
330        // Parse response
331        let mut response = BatchQuotesResponse::new();
332
333        if let Some(quote_response) = json.get("quoteResponse") {
334            if let Some(results) = quote_response.get("result").and_then(|r| r.as_array()) {
335                let mut cache = self.quote_cache.write().await;
336
337                for result in results {
338                    if let Some(symbol) = result.get("symbol").and_then(|s| s.as_str()) {
339                        match Quote::from_batch_response(result) {
340                            Ok(mut quote) => {
341                                // Merge logo URLs if we have them
342                                if let Some((logo_url, company_logo_url)) = logo_map.get(symbol) {
343                                    if quote.logo_url.is_none() {
344                                        quote.logo_url = logo_url.clone();
345                                    }
346                                    if quote.company_logo_url.is_none() {
347                                        quote.company_logo_url = company_logo_url.clone();
348                                    }
349                                }
350                                cache.insert(symbol.to_string(), quote.clone());
351                                response.quotes.insert(symbol.to_string(), quote);
352                            }
353                            Err(e) => {
354                                response.errors.insert(symbol.to_string(), e.to_string());
355                            }
356                        }
357                    }
358                }
359            }
360
361            // Track missing symbols
362            for symbol in &self.symbols {
363                if !response.quotes.contains_key(symbol) && !response.errors.contains_key(symbol) {
364                    response
365                        .errors
366                        .insert(symbol.clone(), "Symbol not found in response".to_string());
367                }
368            }
369        }
370
371        Ok(response)
372    }
373
374    /// Get a specific quote by symbol (from cache or fetch all)
375    pub async fn quote(&self, symbol: &str, include_logo: bool) -> Result<Quote> {
376        {
377            let cache = self.quote_cache.read().await;
378            if let Some(quote) = cache.get(symbol) {
379                return Ok(quote.clone());
380            }
381        }
382
383        let response = self.quotes(include_logo).await?;
384
385        response
386            .quotes
387            .get(symbol)
388            .cloned()
389            .ok_or_else(|| YahooError::SymbolNotFound {
390                symbol: Some(symbol.to_string()),
391                context: response
392                    .errors
393                    .get(symbol)
394                    .cloned()
395                    .unwrap_or_else(|| "Symbol not found".to_string()),
396            })
397    }
398
399    /// Batch fetch charts for all symbols concurrently
400    ///
401    /// Chart data cannot be batched in a single request, so this fetches
402    /// all charts concurrently using tokio for maximum performance.
403    pub async fn charts(
404        &self,
405        interval: Interval,
406        range: TimeRange,
407    ) -> Result<BatchChartsResponse> {
408        // Check cache
409        {
410            let cache = self.chart_cache.read().await;
411            if self
412                .symbols
413                .iter()
414                .all(|s| cache.contains_key(&(s.clone(), interval, range)))
415            {
416                let mut response = BatchChartsResponse::new();
417                for symbol in &self.symbols {
418                    if let Some(result) = cache.get(&(symbol.clone(), interval, range)) {
419                        response.charts.insert(
420                            symbol.clone(),
421                            Chart {
422                                symbol: symbol.clone(),
423                                meta: result.meta.clone(),
424                                candles: result.to_candles(),
425                                interval: Some(interval.as_str().to_string()),
426                                range: Some(range.as_str().to_string()),
427                            },
428                        );
429                    }
430                }
431                return Ok(response);
432            }
433        }
434
435        // Fetch all charts concurrently
436        let futures: Vec<_> = self
437            .symbols
438            .iter()
439            .map(|symbol| {
440                let client = Arc::clone(&self.client);
441                let symbol = symbol.clone();
442                async move {
443                    let result = client.get_chart(&symbol, interval, range).await;
444                    (symbol, result)
445                }
446            })
447            .collect();
448
449        let results = futures::future::join_all(futures).await;
450
451        let mut response = BatchChartsResponse::new();
452        let mut cache = self.chart_cache.write().await;
453
454        for (symbol, result) in results {
455            match result {
456                Ok(json) => match ChartResponse::from_json(json) {
457                    Ok(chart_response) => {
458                        if let Some(mut chart_results) = chart_response.chart.result {
459                            if let Some(chart_result) = chart_results.pop() {
460                                let chart = Chart {
461                                    symbol: symbol.clone(),
462                                    meta: chart_result.meta.clone(),
463                                    candles: chart_result.to_candles(),
464                                    interval: Some(interval.as_str().to_string()),
465                                    range: Some(range.as_str().to_string()),
466                                };
467                                cache.insert((symbol.clone(), interval, range), chart_result);
468                                response.charts.insert(symbol, chart);
469                            } else {
470                                response
471                                    .errors
472                                    .insert(symbol, "Empty chart response".to_string());
473                            }
474                        } else {
475                            response
476                                .errors
477                                .insert(symbol, "No chart data in response".to_string());
478                        }
479                    }
480                    Err(e) => {
481                        response.errors.insert(symbol, e.to_string());
482                    }
483                },
484                Err(e) => {
485                    response.errors.insert(symbol, e.to_string());
486                }
487            }
488        }
489
490        Ok(response)
491    }
492
493    /// Get a specific chart by symbol
494    pub async fn chart(&self, symbol: &str, interval: Interval, range: TimeRange) -> Result<Chart> {
495        {
496            let cache = self.chart_cache.read().await;
497            if let Some(result) = cache.get(&(symbol.to_string(), interval, range)) {
498                return Ok(Chart {
499                    symbol: symbol.to_string(),
500                    meta: result.meta.clone(),
501                    candles: result.to_candles(),
502                    interval: Some(interval.as_str().to_string()),
503                    range: Some(range.as_str().to_string()),
504                });
505            }
506        }
507
508        let response = self.charts(interval, range).await?;
509
510        response
511            .charts
512            .get(symbol)
513            .cloned()
514            .ok_or_else(|| YahooError::SymbolNotFound {
515                symbol: Some(symbol.to_string()),
516                context: response
517                    .errors
518                    .get(symbol)
519                    .cloned()
520                    .unwrap_or_else(|| "Symbol not found".to_string()),
521            })
522    }
523
524    /// Clear all caches
525    pub async fn clear_cache(&self) {
526        self.quote_cache.write().await.clear();
527        self.chart_cache.write().await.clear();
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    #[tokio::test]
536    #[ignore] // Requires network access
537    async fn test_tickers_quotes() {
538        let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await.unwrap();
539        let result = tickers.quotes(false).await.unwrap();
540
541        assert!(result.success_count() > 0);
542    }
543
544    #[tokio::test]
545    #[ignore] // Requires network access
546    async fn test_tickers_charts() {
547        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
548        let result = tickers
549            .charts(Interval::OneDay, TimeRange::FiveDays)
550            .await
551            .unwrap();
552
553        assert!(result.success_count() > 0);
554    }
555}