Skip to main content

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 super::macros::{batch_fetch_cached, define_batch_response};
6use crate::adapters::yahoo::client::ClientConfig;
7#[cfg(feature = "backtesting")]
8use crate::backtesting;
9use crate::constants::{Frequency, Interval, Region, StatementType, TimeRange};
10use crate::error::{FinanceError, Result};
11use crate::format::Both;
12#[cfg(any(feature = "backtesting", feature = "indicators"))]
13use crate::indicators;
14use crate::models::chart::events::ChartEvents;
15use crate::models::chart::spark::Spark;
16use crate::models::chart::{CapitalGain, Chart, Dividend, Split};
17use crate::models::corporate::news::News;
18use crate::models::corporate::recommendation::Recommendation;
19use crate::models::format::Format;
20use crate::models::fundamentals::FinancialStatement;
21use crate::models::options::Options;
22use crate::models::quote::{Quote, QuoteSummaryResponse};
23
24use crate::providers::types::recommendation_from_similar;
25use crate::providers::yahoo::YahooProvider;
26use crate::providers::{
27    Capability, Fetch, Provider, ProviderAdapter, ProviderSet, Routes, build_providers,
28};
29use crate::ticker::ClientHandle;
30use crate::utils::{CacheEntry, EVICTION_THRESHOLD, filter_by_range};
31use futures::stream::{self, StreamExt};
32use std::collections::HashMap;
33use std::sync::Arc;
34use std::time::Duration;
35use tokio::sync::RwLock;
36
37// Type aliases — MapCache wraps values in CacheEntry for TTL support.
38type MapCache<K, V> = Arc<RwLock<HashMap<K, CacheEntry<V>>>>;
39type ChartCacheKey = (Arc<str>, Interval, TimeRange);
40type QuoteCache = MapCache<Arc<str>, Quote>;
41type ChartCache = MapCache<ChartCacheKey, Chart>;
42type EventsCache = MapCache<Arc<str>, ChartEvents>;
43type FinancialsCache = MapCache<(Arc<str>, StatementType, Frequency), FinancialStatement>;
44type NewsCache = MapCache<Arc<str>, Vec<News>>;
45type RecommendationsCache = MapCache<(Arc<str>, u32), Recommendation>;
46type OptionsCache = MapCache<(Arc<str>, Option<i64>), Options>;
47type SparkCacheKey = (Arc<str>, Interval, TimeRange);
48type SparkCache = MapCache<SparkCacheKey, Spark>;
49#[cfg(feature = "indicators")]
50type IndicatorsCache = MapCache<(Arc<str>, Interval, TimeRange), indicators::IndicatorsSummary>;
51
52// Fetch guards for request deduplication — prevent concurrent duplicate fetches
53type FetchGuard = Arc<tokio::sync::Mutex<()>>;
54type FetchGuardMap<K> = Arc<RwLock<HashMap<K, FetchGuard>>>;
55
56// Generate all batch response types
57define_batch_response! {
58    /// Response containing quotes for multiple symbols.
59    BatchQuotesResponse => quotes: Quote
60}
61
62define_batch_response! {
63    /// Response containing charts for multiple symbols.
64    BatchChartsResponse => charts: Chart
65}
66
67define_batch_response! {
68    /// Response containing spark data for multiple symbols.
69    ///
70    /// Spark data is optimized for sparkline rendering with only close prices.
71    /// Unlike charts, spark data is fetched in a single batch request.
72    BatchSparksResponse => sparks: Spark
73}
74
75define_batch_response! {
76    /// Response containing dividends for multiple symbols.
77    BatchDividendsResponse => dividends: Vec<Dividend>
78}
79
80define_batch_response! {
81    /// Response containing splits for multiple symbols.
82    BatchSplitsResponse => splits: Vec<Split>
83}
84
85define_batch_response! {
86    /// Response containing capital gains for multiple symbols.
87    BatchCapitalGainsResponse => capital_gains: Vec<CapitalGain>
88}
89
90define_batch_response! {
91    /// Response containing financial statements for multiple symbols.
92    BatchFinancialsResponse => financials: FinancialStatement
93}
94
95define_batch_response! {
96    /// Response containing news articles for multiple symbols.
97    BatchNewsResponse => news: Vec<News>
98}
99
100define_batch_response! {
101    /// Response containing recommendations for multiple symbols.
102    BatchRecommendationsResponse => recommendations: Recommendation
103}
104
105define_batch_response! {
106    /// Response containing options chains for multiple symbols.
107    BatchOptionsResponse => options: Options
108}
109
110#[cfg(feature = "indicators")]
111define_batch_response! {
112    /// Response containing technical indicators for multiple symbols.
113    BatchIndicatorsResponse => indicators: indicators::IndicatorsSummary
114}
115
116/// Default maximum concurrent requests for batch operations.
117const DEFAULT_MAX_CONCURRENCY: usize = 10;
118
119/// Builder for Tickers
120pub struct TickersBuilder {
121    symbols: Vec<Arc<str>>,
122    config: ClientConfig,
123    shared_client: Option<ClientHandle>,
124    injected_providers: Option<Arc<ProviderSet>>,
125    max_concurrency: usize,
126    cache_ttl: Option<Duration>,
127    include_logo: bool,
128}
129
130impl TickersBuilder {
131    fn new<S, I>(symbols: I) -> Self
132    where
133        S: Into<String>,
134        I: IntoIterator<Item = S>,
135    {
136        Self {
137            symbols: symbols.into_iter().map(|s| s.into().into()).collect(),
138            config: ClientConfig::default(),
139            shared_client: None,
140            injected_providers: None,
141            max_concurrency: DEFAULT_MAX_CONCURRENCY,
142            cache_ttl: None,
143            include_logo: false,
144        }
145    }
146
147    /// Set the region (automatically sets correct lang and region code)
148    pub fn region(mut self, region: Region) -> Self {
149        self.config.lang = region.lang().to_string();
150        self.config.region = region.region().to_string();
151        self
152    }
153
154    /// Set the language code (e.g., "en-US", "ja-JP", "de-DE")
155    pub fn lang(mut self, lang: impl Into<String>) -> Self {
156        self.config.lang = lang.into();
157        self
158    }
159
160    /// Set the region code (e.g., "US", "JP", "DE")
161    pub fn region_code(mut self, region: impl Into<String>) -> Self {
162        self.config.region = region.into();
163        self
164    }
165
166    /// Set the HTTP request timeout
167    pub fn timeout(mut self, timeout: Duration) -> Self {
168        self.config.timeout = timeout;
169        self
170    }
171
172    /// Set the proxy URL
173    pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
174        self.config.proxy = Some(proxy.into());
175        self
176    }
177
178    #[allow(dead_code)]
179    pub(crate) fn config(mut self, config: ClientConfig) -> Self {
180        self.config = config;
181        self
182    }
183
184    /// Set the maximum number of concurrent requests for batch operations.
185    ///
186    /// Controls how many HTTP requests run in parallel when methods like
187    /// `charts()`, `financials()`, or `news()` fetch data for each symbol.
188    /// Default is 10.
189    ///
190    /// Lower values reduce the risk of rate limiting from Yahoo Finance.
191    /// Higher values increase throughput for large symbol lists.
192    pub fn max_concurrency(mut self, n: usize) -> Self {
193        self.max_concurrency = n.max(1);
194        self
195    }
196
197    /// Enable response caching with a time-to-live.
198    ///
199    /// By default caching is **disabled** — every call fetches fresh data.
200    /// When enabled, responses are reused until the TTL expires. Stale
201    /// entries are evicted on the next write.
202    ///
203    /// # Example
204    ///
205    /// ```no_run
206    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
207    /// use finance_query::Tickers;
208    /// use std::time::Duration;
209    ///
210    /// let tickers = Tickers::builder(["AAPL", "MSFT"])
211    ///     .cache(Duration::from_secs(30))
212    ///     .build()
213    ///     .await?;
214    /// # Ok(())
215    /// # }
216    /// ```
217    pub fn cache(mut self, ttl: Duration) -> Self {
218        self.cache_ttl = Some(ttl);
219        self
220    }
221
222    /// Include company logo URLs in quote responses.
223    ///
224    /// When enabled, `quotes()` will fetch logo URLs in parallel with the
225    /// quote batch request, adding a small extra request.
226    pub fn logo(mut self) -> Self {
227        self.include_logo = true;
228        self
229    }
230
231    /// Pre-inject a shared provider set (used by [`Providers::tickers`]).
232    pub(crate) fn with_provider_set(mut self, set: Arc<ProviderSet>) -> Self {
233        self.injected_providers = Some(set);
234        self
235    }
236
237    /// Share an existing authenticated session instead of creating a new one.
238    ///
239    /// Avoids redundant auth handshakes when combining `Tickers` with other
240    /// `Ticker` instances. Obtain a handle from any existing `Ticker` or
241    /// `Tickers` via `.client_handle()`.
242    pub fn client(mut self, handle: ClientHandle) -> Self {
243        self.shared_client = Some(handle);
244        self
245    }
246
247    /// Build the Tickers instance
248    pub async fn build(self) -> Result<Tickers> {
249        #[cfg(feature = "translation")]
250        let translate_lang = {
251            let lang = crate::translation::Lang::parse(&self.config.lang)?;
252            (!lang.is_english()).then_some(lang)
253        };
254        let providers = if let Some(set) = self.injected_providers {
255            set
256        } else if let Some(handle) = self.shared_client {
257            let yahoo = YahooProvider::from_client(handle.0);
258            let client = yahoo.client_arc();
259            Arc::new(ProviderSet::new(
260                vec![Arc::new(yahoo) as Arc<dyn ProviderAdapter>],
261                Some(client),
262                Routes::new(Fetch::Sequential),
263            ))
264        } else {
265            Arc::new(
266                build_providers(
267                    &[Provider::Yahoo],
268                    &self.config,
269                    Routes::new(Fetch::Sequential),
270                )
271                .await?,
272            )
273        };
274
275        Ok(Tickers {
276            symbols: self.symbols,
277            providers,
278            max_concurrency: self.max_concurrency,
279            cache_ttl: self.cache_ttl,
280            include_logo: self.include_logo,
281            #[cfg(feature = "translation")]
282            translate_lang,
283            quote_cache: Default::default(),
284            chart_cache: Default::default(),
285            events_cache: Default::default(),
286            financials_cache: Default::default(),
287            news_cache: Default::default(),
288            recommendations_cache: Default::default(),
289            options_cache: Default::default(),
290            spark_cache: Default::default(),
291            #[cfg(feature = "indicators")]
292            indicators_cache: Default::default(),
293
294            // Initialize fetch guards for request deduplication
295            quotes_fetch: Arc::new(tokio::sync::Mutex::new(())),
296            charts_fetch: Default::default(),
297            financials_fetch: Default::default(),
298            news_fetch: Arc::new(tokio::sync::Mutex::new(())),
299            recommendations_fetch: Default::default(),
300            options_fetch: Default::default(),
301            spark_fetch: Default::default(),
302            #[cfg(feature = "indicators")]
303            indicators_fetch: Default::default(),
304        })
305    }
306}
307
308/// Multi-symbol ticker for efficient batch operations.
309///
310/// `Tickers` optimizes data fetching for multiple symbols by:
311/// - Using batch endpoints where available (e.g., /v7/finance/quote)
312/// - Fetching concurrently when batch endpoints don't exist
313/// - Sharing a single authenticated client across all symbols
314/// - Caching results per symbol
315///
316/// # Example
317///
318/// ```no_run
319/// use finance_query::Tickers;
320///
321/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
322/// // Create tickers for multiple symbols
323/// let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await?;
324///
325/// // Batch fetch all quotes (single API call)
326/// let quotes = tickers.quotes().await?;
327/// for (symbol, quote) in &quotes.quotes {
328///     let price = quote.regular_market_price.as_ref().and_then(|v| v.raw).unwrap_or(0.0);
329///     println!("{}: ${:.2}", symbol, price);
330/// }
331///
332/// // Fetch charts concurrently
333/// use finance_query::{Interval, TimeRange};
334/// let charts = tickers.charts(Interval::OneDay, TimeRange::OneMonth).await?;
335/// # Ok(())
336/// # }
337/// ```
338pub struct Tickers {
339    symbols: Vec<Arc<str>>,
340    providers: Arc<ProviderSet>,
341    max_concurrency: usize,
342    cache_ttl: Option<Duration>,
343    include_logo: bool,
344    #[cfg(feature = "translation")]
345    translate_lang: Option<crate::translation::Lang>,
346    quote_cache: QuoteCache,
347    chart_cache: ChartCache,
348    events_cache: EventsCache,
349    financials_cache: FinancialsCache,
350    news_cache: NewsCache,
351    recommendations_cache: RecommendationsCache,
352    options_cache: OptionsCache,
353    spark_cache: SparkCache,
354    #[cfg(feature = "indicators")]
355    indicators_cache: IndicatorsCache,
356
357    // Fetch guards prevent duplicate concurrent requests
358    quotes_fetch: FetchGuard,
359    charts_fetch: FetchGuardMap<(Interval, TimeRange)>,
360    financials_fetch: FetchGuardMap<(StatementType, Frequency)>,
361    news_fetch: FetchGuard,
362    recommendations_fetch: FetchGuardMap<u32>,
363    options_fetch: FetchGuardMap<Option<i64>>,
364    spark_fetch: FetchGuardMap<(Interval, TimeRange)>,
365    #[cfg(feature = "indicators")]
366    indicators_fetch: FetchGuardMap<(Interval, TimeRange)>,
367}
368
369impl std::fmt::Debug for Tickers {
370    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371        f.debug_struct("Tickers")
372            .field("symbols", &self.symbols)
373            .field("max_concurrency", &self.max_concurrency)
374            .field("cache_ttl", &self.cache_ttl)
375            .finish_non_exhaustive()
376    }
377}
378
379impl Tickers {
380    /// Creates new tickers with default configuration
381    ///
382    /// # Arguments
383    ///
384    /// * `symbols` - Iterable of stock symbols (e.g., `["AAPL", "MSFT"]`)
385    ///
386    /// # Example
387    ///
388    /// ```no_run
389    /// use finance_query::Tickers;
390    ///
391    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
392    /// let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await?;
393    /// # Ok(())
394    /// # }
395    /// ```
396    pub async fn new<S, I>(symbols: I) -> Result<Self>
397    where
398        S: Into<String>,
399        I: IntoIterator<Item = S>,
400    {
401        Self::builder(symbols).build().await
402    }
403
404    /// Creates a new builder for Tickers
405    pub fn builder<S, I>(symbols: I) -> TickersBuilder
406    where
407        S: Into<String>,
408        I: IntoIterator<Item = S>,
409    {
410        TickersBuilder::new(symbols)
411    }
412
413    /// Returns the symbols this tickers instance manages
414    pub fn symbols(&self) -> Vec<&str> {
415        self.symbols.iter().map(|s| &**s).collect()
416    }
417
418    /// Number of symbols
419    pub fn len(&self) -> usize {
420        self.symbols.len()
421    }
422
423    /// Check if empty
424    pub fn is_empty(&self) -> bool {
425        self.symbols.is_empty()
426    }
427
428    /// Returns a handle to the underlying Yahoo Finance session.
429    ///
430    /// Pass to [`Ticker::builder`](crate::Ticker::builder) or other
431    /// [`Tickers::builder`] calls via `.client(handle)` to share the
432    /// authenticated session without a new auth handshake.
433    ///
434    /// # Panics
435    ///
436    /// Panics if these tickers were created via [`Providers`](crate::Providers) with
437    /// no Yahoo provider configured. For session sharing across multiple tickers,
438    /// prefer [`Providers::tickers`](crate::Providers::tickers) instead.
439    pub fn client_handle(&self) -> ClientHandle {
440        ClientHandle(
441            self.providers
442                .first_yahoo()
443                .expect("Tickers always uses a Yahoo session"),
444        )
445    }
446
447    /// Returns `true` if a cache entry exists and has not exceeded the TTL.
448    #[inline]
449    fn is_cache_fresh<T>(&self, entry: Option<&CacheEntry<T>>) -> bool {
450        CacheEntry::is_fresh_with_ttl(entry, self.cache_ttl)
451    }
452
453    /// Translate a response value when a non-English language is configured
454    /// (no-op otherwise).
455    #[cfg(feature = "translation")]
456    pub(crate) async fn translate_response<T: crate::translation::Translatable>(
457        &self,
458        value: &mut T,
459    ) -> Result<()> {
460        if let Some(lang) = &self.translate_lang {
461            crate::translation::translate_with(value, lang).await?;
462        }
463        Ok(())
464    }
465
466    /// Returns `true` if all keys are present and fresh in a map cache.
467    fn all_cached<K: Eq + std::hash::Hash, V>(
468        &self,
469        map: &HashMap<K, CacheEntry<V>>,
470        keys: impl Iterator<Item = K>,
471    ) -> bool {
472        let Some(ttl) = self.cache_ttl else {
473            return false;
474        };
475        keys.into_iter()
476            .all(|k| map.get(&k).map(|e| e.is_fresh(ttl)).unwrap_or(false))
477    }
478
479    /// Insert into a map cache, amortizing stale-entry eviction.
480    ///
481    /// Only sweeps stale entries when the map exceeds [`EVICTION_THRESHOLD`],
482    /// avoiding O(n) scans on every write.
483    #[inline]
484    fn cache_insert<K: Eq + std::hash::Hash, V>(
485        &self,
486        map: &mut HashMap<K, CacheEntry<V>>,
487        key: K,
488        value: V,
489    ) {
490        if let Some(ttl) = self.cache_ttl {
491            if map.len() >= EVICTION_THRESHOLD {
492                map.retain(|_, entry| entry.is_fresh(ttl));
493            }
494            map.insert(key, CacheEntry::new(value));
495        }
496    }
497
498    /// Batch fetch quotes for all symbols.
499    ///
500    /// Dispatches through the configured provider set. When logos are enabled,
501    /// fetches logo URLs in parallel via the Yahoo client.
502    ///
503    /// Use [`TickersBuilder::logo()`](TickersBuilder::logo) to enable logo fetching
504    /// for this tickers instance.
505    pub async fn quotes(&self) -> Result<BatchQuotesResponse> {
506        // Fast path: check if all symbols are cached
507        {
508            let cache = self.quote_cache.read().await;
509            if self.all_cached(&cache, self.symbols.iter().cloned()) {
510                let mut response = BatchQuotesResponse::with_capacity(self.symbols.len());
511                for symbol in &self.symbols {
512                    if let Some(entry) = cache.get(symbol) {
513                        response
514                            .quotes
515                            .insert(symbol.to_string(), entry.value.clone());
516                    }
517                }
518                return Ok(response);
519            }
520        }
521
522        let _fetch_guard = self.quotes_fetch.lock().await;
523
524        // Double-check: another task may have fetched while we waited
525        {
526            let cache = self.quote_cache.read().await;
527            if self.all_cached(&cache, self.symbols.iter().cloned()) {
528                let mut response = BatchQuotesResponse::with_capacity(self.symbols.len());
529                for symbol in &self.symbols {
530                    if let Some(entry) = cache.get(symbol) {
531                        response
532                            .quotes
533                            .insert(symbol.to_string(), entry.value.clone());
534                    }
535                }
536                return Ok(response);
537            }
538        }
539
540        let symbol_strings: Vec<String> = self.symbols.iter().map(|s| s.to_string()).collect();
541        let mut response = BatchQuotesResponse::with_capacity(self.symbols.len());
542
543        let (quote_data, logos) = if self.include_logo {
544            // Fire logo fetch in parallel with quote fetch; logos are Yahoo-only
545            let providers_logo = Arc::clone(&self.providers);
546            let syms_logo = symbol_strings.clone();
547            let logo_future = async move {
548                if let Ok(client) = providers_logo.first_yahoo() {
549                    let syms_ref: Vec<&str> = syms_logo.iter().map(String::as_str).collect();
550                    crate::adapters::yahoo::quote::quotes::fetch_with_fields(
551                        &client,
552                        &syms_ref,
553                        Some(&["logoUrl", "companyLogoUrl"]),
554                        true,
555                        true,
556                    )
557                    .await
558                    .ok()
559                } else {
560                    None
561                }
562            };
563
564            let providers_quote = Arc::clone(&self.providers);
565            let syms_quote = symbol_strings.clone();
566            let quote_future = async move {
567                providers_quote
568                    .fetch(Capability::QUOTE, |p| {
569                        let syms = syms_quote.clone();
570                        let p = p.clone();
571                        async move {
572                            let syms_ref: Vec<&str> = syms.iter().map(String::as_str).collect();
573                            p.fetch_quotes_batch(&syms_ref).await
574                        }
575                    })
576                    .await
577            };
578
579            let (batch_result, logo_result) = tokio::join!(quote_future, logo_future);
580            let quote_data = match batch_result {
581                Ok(data) => data,
582                Err(_) => {
583                    self.fetch_quotes_per_symbol(&symbol_strings, &mut response)
584                        .await
585                }
586            };
587            (quote_data, logo_result)
588        } else {
589            let providers = Arc::clone(&self.providers);
590            let syms = symbol_strings.clone();
591            let batch_result = providers
592                .fetch(Capability::QUOTE, |p| {
593                    let syms = syms.clone();
594                    let p = p.clone();
595                    async move {
596                        let syms_ref: Vec<&str> = syms.iter().map(String::as_str).collect();
597                        p.fetch_quotes_batch(&syms_ref).await
598                    }
599                })
600                .await;
601            let data = match batch_result {
602                Ok(data) => data,
603                Err(_) => {
604                    self.fetch_quotes_per_symbol(&symbol_strings, &mut response)
605                        .await
606                }
607            };
608            (data, None)
609        };
610
611        let logo_map: HashMap<String, (Option<String>, Option<String>)> = logos
612            .and_then(|l| l.get("quoteResponse")?.get("result")?.as_array().cloned())
613            .map(|results| {
614                results
615                    .iter()
616                    .filter_map(|r| {
617                        let symbol = r.get("symbol")?.as_str()?.to_string();
618                        let logo_url = r.get("logoUrl").and_then(|v| v.as_str()).map(String::from);
619                        let company_logo_url = r
620                            .get("companyLogoUrl")
621                            .and_then(|v| v.as_str())
622                            .map(String::from);
623                        Some((symbol, (logo_url, company_logo_url)))
624                    })
625                    .collect()
626            })
627            .unwrap_or_default();
628
629        let mut parsed_quotes: Vec<(String, Quote)> = Vec::new();
630
631        for (symbol, summary) in quote_data {
632            let logo_url = logo_map.get(&symbol).and_then(|(l, _)| l.clone());
633            let company_logo_url = logo_map.get(&symbol).and_then(|(_, c)| c.clone());
634            let quote = Quote::from_response(&summary, logo_url, company_logo_url);
635            parsed_quotes.push((symbol, quote));
636        }
637
638        for (symbol, quote) in parsed_quotes {
639            response.quotes.insert(symbol, quote);
640        }
641
642        // Translate before caching so cached quotes are already localized
643        // and repeat reads don't re-run the translation backend.
644        #[cfg(feature = "translation")]
645        self.translate_response(&mut response).await?;
646
647        if self.cache_ttl.is_some() {
648            let mut cache = self.quote_cache.write().await;
649            for (symbol, quote) in &response.quotes {
650                self.cache_insert(&mut cache, symbol.as_str().into(), quote.clone());
651            }
652        }
653
654        // Track missing symbols
655        for symbol in &self.symbols {
656            let s = &**symbol;
657            if !response.quotes.contains_key(s) && !response.errors.contains_key(s) {
658                response.errors.insert(
659                    symbol.to_string(),
660                    "Symbol not found in response".to_string(),
661                );
662            }
663        }
664
665        Ok(response)
666    }
667
668    /// Fallback for when no provider supports `fetch_quotes_batch`.
669    /// Fetches each symbol individually; failures go into `response.errors`.
670    async fn fetch_quotes_per_symbol(
671        &self,
672        symbols: &[String],
673        response: &mut BatchQuotesResponse,
674    ) -> Vec<(String, QuoteSummaryResponse)> {
675        let futures: Vec<_> = symbols
676            .iter()
677            .map(|sym| {
678                let providers = Arc::clone(&self.providers);
679                let sym = sym.clone();
680                async move {
681                    let result = providers
682                        .fetch(Capability::QUOTE, |p| {
683                            let sym = sym.clone();
684                            let p = p.clone();
685                            async move { p.fetch_quote(&sym).await }
686                        })
687                        .await;
688                    (sym, result)
689                }
690            })
691            .collect();
692
693        let results: Vec<_> = stream::iter(futures)
694            .buffer_unordered(self.max_concurrency)
695            .collect()
696            .await;
697
698        let mut successes = Vec::new();
699        for (sym, result) in results {
700            match result {
701                Ok(resp) => successes.push((sym, resp)),
702                Err(e) => {
703                    response.errors.insert(sym, e.to_string());
704                }
705            }
706        }
707        successes
708    }
709
710    /// Get a specific quote by symbol (from cache or fetch all)
711    pub async fn quote<F>(&self, symbol: &str) -> Result<Quote<F>>
712    where
713        F: Format,
714        Quote<Both>: Into<Quote<F>>,
715    {
716        {
717            let cache = self.quote_cache.read().await;
718            if let Some(entry) = cache.get(symbol)
719                && self.is_cache_fresh(Some(entry))
720            {
721                return Ok(entry.value.clone().into());
722            }
723        }
724
725        let response = self.quotes().await?;
726
727        response
728            .quotes
729            .get(symbol)
730            .cloned()
731            .map(Into::into)
732            .ok_or_else(|| FinanceError::SymbolNotFound {
733                symbol: Some(symbol.to_string()),
734                context: response
735                    .errors
736                    .get(symbol)
737                    .cloned()
738                    .unwrap_or_else(|| "Symbol not found".to_string()),
739            })
740    }
741
742    /// Helper to get or create a fetch guard for a given key.
743    ///
744    /// Returns the guard from the map, never a locally-created copy that
745    /// could diverge under contention.
746    async fn get_fetch_guard<K: Clone + Eq + std::hash::Hash>(
747        guard_map: &FetchGuardMap<K>,
748        key: K,
749    ) -> FetchGuard {
750        {
751            let guards = guard_map.read().await;
752            if let Some(guard) = guards.get(&key) {
753                return Arc::clone(guard);
754            }
755        }
756
757        let mut guards = guard_map.write().await;
758        Arc::clone(
759            guards
760                .entry(key)
761                .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
762        )
763    }
764
765    /// Batch fetch charts for all symbols concurrently
766    ///
767    /// Chart data cannot be batched in a single request, so this fetches
768    /// all charts concurrently using tokio for maximum performance.
769    pub async fn charts(
770        &self,
771        interval: Interval,
772        range: TimeRange,
773    ) -> Result<BatchChartsResponse> {
774        // Fast path: check if all symbols are cached
775        {
776            let cache = self.chart_cache.read().await;
777            if self.all_cached(
778                &cache,
779                self.symbols.iter().map(|s| (s.clone(), interval, range)),
780            ) {
781                let mut response = BatchChartsResponse::with_capacity(self.symbols.len());
782                for symbol in &self.symbols {
783                    if let Some(entry) = cache.get(&(symbol.clone(), interval, range)) {
784                        response
785                            .charts
786                            .insert(symbol.to_string(), entry.value.clone());
787                    }
788                }
789                return Ok(response);
790            }
791        }
792
793        // Slow path: acquire fetch guard to prevent duplicate concurrent requests
794        let fetch_guard = Self::get_fetch_guard(&self.charts_fetch, (interval, range)).await;
795        let _guard = fetch_guard.lock().await;
796
797        // Double-check: another task may have fetched while we waited
798        {
799            let cache = self.chart_cache.read().await;
800            if self.all_cached(
801                &cache,
802                self.symbols.iter().map(|s| (s.clone(), interval, range)),
803            ) {
804                let mut response = BatchChartsResponse::with_capacity(self.symbols.len());
805                for symbol in &self.symbols {
806                    if let Some(entry) = cache.get(&(symbol.clone(), interval, range)) {
807                        response
808                            .charts
809                            .insert(symbol.to_string(), entry.value.clone());
810                    }
811                }
812                return Ok(response);
813            }
814        }
815
816        // Fetch all charts concurrently via provider dispatch (no lock held during I/O)
817        let futures: Vec<_> = self
818            .symbols
819            .iter()
820            .map(|symbol| {
821                let providers = Arc::clone(&self.providers);
822                let symbol = Arc::clone(symbol);
823                async move {
824                    let sym = symbol.to_string();
825                    let result = providers
826                        .fetch(Capability::CHART, |p| {
827                            let sym = sym.clone();
828                            let p = p.clone();
829                            async move { p.fetch_chart(&sym, interval, range).await }
830                        })
831                        .await;
832                    (symbol, result)
833                }
834            })
835            .collect();
836
837        let results: Vec<_> = stream::iter(futures)
838            .buffer_unordered(self.max_concurrency)
839            .collect()
840            .await;
841
842        let mut response = BatchChartsResponse::with_capacity(self.symbols.len());
843        let mut parsed_charts: Vec<(Arc<str>, Chart)> = Vec::new();
844
845        for (symbol, result) in results {
846            match result {
847                Ok(data) => {
848                    let chart = data;
849                    parsed_charts.push((symbol, chart));
850                }
851                Err(e) => {
852                    response.errors.insert(symbol.to_string(), e.to_string());
853                }
854            }
855        }
856
857        // Move into cache, then clone for response — avoids double-clone
858        if self.cache_ttl.is_some() {
859            let mut cache = self.chart_cache.write().await;
860            let cache_keys: Vec<_> = parsed_charts
861                .into_iter()
862                .map(|(symbol, chart)| {
863                    self.cache_insert(&mut cache, (symbol.clone(), interval, range), chart);
864                    symbol
865                })
866                .collect();
867            for symbol in cache_keys {
868                if let Some(cached) = cache.get(&(symbol.clone(), interval, range)) {
869                    response
870                        .charts
871                        .insert(symbol.to_string(), cached.value.clone());
872                }
873            }
874        } else {
875            for (symbol, chart) in parsed_charts {
876                response.charts.insert(symbol.to_string(), chart);
877            }
878        }
879
880        Ok(response)
881    }
882
883    /// Get a specific chart by symbol
884    pub async fn chart(&self, symbol: &str, interval: Interval, range: TimeRange) -> Result<Chart> {
885        {
886            let cache = self.chart_cache.read().await;
887            let key: Arc<str> = symbol.into();
888            if let Some(entry) = cache.get(&(key, interval, range))
889                && self.is_cache_fresh(Some(entry))
890            {
891                return Ok(entry.value.clone());
892            }
893        }
894
895        let response = self.charts(interval, range).await?;
896
897        response
898            .charts
899            .get(symbol)
900            .cloned()
901            .ok_or_else(|| FinanceError::SymbolNotFound {
902                symbol: Some(symbol.to_string()),
903                context: response
904                    .errors
905                    .get(symbol)
906                    .cloned()
907                    .unwrap_or_else(|| "Symbol not found".to_string()),
908            })
909    }
910
911    /// Batch fetch chart data for a custom date range for all symbols concurrently.
912    ///
913    /// Unlike [`charts()`](Self::charts) which uses predefined time ranges,
914    /// this method accepts absolute start/end timestamps. Results are **not cached**
915    /// since custom ranges have unbounded key space.
916    ///
917    /// # Arguments
918    ///
919    /// * `interval` - Time interval between data points
920    /// * `start` - Start date as Unix timestamp (seconds since epoch)
921    /// * `end` - End date as Unix timestamp (seconds since epoch)
922    pub async fn charts_range(
923        &self,
924        interval: Interval,
925        start: i64,
926        end: i64,
927    ) -> Result<BatchChartsResponse> {
928        let futures: Vec<_> = self
929            .symbols
930            .iter()
931            .map(|symbol| {
932                let providers = Arc::clone(&self.providers);
933                let symbol = Arc::clone(symbol);
934                async move {
935                    let sym = symbol.to_string();
936                    let result = providers
937                        .fetch(Capability::CHART, |p| {
938                            let sym = sym.clone();
939                            let p = p.clone();
940                            async move { p.fetch_chart_range(&sym, interval, start, end).await }
941                        })
942                        .await;
943                    (symbol, result)
944                }
945            })
946            .collect();
947
948        let results: Vec<_> = stream::iter(futures)
949            .buffer_unordered(self.max_concurrency)
950            .collect()
951            .await;
952
953        let mut response = BatchChartsResponse::with_capacity(self.symbols.len());
954
955        for (symbol, result) in results {
956            match result {
957                Ok(data) => {
958                    let chart = data;
959                    response.charts.insert(symbol.to_string(), chart);
960                }
961                Err(e) => {
962                    response.errors.insert(symbol.to_string(), e.to_string());
963                }
964            }
965        }
966
967        Ok(response)
968    }
969
970    /// Ensures events are loaded for all symbols using chart requests.
971    ///
972    /// Fetches events concurrently for symbols that don't have cached events.
973    /// Uses `TimeRange::Max` to get full event history (Yahoo returns all
974    /// dividends/splits/capital gains regardless of chart range).
975    ///
976    /// Events are always stored regardless of `cache_ttl` because they are
977    /// derived data (not a TTL-bounded cache). When `cache_ttl` is `None`,
978    /// events persist for the lifetime of the `Tickers` instance.
979    async fn ensure_events_loaded(&self) -> Result<()> {
980        // Check which symbols need event data (existence check, not TTL-based)
981        let symbols_to_fetch: Vec<Arc<str>> = {
982            let cache = self.events_cache.read().await;
983            self.symbols
984                .iter()
985                .filter(|sym| !cache.contains_key(*sym))
986                .cloned()
987                .collect()
988        };
989
990        if symbols_to_fetch.is_empty() {
991            return Ok(());
992        }
993
994        // Fetch events concurrently for all symbols that need it via provider dispatch
995        let futures: Vec<_> = symbols_to_fetch
996            .iter()
997            .map(|symbol| {
998                let providers = Arc::clone(&self.providers);
999                let symbol = Arc::clone(symbol);
1000                async move {
1001                    let sym = symbol.to_string();
1002                    let result = providers
1003                        .fetch(Capability::CORPORATE, |p| {
1004                            let sym = sym.clone();
1005                            let p = p.clone();
1006                            async move { p.fetch_events(&sym).await }
1007                        })
1008                        .await;
1009                    (symbol, result)
1010                }
1011            })
1012            .collect();
1013
1014        let results: Vec<_> = stream::iter(futures)
1015            .buffer_unordered(self.max_concurrency)
1016            .collect()
1017            .await;
1018
1019        let mut parsed_events: Vec<(Arc<str>, ChartEvents)> = Vec::new();
1020
1021        for (symbol, result) in results {
1022            if let Ok(events_data) = result {
1023                parsed_events.push((symbol, events_data));
1024            }
1025        }
1026
1027        // Always store events — they are derived data, not TTL-bounded cache
1028        if !parsed_events.is_empty() {
1029            let mut events_cache = self.events_cache.write().await;
1030            for (symbol, events) in parsed_events {
1031                events_cache.insert(symbol, CacheEntry::new(events));
1032            }
1033        }
1034
1035        Ok(())
1036    }
1037
1038    /// Batch fetch spark data for all symbols in a single request.
1039    ///
1040    /// Spark data is optimized for sparkline rendering, returning only close prices.
1041    /// Unlike `charts()`, this fetches all symbols in ONE API call, making it
1042    /// much more efficient for displaying price trends on dashboards or watchlists.
1043    ///
1044    /// # Arguments
1045    ///
1046    /// * `interval` - Time interval between data points (e.g., `Interval::FiveMinutes`)
1047    /// * `range` - Time range to fetch (e.g., `TimeRange::OneDay`)
1048    ///
1049    /// # Example
1050    ///
1051    /// ```no_run
1052    /// use finance_query::{Tickers, Interval, TimeRange};
1053    ///
1054    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1055    /// let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await?;
1056    /// let sparks = tickers.spark(Interval::FiveMinutes, TimeRange::OneDay).await?;
1057    ///
1058    /// for (symbol, spark) in &sparks.sparks {
1059    ///     if let Some(change) = spark.percent_change() {
1060    ///         println!("{}: {:.2}%", symbol, change);
1061    ///     }
1062    /// }
1063    /// # Ok(())
1064    /// # }
1065    /// ```
1066    pub async fn spark(&self, interval: Interval, range: TimeRange) -> Result<BatchSparksResponse> {
1067        // Fast path: check if all symbols are cached
1068        {
1069            let cache = self.spark_cache.read().await;
1070            if self.all_cached(
1071                &cache,
1072                self.symbols.iter().map(|s| (s.clone(), interval, range)),
1073            ) {
1074                let mut response = BatchSparksResponse::with_capacity(self.symbols.len());
1075                for symbol in &self.symbols {
1076                    if let Some(entry) = cache.get(&(symbol.clone(), interval, range)) {
1077                        response
1078                            .sparks
1079                            .insert(symbol.to_string(), entry.value.clone());
1080                    }
1081                }
1082                return Ok(response);
1083            }
1084        }
1085
1086        // Slow path: acquire fetch guard
1087        let fetch_guard = Self::get_fetch_guard(&self.spark_fetch, (interval, range)).await;
1088        let _guard = fetch_guard.lock().await;
1089
1090        // Double-check after guard
1091        {
1092            let cache = self.spark_cache.read().await;
1093            if self.all_cached(
1094                &cache,
1095                self.symbols.iter().map(|s| (s.clone(), interval, range)),
1096            ) {
1097                let mut response = BatchSparksResponse::with_capacity(self.symbols.len());
1098                for symbol in &self.symbols {
1099                    if let Some(entry) = cache.get(&(symbol.clone(), interval, range)) {
1100                        response
1101                            .sparks
1102                            .insert(symbol.to_string(), entry.value.clone());
1103                    }
1104                }
1105                return Ok(response);
1106            }
1107        }
1108
1109        // Dispatch through the provider set under the CHART capability so spark
1110        // honors routing like every other chart path (Yahoo is the default).
1111        let providers = Arc::clone(&self.providers);
1112        let syms: Vec<String> = self.symbols.iter().map(|s| s.to_string()).collect();
1113        let spark_result = providers
1114            .fetch(Capability::CHART, |p| {
1115                let syms = syms.clone();
1116                let p = p.clone();
1117                async move {
1118                    let syms_ref: Vec<&str> = syms.iter().map(String::as_str).collect();
1119                    p.fetch_spark(&syms_ref, interval, range).await
1120                }
1121            })
1122            .await;
1123
1124        let mut response = BatchSparksResponse::with_capacity(self.symbols.len());
1125
1126        match spark_result {
1127            Ok(parsed_sparks) => {
1128                // Cache all parsed sparks
1129                if self.cache_ttl.is_some() {
1130                    let mut cache = self.spark_cache.write().await;
1131                    for (symbol, spark) in &parsed_sparks {
1132                        let key: Arc<str> = symbol.as_str().into();
1133                        self.cache_insert(&mut cache, (key, interval, range), spark.clone());
1134                    }
1135                }
1136
1137                // Build response
1138                for (symbol, spark) in parsed_sparks {
1139                    response.sparks.insert(symbol, spark);
1140                }
1141
1142                // Track missing symbols
1143                for symbol in &self.symbols {
1144                    let symbol_str = &**symbol;
1145                    if !response.sparks.contains_key(symbol_str)
1146                        && !response.errors.contains_key(symbol_str)
1147                    {
1148                        response.errors.insert(
1149                            symbol.to_string(),
1150                            "Symbol not found in response".to_string(),
1151                        );
1152                    }
1153                }
1154            }
1155            Err(e) => {
1156                for symbol in &self.symbols {
1157                    response.errors.insert(symbol.to_string(), e.to_string());
1158                }
1159            }
1160        }
1161
1162        Ok(response)
1163    }
1164
1165    /// Batch fetch dividends for all symbols
1166    ///
1167    /// Returns dividend history for all symbols, filtered by the specified time range.
1168    /// Dividends are cached per symbol after the first chart fetch.
1169    ///
1170    /// # Arguments
1171    ///
1172    /// * `range` - Time range to filter dividends
1173    ///
1174    /// # Example
1175    ///
1176    /// ```no_run
1177    /// use finance_query::{Tickers, TimeRange};
1178    ///
1179    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1180    /// let tickers = Tickers::new(["AAPL", "MSFT"]).await?;
1181    /// let dividends = tickers.dividends(TimeRange::OneYear).await?;
1182    ///
1183    /// for (symbol, divs) in &dividends.dividends {
1184    ///     println!("{}: {} dividends", symbol, divs.len());
1185    /// }
1186    /// # Ok(())
1187    /// # }
1188    /// ```
1189    pub async fn dividends(&self, range: TimeRange) -> Result<BatchDividendsResponse> {
1190        let mut response = BatchDividendsResponse::with_capacity(self.symbols.len());
1191
1192        // Fetch events efficiently (1-day chart request per symbol)
1193        self.ensure_events_loaded().await?;
1194
1195        let events_cache = self.events_cache.read().await;
1196
1197        for symbol in &self.symbols {
1198            if let Some(entry) = events_cache.get(symbol) {
1199                let all_dividends = entry.value.to_dividends();
1200                let filtered = filter_by_range(all_dividends, range);
1201                response.dividends.insert(symbol.to_string(), filtered);
1202            } else {
1203                response
1204                    .errors
1205                    .insert(symbol.to_string(), "No events data available".to_string());
1206            }
1207        }
1208
1209        Ok(response)
1210    }
1211
1212    /// Batch fetch stock splits for all symbols
1213    ///
1214    /// Returns stock split history for all symbols, filtered by the specified time range.
1215    /// Splits are cached per symbol after the first chart fetch.
1216    ///
1217    /// # Arguments
1218    ///
1219    /// * `range` - Time range to filter splits
1220    ///
1221    /// # Example
1222    ///
1223    /// ```no_run
1224    /// use finance_query::{Tickers, TimeRange};
1225    ///
1226    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1227    /// let tickers = Tickers::new(["NVDA", "TSLA"]).await?;
1228    /// let splits = tickers.splits(TimeRange::FiveYears).await?;
1229    ///
1230    /// for (symbol, sp) in &splits.splits {
1231    ///     for split in sp {
1232    ///         println!("{}: {}", symbol, split.ratio);
1233    ///     }
1234    /// }
1235    /// # Ok(())
1236    /// # }
1237    /// ```
1238    pub async fn splits(&self, range: TimeRange) -> Result<BatchSplitsResponse> {
1239        let mut response = BatchSplitsResponse::with_capacity(self.symbols.len());
1240
1241        // Fetch events efficiently (1-day chart request per symbol)
1242        self.ensure_events_loaded().await?;
1243
1244        let events_cache = self.events_cache.read().await;
1245
1246        for symbol in &self.symbols {
1247            if let Some(entry) = events_cache.get(symbol) {
1248                let all_splits = entry.value.to_splits();
1249                let filtered = filter_by_range(all_splits, range);
1250                response.splits.insert(symbol.to_string(), filtered);
1251            } else {
1252                response
1253                    .errors
1254                    .insert(symbol.to_string(), "No events data available".to_string());
1255            }
1256        }
1257
1258        Ok(response)
1259    }
1260
1261    /// Batch fetch capital gains for all symbols
1262    ///
1263    /// Returns capital gain distribution history for all symbols, filtered by the
1264    /// specified time range. This is primarily relevant for mutual funds and ETFs.
1265    /// Capital gains are cached per symbol after the first chart fetch.
1266    ///
1267    /// # Arguments
1268    ///
1269    /// * `range` - Time range to filter capital gains
1270    ///
1271    /// # Example
1272    ///
1273    /// ```no_run
1274    /// use finance_query::{Tickers, TimeRange};
1275    ///
1276    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1277    /// let tickers = Tickers::new(["VFIAX", "VTI"]).await?;
1278    /// let gains = tickers.capital_gains(TimeRange::TwoYears).await?;
1279    ///
1280    /// for (symbol, cg) in &gains.capital_gains {
1281    ///     println!("{}: {} distributions", symbol, cg.len());
1282    /// }
1283    /// # Ok(())
1284    /// # }
1285    /// ```
1286    pub async fn capital_gains(&self, range: TimeRange) -> Result<BatchCapitalGainsResponse> {
1287        let mut response = BatchCapitalGainsResponse::with_capacity(self.symbols.len());
1288
1289        // Fetch events efficiently (1-day chart request per symbol)
1290        self.ensure_events_loaded().await?;
1291
1292        let events_cache = self.events_cache.read().await;
1293
1294        for symbol in &self.symbols {
1295            if let Some(entry) = events_cache.get(symbol) {
1296                let all_gains = entry.value.to_capital_gains();
1297                let filtered = filter_by_range(all_gains, range);
1298                response.capital_gains.insert(symbol.to_string(), filtered);
1299            } else {
1300                response
1301                    .errors
1302                    .insert(symbol.to_string(), "No events data available".to_string());
1303            }
1304        }
1305
1306        Ok(response)
1307    }
1308
1309    /// Batch fetch financial statements for all symbols
1310    ///
1311    /// Fetches the specified financial statement type for all symbols concurrently.
1312    /// Financial statements are cached per (symbol, statement_type, frequency) tuple.
1313    ///
1314    /// # Arguments
1315    ///
1316    /// * `statement_type` - Type of statement (Income, Balance, CashFlow)
1317    /// * `frequency` - Annual or Quarterly
1318    ///
1319    /// # Example
1320    ///
1321    /// ```no_run
1322    /// use finance_query::{Tickers, StatementType, Frequency};
1323    ///
1324    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1325    /// let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await?;
1326    /// let financials = tickers.financials(StatementType::Income, Frequency::Annual).await?;
1327    ///
1328    /// for (symbol, stmt) in &financials.financials {
1329    ///     if let Some(revenue) = stmt.statement.get("TotalRevenue") {
1330    ///         println!("{}: {:?}", symbol, revenue);
1331    ///     }
1332    /// }
1333    /// # Ok(())
1334    /// # }
1335    /// ```
1336    pub async fn financials(
1337        &self,
1338        statement_type: StatementType,
1339        frequency: Frequency,
1340    ) -> Result<BatchFinancialsResponse> {
1341        batch_fetch_cached!(self;
1342            cache: financials_cache,
1343            guard: map(financials_fetch, (statement_type, frequency)),
1344            key: |s| (s.clone(), statement_type, frequency),
1345            response: BatchFinancialsResponse.financials,
1346            fetch: |providers, symbol| {
1347                let sym = symbol.to_string();
1348                providers.fetch(Capability::FUNDAMENTALS, move |p| {
1349                    let sym = sym.clone();
1350                    let p = p.clone();
1351                    async move {
1352                        p.fetch_financials(&sym, statement_type, frequency)
1353                            .await
1354                    }
1355                }).await
1356            },
1357        )
1358    }
1359
1360    /// Batch fetch news articles for all symbols
1361    ///
1362    /// Fetches recent news articles for all symbols concurrently using scrapers.
1363    /// News articles are cached per symbol.
1364    ///
1365    /// # Example
1366    ///
1367    /// ```no_run
1368    /// use finance_query::Tickers;
1369    ///
1370    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1371    /// let tickers = Tickers::new(["AAPL", "MSFT"]).await?;
1372    /// let news = tickers.news().await?;
1373    ///
1374    /// for (symbol, articles) in &news.news {
1375    ///     println!("{}: {} articles", symbol, articles.len());
1376    ///     for article in articles.iter().take(3) {
1377    ///         println!("  - {}", article.title);
1378    ///     }
1379    /// }
1380    /// # Ok(())
1381    /// # }
1382    /// ```
1383    pub async fn news(&self) -> Result<BatchNewsResponse> {
1384        batch_fetch_cached!(self;
1385            cache: news_cache,
1386            guard: simple(news_fetch),
1387            key: |s| s.clone(),
1388            response: BatchNewsResponse.news,
1389            fetch: |providers, symbol| {
1390                let sym = symbol.to_string();
1391                providers.fetch(Capability::CORPORATE, move |p| {
1392                    let sym = sym.clone();
1393                    let p = p.clone();
1394                    async move {
1395                        p.fetch_news(&sym)
1396                            .await
1397                            .map(|data| data.into_iter().collect::<Vec<News>>())
1398                    }
1399                }).await
1400            },
1401        )
1402    }
1403
1404    /// Batch fetch recommendations for all symbols
1405    ///
1406    /// Fetches analyst recommendations and similar stocks for all symbols concurrently.
1407    /// Recommendations are cached per (symbol, limit) tuple — different limits
1408    /// produce different API responses and are cached independently.
1409    ///
1410    /// # Arguments
1411    ///
1412    /// * `limit` - Maximum number of similar stocks to return per symbol
1413    ///
1414    /// # Example
1415    ///
1416    /// ```no_run
1417    /// use finance_query::Tickers;
1418    ///
1419    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1420    /// let tickers = Tickers::new(["AAPL", "MSFT"]).await?;
1421    /// let recommendations = tickers.recommendations(10).await?;
1422    ///
1423    /// for (symbol, rec) in &recommendations.recommendations {
1424    ///     println!("{}: {} recommendations", symbol, rec.count());
1425    ///     for similar in &rec.recommendations {
1426    ///         println!("  - {}: score {}", similar.symbol, similar.score);
1427    ///     }
1428    /// }
1429    /// # Ok(())
1430    /// # }
1431    /// ```
1432    pub async fn recommendations(&self, limit: u32) -> Result<BatchRecommendationsResponse> {
1433        batch_fetch_cached!(self;
1434            cache: recommendations_cache,
1435            guard: map(recommendations_fetch, limit),
1436            key: |s| (s.clone(), limit),
1437            response: BatchRecommendationsResponse.recommendations,
1438            fetch: |providers, symbol| {
1439                let sym = symbol.to_string();
1440                providers.fetch(Capability::CORPORATE, move |p| {
1441                    let sym = sym.clone();
1442                    let p = p.clone();
1443                    async move {
1444                        let items = p.fetch_similar_symbols(&sym, limit).await?;
1445                        Ok(recommendation_from_similar(
1446                            sym,
1447                            Some(p.id()),
1448                            items,
1449                            Some(limit),
1450                        ))
1451                    }
1452                }).await
1453            },
1454        )
1455    }
1456
1457    /// Batch fetch options chains for all symbols
1458    ///
1459    /// Fetches options chains for the specified expiration date for all symbols concurrently.
1460    /// Options are cached per (symbol, date) tuple.
1461    ///
1462    /// # Arguments
1463    ///
1464    /// * `date` - Optional expiration date (Unix timestamp). If None, fetches nearest expiration.
1465    ///
1466    /// # Example
1467    ///
1468    /// ```no_run
1469    /// use finance_query::Tickers;
1470    ///
1471    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1472    /// let tickers = Tickers::new(["AAPL", "MSFT"]).await?;
1473    /// let options = tickers.options(None).await?;
1474    ///
1475    /// for (symbol, opts) in &options.options {
1476    ///     println!("{}: {} expirations", symbol, opts.expiration_dates().len());
1477    /// }
1478    /// # Ok(())
1479    /// # }
1480    /// ```
1481    pub async fn options(&self, date: Option<i64>) -> Result<BatchOptionsResponse> {
1482        batch_fetch_cached!(self;
1483            cache: options_cache,
1484            guard: map(options_fetch, date),
1485            key: |s| (s.clone(), date),
1486            response: BatchOptionsResponse.options,
1487            fetch: |providers, symbol| {
1488                let sym = symbol.to_string();
1489                providers.fetch(Capability::OPTIONS, move |p| {
1490                    let sym = sym.clone();
1491                    let p = p.clone();
1492                    async move {
1493                        p.fetch_options(&sym, date).await
1494                    }
1495                }).await
1496            },
1497        )
1498    }
1499
1500    /// Aggregate upcoming financial events across all symbols into a single
1501    /// time-sorted list.
1502    ///
1503    /// Merges earnings, dividend, and standard-monthly options-expiration events
1504    /// for every symbol — plus, with the `fred` feature, major economic releases
1505    /// (CPI, NFP, GDP, …) — within the forward window `[now, now + range]`,
1506    /// sorted ascending by timestamp.
1507    ///
1508    /// Best-effort per symbol: a symbol whose quote or options fetch fails
1509    /// simply contributes no events rather than failing the whole call.
1510    ///
1511    /// # Example
1512    ///
1513    /// ```no_run
1514    /// use finance_query::{Tickers, TimeRange};
1515    ///
1516    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1517    /// let tickers = Tickers::new(["AAPL", "MSFT", "TSLA"]).await?;
1518    /// let events = tickers.calendar(TimeRange::OneMonth).await?;
1519    /// for e in &events {
1520    ///     println!("{} {:?} {:?}", e.date, e.symbol, e.event);
1521    /// }
1522    /// # Ok(())
1523    /// # }
1524    /// ```
1525    pub async fn calendar(
1526        &self,
1527        range: TimeRange,
1528    ) -> Result<Vec<crate::models::calendar::CalendarEvent>> {
1529        let now = chrono::Utc::now().timestamp();
1530        let window = (now, now + range.approx_duration_secs());
1531
1532        let symbol_strings: Vec<String> = self.symbols.iter().map(|s| s.to_string()).collect();
1533        let providers = Arc::clone(&self.providers);
1534
1535        let per_symbol = symbol_strings.into_iter().map(|sym| {
1536            let providers = Arc::clone(&providers);
1537            async move {
1538                let quote_fut = {
1539                    let sym = sym.clone();
1540                    providers.fetch(Capability::QUOTE, move |p| {
1541                        let sym = sym.clone();
1542                        let p = p.clone();
1543                        async move { p.fetch_quote(&sym).await }
1544                    })
1545                };
1546                let opts_fut = {
1547                    let sym = sym.clone();
1548                    providers.fetch(Capability::OPTIONS, move |p| {
1549                        let sym = sym.clone();
1550                        let p = p.clone();
1551                        async move { p.fetch_options(&sym, None).await }
1552                    })
1553                };
1554                let (quote, options) = tokio::join!(quote_fut, opts_fut);
1555                let calendar_events = quote.ok().and_then(|q| q.calendar_events);
1556                let options = options.ok();
1557                crate::models::calendar::build_symbol_events(
1558                    &sym,
1559                    calendar_events.as_ref(),
1560                    options.as_ref(),
1561                    window,
1562                )
1563            }
1564        });
1565
1566        let per_symbol_fut = stream::iter(per_symbol)
1567            .buffer_unordered(self.max_concurrency)
1568            .collect::<Vec<_>>();
1569
1570        // The FRED economic-release fetch is independent of the per-symbol work,
1571        // so run it concurrently with the per-symbol stream.
1572        #[cfg(feature = "fred")]
1573        let (per_symbol_events, releases) =
1574            tokio::join!(per_symbol_fut, crate::adapters::fred::release_dates());
1575        #[cfg(not(feature = "fred"))]
1576        let per_symbol_events = per_symbol_fut.await;
1577
1578        let mut events: Vec<crate::models::calendar::CalendarEvent> =
1579            per_symbol_events.into_iter().flatten().collect();
1580
1581        #[cfg(feature = "fred")]
1582        if let Ok(releases) = releases {
1583            events.extend(crate::models::calendar::build_economic_events(
1584                releases, window,
1585            ));
1586        }
1587
1588        crate::models::calendar::sort_events(&mut events);
1589        Ok(events)
1590    }
1591
1592    /// Batch calculate all technical indicators for all symbols
1593    ///
1594    /// Calculates complete indicator summaries for all symbols from their chart data.
1595    /// Indicators are cached per (symbol, interval, range) tuple.
1596    ///
1597    /// # Arguments
1598    ///
1599    /// * `interval` - The time interval for each candle
1600    /// * `range` - The time range to fetch data for
1601    ///
1602    /// # Example
1603    ///
1604    /// ```no_run
1605    /// use finance_query::{Tickers, Interval, TimeRange};
1606    ///
1607    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1608    /// let tickers = Tickers::new(["AAPL", "MSFT"]).await?;
1609    /// let indicators = tickers.indicators(Interval::OneDay, TimeRange::ThreeMonths).await?;
1610    ///
1611    /// for (symbol, ind) in &indicators.indicators {
1612    ///     println!("{}: RSI(14) = {:?}, SMA(20) = {:?}", symbol, ind.rsi_14, ind.sma_20);
1613    /// }
1614    /// # Ok(())
1615    /// # }
1616    /// ```
1617    #[cfg(feature = "indicators")]
1618    pub async fn indicators(
1619        &self,
1620        interval: Interval,
1621        range: TimeRange,
1622    ) -> Result<BatchIndicatorsResponse> {
1623        let cache_key_for = |symbol: &Arc<str>| (symbol.clone(), interval, range);
1624
1625        // Fast path: check if all symbols are cached
1626        {
1627            let cache = self.indicators_cache.read().await;
1628            if self.all_cached(&cache, self.symbols.iter().map(&cache_key_for)) {
1629                let mut response = BatchIndicatorsResponse::with_capacity(self.symbols.len());
1630                for symbol in &self.symbols {
1631                    if let Some(entry) = cache.get(&cache_key_for(symbol)) {
1632                        response
1633                            .indicators
1634                            .insert(symbol.to_string(), entry.value.clone());
1635                    }
1636                }
1637                return Ok(response);
1638            }
1639        }
1640
1641        // Slow path: acquire fetch guard to prevent duplicate concurrent calculations
1642        let fetch_guard = Self::get_fetch_guard(&self.indicators_fetch, (interval, range)).await;
1643        let _guard = fetch_guard.lock().await;
1644
1645        // Double-check: another task may have computed while we waited
1646        {
1647            let cache = self.indicators_cache.read().await;
1648            if self.all_cached(&cache, self.symbols.iter().map(&cache_key_for)) {
1649                let mut response = BatchIndicatorsResponse::with_capacity(self.symbols.len());
1650                for symbol in &self.symbols {
1651                    if let Some(entry) = cache.get(&cache_key_for(symbol)) {
1652                        response
1653                            .indicators
1654                            .insert(symbol.to_string(), entry.value.clone());
1655                    }
1656                }
1657                return Ok(response);
1658            }
1659        }
1660
1661        // Fetch charts first (which may already be cached, has its own deduplication)
1662        let charts_response = self.charts(interval, range).await?;
1663
1664        let mut response = BatchIndicatorsResponse::with_capacity(self.symbols.len());
1665
1666        // Calculate all indicators first (no lock held)
1667        let mut calculated_indicators: Vec<(String, indicators::IndicatorsSummary)> = Vec::new();
1668
1669        for (symbol, chart) in &charts_response.charts {
1670            let indicators = indicators::summary::calculate_indicators(&chart.candles);
1671            calculated_indicators.push((symbol.to_string(), indicators));
1672        }
1673
1674        // Now acquire write lock briefly for batch cache insertion
1675        if self.cache_ttl.is_some() {
1676            let mut cache = self.indicators_cache.write().await;
1677            for (symbol, indicators) in &calculated_indicators {
1678                let key: Arc<str> = symbol.as_str().into();
1679                self.cache_insert(&mut cache, cache_key_for(&key), indicators.clone());
1680            }
1681        }
1682
1683        // Populate response (no lock needed)
1684        for (symbol, indicators) in calculated_indicators {
1685            response.indicators.insert(symbol, indicators);
1686        }
1687
1688        // Add errors from chart fetch
1689        for (symbol, error) in &charts_response.errors {
1690            response.errors.insert(symbol.to_string(), error.clone());
1691        }
1692
1693        Ok(response)
1694    }
1695
1696    // ========================================================================
1697    // Dynamic Symbol Management
1698    // ========================================================================
1699
1700    /// Add symbols to the watch list
1701    ///
1702    /// Adds new symbols to track without affecting existing cached data.
1703    ///
1704    /// # Example
1705    ///
1706    /// ```no_run
1707    /// use finance_query::Tickers;
1708    ///
1709    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1710    /// let mut tickers = Tickers::new(["AAPL"]).await?;
1711    /// tickers.add_symbols(["MSFT", "GOOGL"]);
1712    /// assert_eq!(tickers.len(), 3);
1713    /// # Ok(())
1714    /// # }
1715    /// ```
1716    pub fn add_symbols<S, I>(&mut self, symbols: I)
1717    where
1718        S: Into<String>,
1719        I: IntoIterator<Item = S>,
1720    {
1721        // Use HashSet for O(n+m) deduplication instead of O(n*m) linear search
1722        use std::collections::HashSet;
1723
1724        let existing: HashSet<&str> = self.symbols.iter().map(|s| &**s).collect();
1725        let to_add: Vec<Arc<str>> = symbols
1726            .into_iter()
1727            .map(Into::into)
1728            .filter(|s| !existing.contains(s.as_str()))
1729            .map(|s| s.into())
1730            .collect();
1731
1732        self.symbols.extend(to_add);
1733    }
1734
1735    // ========================================================================
1736    // Portfolio Backtesting
1737    // ========================================================================
1738
1739    /// Run a multi-symbol portfolio backtest across all tracked symbols.
1740    ///
1741    /// Fetches charts and dividends for each symbol concurrently, then runs
1742    /// the portfolio engine with the given strategy factory. Capital is shared
1743    /// across all symbols according to the [`PortfolioConfig`] allocation rules.
1744    ///
1745    /// `factory` is called once per symbol to produce an independent strategy
1746    /// instance:
1747    ///
1748    /// ```no_run
1749    /// use finance_query::{Tickers, Interval, TimeRange};
1750    /// use finance_query::backtesting::{SmaCrossover, BacktestConfig};
1751    /// use finance_query::backtesting::portfolio::{PortfolioConfig, RebalanceMode};
1752    ///
1753    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1754    /// let tickers = Tickers::new(["AAPL", "MSFT", "NVDA"]).await?;
1755    ///
1756    /// let config = PortfolioConfig::new(BacktestConfig::default())
1757    ///     .max_total_positions(2)
1758    ///     .rebalance(RebalanceMode::EqualWeight);
1759    ///
1760    /// let result = tickers.backtest(
1761    ///     Interval::OneDay,
1762    ///     TimeRange::TwoYears,
1763    ///     Some(config),
1764    ///     |_sym| SmaCrossover::new(10, 50),
1765    /// ).await?;
1766    ///
1767    /// println!("Portfolio return: {:.2}%", result.portfolio_metrics.total_return_pct);
1768    /// # Ok(())
1769    /// # }
1770    /// ```
1771    ///
1772    /// [`PortfolioConfig`]: backtesting::portfolio::PortfolioConfig
1773    #[cfg(feature = "backtesting")]
1774    pub async fn backtest<S, F>(
1775        &self,
1776        interval: Interval,
1777        range: TimeRange,
1778        config: Option<backtesting::portfolio::PortfolioConfig>,
1779        factory: F,
1780    ) -> backtesting::Result<backtesting::portfolio::PortfolioResult>
1781    where
1782        S: backtesting::Strategy,
1783        F: Fn(&str) -> S,
1784    {
1785        use crate::backtesting::portfolio::{PortfolioEngine, SymbolData};
1786
1787        let config = config.unwrap_or_default();
1788        config.validate(self.symbols.len())?;
1789
1790        // Fetch charts for all symbols (uses the batch chart cache)
1791        let charts = self
1792            .charts(interval, range)
1793            .await
1794            .map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
1795
1796        // Fetch dividends for all symbols (events cache is already warm after charts())
1797        // Treat errors as "no dividends" — dividend processing is best-effort
1798        let dividends_map = self
1799            .dividends(range)
1800            .await
1801            .map(|b| b.dividends)
1802            .unwrap_or_default();
1803
1804        // Assemble SymbolData slices — skip symbols with no chart data
1805        let symbol_data: Vec<SymbolData> = self
1806            .symbols
1807            .iter()
1808            .filter_map(|sym| {
1809                charts.charts.get(sym.as_ref()).map(|chart| {
1810                    let divs = dividends_map.get(sym.as_ref()).cloned().unwrap_or_default();
1811                    SymbolData::new(sym.as_ref(), chart.candles.clone()).with_dividends(divs)
1812                })
1813            })
1814            .collect();
1815
1816        let engine = PortfolioEngine::new(config);
1817        engine.run(&symbol_data, factory)
1818    }
1819
1820    /// Remove symbols from the watch list
1821    ///
1822    /// Removes symbols and clears their cached data to free memory.
1823    ///
1824    /// # Example
1825    ///
1826    /// ```no_run
1827    /// use finance_query::Tickers;
1828    ///
1829    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1830    /// let mut tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await?;
1831    /// tickers.remove_symbols(["MSFT"]);
1832    /// assert_eq!(tickers.len(), 2);
1833    /// # Ok(())
1834    /// # }
1835    /// ```
1836    pub async fn remove_symbols<S, I>(&mut self, symbols: I)
1837    where
1838        S: Into<String>,
1839        I: IntoIterator<Item = S>,
1840    {
1841        use std::collections::HashSet;
1842        let owned: Vec<String> = symbols.into_iter().map(Into::into).collect();
1843        let to_remove: HashSet<&str> = owned.iter().map(|s| s.as_str()).collect();
1844
1845        // Remove from symbol list — O(1) lookup per element
1846        self.symbols.retain(|s| !to_remove.contains(&**s));
1847
1848        // Acquire all independent write locks in parallel
1849        let (
1850            mut quote_cache,
1851            mut chart_cache,
1852            mut events_cache,
1853            mut financials_cache,
1854            mut news_cache,
1855            mut recommendations_cache,
1856            mut options_cache,
1857            mut spark_cache,
1858        ) = tokio::join!(
1859            self.quote_cache.write(),
1860            self.chart_cache.write(),
1861            self.events_cache.write(),
1862            self.financials_cache.write(),
1863            self.news_cache.write(),
1864            self.recommendations_cache.write(),
1865            self.options_cache.write(),
1866            self.spark_cache.write(),
1867        );
1868
1869        // Simple key caches — O(1) per removal
1870        for symbol in &to_remove {
1871            let key: Arc<str> = (*symbol).into();
1872            quote_cache.remove(&key);
1873            events_cache.remove(&key);
1874            news_cache.remove(&key);
1875        }
1876
1877        // Composite key caches — O(n) retain but O(1) contains check
1878        chart_cache.retain(|(sym, _, _), _| !to_remove.contains(&**sym));
1879        financials_cache.retain(|(sym, _, _), _| !to_remove.contains(&**sym));
1880        recommendations_cache.retain(|(sym, _), _| !to_remove.contains(&**sym));
1881        options_cache.retain(|(sym, _), _| !to_remove.contains(&**sym));
1882        spark_cache.retain(|(sym, _, _), _| !to_remove.contains(&**sym));
1883
1884        // Drop all guards before cfg-gated lock
1885        drop((
1886            quote_cache,
1887            chart_cache,
1888            events_cache,
1889            financials_cache,
1890            news_cache,
1891            recommendations_cache,
1892            options_cache,
1893            spark_cache,
1894        ));
1895
1896        #[cfg(feature = "indicators")]
1897        self.indicators_cache
1898            .write()
1899            .await
1900            .retain(|(sym, _, _), _| !to_remove.contains(&**sym));
1901    }
1902
1903    /// Clear all cached data and fetch guards, forcing fresh fetches on next access.
1904    ///
1905    /// Use this when you need up-to-date data from a long-lived `Tickers` instance.
1906    /// Also clears fetch guard maps to prevent unbounded growth.
1907    pub async fn clear_cache(&self) {
1908        tokio::join!(
1909            // Data caches
1910            async { self.quote_cache.write().await.clear() },
1911            async { self.chart_cache.write().await.clear() },
1912            async { self.events_cache.write().await.clear() },
1913            async { self.financials_cache.write().await.clear() },
1914            async { self.news_cache.write().await.clear() },
1915            async { self.recommendations_cache.write().await.clear() },
1916            async { self.options_cache.write().await.clear() },
1917            async { self.spark_cache.write().await.clear() },
1918            async {
1919                #[cfg(feature = "indicators")]
1920                self.indicators_cache.write().await.clear();
1921            },
1922            // Fetch guard maps (prevent unbounded growth)
1923            async { self.charts_fetch.write().await.clear() },
1924            async { self.financials_fetch.write().await.clear() },
1925            async { self.recommendations_fetch.write().await.clear() },
1926            async { self.options_fetch.write().await.clear() },
1927            async { self.spark_fetch.write().await.clear() },
1928            async {
1929                #[cfg(feature = "indicators")]
1930                self.indicators_fetch.write().await.clear();
1931            },
1932        );
1933    }
1934
1935    /// Clear only the cached quote data.
1936    ///
1937    /// The next call to `quotes()` or `quote()` will re-fetch from the API.
1938    pub async fn clear_quote_cache(&self) {
1939        self.quote_cache.write().await.clear();
1940    }
1941
1942    /// Clear only the cached chart, spark, and events data.
1943    ///
1944    /// The next call to `charts()`, `spark()`, `dividends()`, `splits()`,
1945    /// or `capital_gains()` will re-fetch from the API.
1946    pub async fn clear_chart_cache(&self) {
1947        tokio::join!(
1948            async { self.chart_cache.write().await.clear() },
1949            async { self.events_cache.write().await.clear() },
1950            async { self.spark_cache.write().await.clear() },
1951            async {
1952                #[cfg(feature = "indicators")]
1953                self.indicators_cache.write().await.clear();
1954            },
1955        );
1956    }
1957}
1958
1959#[cfg(test)]
1960mod tests {
1961    use super::*;
1962
1963    #[tokio::test]
1964    #[ignore = "requires network access"]
1965    async fn test_tickers_quotes() {
1966        let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await.unwrap();
1967        let result = tickers.quotes().await.unwrap();
1968
1969        assert!(result.success_count() > 0);
1970    }
1971
1972    #[tokio::test]
1973    #[ignore = "requires network access"]
1974    async fn test_tickers_charts() {
1975        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
1976        let result = tickers
1977            .charts(Interval::OneDay, TimeRange::FiveDays)
1978            .await
1979            .unwrap();
1980
1981        assert!(result.success_count() > 0);
1982    }
1983
1984    #[tokio::test]
1985    #[ignore = "requires network access"]
1986    async fn test_tickers_spark() {
1987        let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await.unwrap();
1988        let result = tickers
1989            .spark(Interval::FiveMinutes, TimeRange::OneDay)
1990            .await
1991            .unwrap();
1992
1993        assert!(result.success_count() > 0);
1994
1995        // Verify spark data structure
1996        if let Some(spark) = result.sparks.get("AAPL") {
1997            assert!(!spark.closes.is_empty());
1998            assert_eq!(spark.symbol, "AAPL");
1999            // Verify helper methods work
2000            assert!(spark.percent_change().is_some());
2001        }
2002    }
2003
2004    #[tokio::test]
2005    #[ignore = "requires network access"]
2006    async fn test_tickers_dividends() {
2007        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
2008        let result = tickers.dividends(TimeRange::OneYear).await.unwrap();
2009
2010        assert!(result.success_count() > 0);
2011
2012        // Verify dividend data structure
2013        if let Some(dividends) = result.dividends.get("AAPL")
2014            && !dividends.is_empty()
2015        {
2016            let div = &dividends[0];
2017            assert!(div.timestamp > 0);
2018            assert!(div.amount > 0.0);
2019        }
2020    }
2021
2022    #[tokio::test]
2023    #[ignore = "requires network access"]
2024    async fn test_tickers_splits() {
2025        let tickers = Tickers::new(["NVDA", "TSLA"]).await.unwrap();
2026        let result = tickers.splits(TimeRange::FiveYears).await.unwrap();
2027
2028        // Note: Not all symbols have splits, so we just check for successful response
2029        assert!(result.success_count() > 0);
2030
2031        // If there are splits, verify structure
2032        for splits in result.splits.values() {
2033            for split in splits {
2034                assert!(split.timestamp > 0);
2035                assert!(split.numerator > 0.0);
2036                assert!(split.denominator > 0.0);
2037                assert!(!split.ratio.is_empty());
2038            }
2039        }
2040    }
2041
2042    #[tokio::test]
2043    #[ignore = "requires network access"]
2044    async fn test_tickers_capital_gains() {
2045        let tickers = Tickers::new(["VFIAX", "VTI"]).await.unwrap();
2046        let result = tickers.capital_gains(TimeRange::TwoYears).await.unwrap();
2047
2048        // Note: Not all symbols have capital gains distributions
2049        assert!(result.success_count() > 0);
2050
2051        // If there are capital gains, verify structure
2052        for gains in result.capital_gains.values() {
2053            for gain in gains {
2054                assert!(gain.timestamp > 0);
2055                assert!(gain.amount >= 0.0);
2056            }
2057        }
2058    }
2059
2060    #[tokio::test]
2061    #[ignore = "requires network access"]
2062    async fn test_tickers_financials() {
2063        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
2064        let result = tickers
2065            .financials(StatementType::Income, Frequency::Annual)
2066            .await
2067            .unwrap();
2068
2069        assert!(result.success_count() > 0);
2070
2071        // Verify financial statement structure
2072        for (symbol, stmt) in &result.financials {
2073            assert_eq!(stmt.symbol, *symbol);
2074            assert_eq!(stmt.statement_type, "income");
2075            assert_eq!(stmt.frequency, "annual");
2076            assert!(!stmt.statement.is_empty());
2077
2078            // Common income statement fields
2079            if let Some(revenue) = stmt.statement.get("TotalRevenue") {
2080                assert!(!revenue.is_empty());
2081            }
2082        }
2083    }
2084
2085    #[tokio::test]
2086    #[ignore = "requires network access"]
2087    async fn test_tickers_news() {
2088        let tickers = Tickers::new(["AAPL", "TSLA"]).await.unwrap();
2089        let result = tickers.news().await.unwrap();
2090
2091        assert!(result.success_count() > 0);
2092
2093        // Verify news structure
2094        for articles in result.news.values() {
2095            if !articles.is_empty() {
2096                let article = &articles[0];
2097                assert!(!article.title.is_empty());
2098                assert!(!article.link.is_empty());
2099                assert!(!article.source.is_empty());
2100            }
2101        }
2102    }
2103
2104    #[tokio::test]
2105    #[ignore = "requires network access"]
2106    async fn test_tickers_recommendations() {
2107        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
2108        let result = tickers.recommendations(5).await.unwrap();
2109
2110        assert!(result.success_count() > 0);
2111
2112        // Verify recommendations structure
2113        for (symbol, rec) in &result.recommendations {
2114            assert_eq!(rec.symbol, *symbol);
2115            assert!(rec.count() > 0);
2116            for similar in &rec.recommendations {
2117                assert!(!similar.symbol.is_empty());
2118            }
2119        }
2120    }
2121
2122    #[tokio::test]
2123    #[ignore = "requires network access"]
2124    async fn test_tickers_options() {
2125        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
2126        let result = tickers.options(None).await.unwrap();
2127
2128        assert!(result.success_count() > 0);
2129
2130        // Verify options structure
2131        for opts in result.options.values() {
2132            assert!(!opts.expiration_dates().is_empty());
2133        }
2134    }
2135
2136    #[tokio::test]
2137    #[ignore = "requires network access"]
2138    #[cfg(feature = "indicators")]
2139    async fn test_tickers_indicators() {
2140        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
2141        let result = tickers
2142            .indicators(Interval::OneDay, TimeRange::ThreeMonths)
2143            .await
2144            .unwrap();
2145
2146        assert!(result.success_count() > 0);
2147
2148        // Verify indicators structure
2149        for ind in result.indicators.values() {
2150            // Check that at least some indicators are present
2151            assert!(ind.rsi_14.is_some() || ind.sma_20.is_some());
2152        }
2153    }
2154
2155    #[tokio::test]
2156    async fn test_tickers_add_symbols() {
2157        let mut tickers = Tickers::new(["AAPL"]).await.unwrap();
2158        assert_eq!(tickers.len(), 1);
2159        assert_eq!(tickers.symbols(), &["AAPL"]);
2160
2161        tickers.add_symbols(["MSFT", "GOOGL"]);
2162        assert_eq!(tickers.len(), 3);
2163        assert!(tickers.symbols().contains(&"AAPL"));
2164        assert!(tickers.symbols().contains(&"MSFT"));
2165        assert!(tickers.symbols().contains(&"GOOGL"));
2166
2167        // Adding duplicate shouldn't increase count
2168        tickers.add_symbols(["AAPL"]);
2169        assert_eq!(tickers.len(), 3);
2170    }
2171
2172    #[tokio::test]
2173    #[ignore = "requires network access"]
2174    async fn test_tickers_remove_symbols() {
2175        let mut tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await.unwrap();
2176        assert_eq!(tickers.len(), 3);
2177
2178        // Fetch some data to populate caches
2179        let _ = tickers.quotes().await;
2180
2181        // Remove one symbol
2182        tickers.remove_symbols(["MSFT"]).await;
2183        assert_eq!(tickers.len(), 2);
2184        assert!(tickers.symbols().contains(&"AAPL"));
2185        assert!(!tickers.symbols().contains(&"MSFT"));
2186        assert!(tickers.symbols().contains(&"GOOGL"));
2187
2188        // Verify cache was cleared
2189        let quotes = tickers.quotes().await.unwrap();
2190        assert!(!quotes.quotes.contains_key("MSFT"));
2191        assert_eq!(quotes.quotes.len(), 2);
2192    }
2193}