Skip to main content

finance_query/ticker/
core.rs

1//! Symbol-specific data access from multiple providers.
2
3use crate::adapters::edgar;
4use crate::adapters::yahoo::client::{ClientConfig, YahooClient};
5#[cfg(feature = "backtesting")]
6use crate::backtesting;
7use crate::constants::{Frequency, Interval, Region, StatementType, TimeRange};
8use crate::error::{FinanceError, Result};
9use crate::format::Both;
10#[cfg(any(feature = "backtesting", feature = "indicators"))]
11use crate::indicators;
12use crate::models::chart::events::ChartEvents;
13use crate::models::chart::{CapitalGain, Chart, Dividend, DividendAnalytics, Split};
14use crate::models::corporate::news::News;
15use crate::models::corporate::recommendation::Recommendation;
16use crate::models::filings::{CompanyFacts, EdgarSubmissions, ProviderFilings};
17use crate::models::format::Format;
18use crate::models::fundamentals::FinancialStatement;
19use crate::models::options::Options;
20use crate::models::quote::{
21    AssetProfile, CalendarEvents, DefaultKeyStatistics, Earnings, EarningsHistory, EarningsTrend,
22    EquityPerformance, FinancialData, FundOwnership, FundPerformance, FundProfile, IndexTrend,
23    IndustryTrend, InsiderHolders, InsiderTransactions, InstitutionOwnership,
24    MajorHoldersBreakdown, NetSharePurchaseActivity, Price, Quote, QuoteSummaryResponse,
25    QuoteTypeData, RecommendationTrend, SecFilings, SectorTrend, SummaryDetail, SummaryProfile,
26    TopHoldings, UpgradeDowngradeHistory,
27};
28
29use super::macros::ticker_fetch;
30use crate::providers::types::recommendation_from_similar;
31use crate::providers::yahoo::YahooProvider;
32use crate::providers::{
33    Capability, Fetch, Provider, ProviderAdapter, ProviderSet, Routes, build_providers,
34};
35#[cfg(feature = "risk")]
36use crate::risk;
37use crate::utils::{CacheEntry, CacheMode, FetchGuards, filter_by_range};
38use std::collections::HashMap;
39use std::sync::Arc;
40use std::time::Duration;
41use tokio::sync::RwLock;
42
43type Cache<T> = Arc<RwLock<Option<CacheEntry<T>>>>;
44type MapCache<K, V> = Arc<RwLock<HashMap<K, CacheEntry<V>>>>;
45
46/// Opaque handle to a shared Yahoo Finance client session.
47///
48/// Allows multiple [`Ticker`] and [`Tickers`](crate::Tickers) instances to share
49/// one authenticated session, avoiding redundant auth handshakes.
50///
51/// Obtain via [`Ticker::client_handle`] or [`Tickers::client_handle`](crate::Tickers::client_handle), then
52/// pass to other builders via `.client(handle)`.
53///
54/// # Example
55///
56/// ```no_run
57/// use finance_query::Ticker;
58///
59/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
60/// let aapl = Ticker::new("AAPL").await?;
61/// let handle = aapl.client_handle();
62///
63/// let msft = Ticker::builder("MSFT").client(handle.clone()).build().await?;
64/// let googl = Ticker::builder("GOOGL").client(handle).build().await?;
65/// # Ok(())
66/// # }
67/// ```
68#[derive(Clone)]
69pub struct ClientHandle(pub(crate) Arc<YahooClient>);
70/// Builder for constructing a [`Ticker`] with optional configuration.
71///
72/// Construct via [`Ticker::builder`]. All builder methods are optional;
73/// call [`build`](TickerBuilder::build) to finalize.
74pub struct TickerBuilder {
75    symbol: Arc<str>,
76    config: ClientConfig,
77    shared_client: Option<ClientHandle>,
78    injected_providers: Option<Arc<ProviderSet>>,
79    cache_mode: CacheMode,
80    include_logo: bool,
81}
82
83impl TickerBuilder {
84    fn new(symbol: impl Into<String>) -> Self {
85        Self {
86            symbol: symbol.into().into(),
87            config: ClientConfig::default(),
88            shared_client: None,
89            injected_providers: None,
90            cache_mode: CacheMode::default(),
91            include_logo: false,
92        }
93    }
94    /// Set the region (automatically sets correct lang and region).
95    pub fn region(mut self, region: Region) -> Self {
96        self.config.lang = region.lang().to_string();
97        self.config.region = region.region().to_string();
98        self
99    }
100    /// Set the language code (e.g., "en-US", "ja-JP").
101    pub fn lang(mut self, lang: impl Into<String>) -> Self {
102        self.config.lang = lang.into();
103        self
104    }
105    /// Set the region code (e.g., "US", "JP").
106    pub fn region_code(mut self, r: impl Into<String>) -> Self {
107        self.config.region = r.into();
108        self
109    }
110    /// Set the HTTP request timeout.
111    pub fn timeout(mut self, t: Duration) -> Self {
112        self.config.timeout = t;
113        self
114    }
115    /// Set the proxy URL.
116    pub fn proxy(mut self, p: impl Into<String>) -> Self {
117        self.config.proxy = Some(p.into());
118        self
119    }
120    #[allow(dead_code)]
121    pub(crate) fn config(mut self, c: ClientConfig) -> Self {
122        self.config = c;
123        self
124    }
125    /// Pre-inject a shared provider set (used by [`Providers::ticker`](crate::Providers::ticker)).
126    ///
127    /// Not part of the stable public API — see [`ProviderAdapter`](crate::ProviderAdapter).
128    #[doc(hidden)]
129    pub fn with_provider_set(mut self, set: Arc<ProviderSet>) -> Self {
130        self.injected_providers = Some(set);
131        self
132    }
133    /// Share an existing authenticated session instead of creating a new one.
134    ///
135    /// Avoids redundant auth handshakes when creating multiple `Ticker` instances.
136    /// Obtain a handle from any existing `Ticker` via [`Ticker::client_handle`].
137    ///
138    /// When set, the builder's `config`, `timeout`, `proxy`, `lang`, and `region`
139    /// settings are ignored — the shared session's configuration is used instead.
140    pub fn client(mut self, handle: ClientHandle) -> Self {
141        self.shared_client = Some(handle);
142        self
143    }
144    /// Cache responses for `ttl` instead of the default 60 seconds.
145    pub fn cache(mut self, ttl: Duration) -> Self {
146        self.cache_mode = CacheMode::Ttl(ttl);
147        self
148    }
149    /// Cache responses for the handle's lifetime instead of the default 60
150    /// seconds.
151    pub fn cache_forever(mut self) -> Self {
152        self.cache_mode = CacheMode::Lifetime;
153        self
154    }
155    /// Disable caching — every call fetches fresh data.
156    ///
157    /// By default a `Ticker` caches each response for 60 seconds, so
158    /// repeated accessor calls within that window reuse one fetch.
159    pub fn no_cache(mut self) -> Self {
160        self.cache_mode = CacheMode::Off;
161        self
162    }
163    /// Include company logo URLs in quote responses.
164    pub fn logo(mut self) -> Self {
165        self.include_logo = true;
166        self
167    }
168
169    /// Build the Ticker instance.
170    pub async fn build(self) -> Result<Ticker> {
171        #[cfg(feature = "translation")]
172        let translate_lang = {
173            let lang = crate::translation::Lang::parse(&self.config.lang)?;
174            (!lang.is_english()).then_some(lang)
175        };
176        let providers = if let Some(set) = self.injected_providers {
177            set
178        } else if let Some(handle) = self.shared_client {
179            let yahoo = YahooProvider::from_client(handle.0);
180            let client = yahoo.client_arc();
181            Arc::new(
182                ProviderSet::new(
183                    vec![Arc::new(yahoo) as Arc<dyn ProviderAdapter>],
184                    Routes::new(Fetch::Sequential),
185                )
186                .with_yahoo_client(Some(client)),
187            )
188        } else {
189            Arc::new(
190                build_providers(
191                    &[Provider::Yahoo],
192                    Vec::new(),
193                    &self.config,
194                    Routes::new(Fetch::Sequential),
195                )
196                .await?,
197            )
198        };
199        Ok(Ticker {
200            symbol: self.symbol,
201            providers,
202            cache_mode: self.cache_mode,
203            include_logo: self.include_logo,
204            #[cfg(feature = "translation")]
205            translate_lang,
206            quote_cache: Default::default(),
207            quote_fetch: Arc::new(tokio::sync::Mutex::new(())),
208            chart_cache: Default::default(),
209            chart_guards: Default::default(),
210            events_cache: Default::default(),
211            events_fetch: Arc::new(tokio::sync::Mutex::new(())),
212            news_cache: Default::default(),
213            news_fetch: Arc::new(tokio::sync::Mutex::new(())),
214            logo_cache: Default::default(),
215            options_cache: Default::default(),
216            options_guards: Default::default(),
217            financials_cache: Default::default(),
218            financials_guards: Default::default(),
219            #[cfg(feature = "indicators")]
220            indicators_cache: Default::default(),
221            #[cfg(feature = "indicators")]
222            indicators_guards: Default::default(),
223            edgar_submissions_cache: Default::default(),
224            edgar_submissions_fetch: Arc::new(tokio::sync::Mutex::new(())),
225            edgar_facts_cache: Default::default(),
226            edgar_facts_fetch: Arc::new(tokio::sync::Mutex::new(())),
227        })
228    }
229}
230
231/// The primary entry point for querying financial data for a single symbol.
232///
233/// Data is fetched on first access and cached for 60 seconds by default.
234/// Use the builder via [`Ticker::builder`] for custom configuration, including
235/// [`cache`](TickerBuilder::cache) and [`no_cache`](TickerBuilder::no_cache).
236pub struct Ticker {
237    symbol: Arc<str>,
238    providers: Arc<ProviderSet>,
239    cache_mode: CacheMode,
240    include_logo: bool,
241    #[cfg(feature = "translation")]
242    translate_lang: Option<crate::translation::Lang>,
243    quote_cache: Cache<QuoteSummaryResponse>,
244    quote_fetch: Arc<tokio::sync::Mutex<()>>,
245    chart_cache: MapCache<(Interval, TimeRange), Chart>,
246    chart_guards: FetchGuards<(Interval, TimeRange)>,
247    events_cache: Cache<ChartEvents>,
248    events_fetch: Arc<tokio::sync::Mutex<()>>,
249    news_cache: Cache<Vec<News>>,
250    news_fetch: Arc<tokio::sync::Mutex<()>>,
251    logo_cache: Cache<(Option<String>, Option<String>)>,
252    options_cache: MapCache<Option<i64>, Options>,
253    options_guards: FetchGuards<Option<i64>>,
254    financials_cache: MapCache<(StatementType, Frequency), FinancialStatement>,
255    financials_guards: FetchGuards<(StatementType, Frequency)>,
256    #[cfg(feature = "indicators")]
257    indicators_cache: MapCache<(Interval, TimeRange), indicators::IndicatorsSummary>,
258    #[cfg(feature = "indicators")]
259    indicators_guards: FetchGuards<(Interval, TimeRange)>,
260    edgar_submissions_cache: Cache<EdgarSubmissions>,
261    edgar_submissions_fetch: Arc<tokio::sync::Mutex<()>>,
262    edgar_facts_cache: Cache<CompanyFacts>,
263    edgar_facts_fetch: Arc<tokio::sync::Mutex<()>>,
264}
265
266impl Ticker {
267    /// Creates a new ticker with default configuration.
268    pub async fn new(symbol: impl Into<String>) -> Result<Self> {
269        Self::builder(symbol).build().await
270    }
271    /// Creates a new builder for Ticker.
272    pub fn builder(symbol: impl Into<String>) -> TickerBuilder {
273        TickerBuilder::new(symbol)
274    }
275    /// Returns the ticker symbol.
276    pub fn symbol(&self) -> &str {
277        &self.symbol
278    }
279
280    /// Returns a handle to the underlying Yahoo Finance session.
281    ///
282    /// Pass to other builders via `.client(handle)` to share the authenticated
283    /// session without a new auth handshake.
284    ///
285    /// # Panics
286    ///
287    /// Panics if this ticker was created via [`Providers`](crate::Providers) with
288    /// no Yahoo provider configured. For session sharing across multiple tickers,
289    /// prefer [`Providers::ticker`](crate::Providers::ticker) instead.
290    pub fn client_handle(&self) -> ClientHandle {
291        ClientHandle(
292            self.providers
293                .first_yahoo()
294                .expect("client_handle requires a Yahoo session; use Providers::ticker() for multi-provider tickers"),
295        )
296    }
297
298    #[allow(dead_code)]
299    pub(crate) fn provider_set(&self) -> &Arc<ProviderSet> {
300        &self.providers
301    }
302
303    /// Translate a response value when a non-English language is configured
304    /// (no-op otherwise).
305    #[cfg(feature = "translation")]
306    pub(crate) async fn translate_response<T: crate::translation::Translatable>(
307        &self,
308        value: &mut T,
309    ) -> Result<()> {
310        if let Some(lang) = &self.translate_lang {
311            crate::translation::translate_with(value, lang).await?;
312        }
313        Ok(())
314    }
315
316    fn is_cache_fresh<T>(&self, entry: Option<&CacheEntry<T>>) -> bool {
317        CacheEntry::is_fresh_entry(entry, self.cache_mode)
318    }
319
320    fn cache_insert<K: Eq + std::hash::Hash, V>(
321        &self,
322        map: &mut HashMap<K, CacheEntry<V>>,
323        key: K,
324        value: V,
325    ) {
326        crate::utils::cache_insert(
327            map,
328            key,
329            value,
330            self.cache_mode,
331            crate::utils::EVICTION_THRESHOLD,
332        );
333    }
334
335    /// Get full quote data, optionally including logo URLs.
336    pub async fn quote<F>(&self) -> Result<Quote<F>>
337    where
338        F: Format,
339        Quote<Both>: Into<Quote<F>>,
340    {
341        let logo_fut = async {
342            if !self.include_logo {
343                return (None, None);
344            }
345            if let Some(e) = self.logo_cache.read().await.as_ref()
346                && self.is_cache_fresh(Some(e))
347            {
348                return e.value.clone();
349            }
350            let fetched = match self.providers.first_yahoo() {
351                Ok(y) => y.get_logo_url(&self.symbol).await,
352                Err(e) => Err(e),
353            };
354            // Only a successful lookup is cached. A symbol that genuinely has no
355            // logo resolves to `(None, None)` and caches like any other answer;
356            // a transport error does not, so one blip can't become permanent for
357            // the handle's life.
358            match fetched {
359                Ok(logos) => {
360                    if self.cache_mode.enabled() {
361                        *self.logo_cache.write().await = Some(CacheEntry::new(logos.clone()));
362                    }
363                    logos
364                }
365                Err(_) => (None, None),
366            }
367        };
368
369        let (cache, (logo_url, company_logo_url)) = tokio::join!(self.ensure_quote(), logo_fut);
370        let cache = cache?;
371        let summary = cache.as_ref().ok_or_else(|| {
372            FinanceError::ApiError("Quote summary cache was empty after fetch".to_string())
373        })?;
374        let quote = Quote::from_response(&summary.value, logo_url, company_logo_url);
375        #[cfg(feature = "translation")]
376        let quote = {
377            drop(cache);
378            let mut quote = quote;
379            self.translate_response(&mut quote).await?;
380            quote
381        };
382        Ok(quote.into())
383    }
384
385    fn chart_from_provider_data(
386        mut data: Chart,
387        interval: Option<Interval>,
388        range: Option<TimeRange>,
389    ) -> Chart {
390        data.interval = interval;
391        data.range = range;
392        data
393    }
394
395    /// Get historical OHLCV chart data.
396    pub async fn chart(&self, interval: Interval, range: TimeRange) -> Result<Chart> {
397        let key = (interval, range);
398        {
399            let cache = self.chart_cache.read().await;
400            if let Some(entry) = cache.get(&key)
401                && self.is_cache_fresh(Some(entry))
402            {
403                return Ok(entry.value.clone());
404            }
405        }
406        self.chart_guards
407            .dedup(key, || async {
408                {
409                    let cache = self.chart_cache.read().await;
410                    if let Some(entry) = cache.get(&key)
411                        && self.is_cache_fresh(Some(entry))
412                    {
413                        return Ok(entry.value.clone());
414                    }
415                }
416                let data =
417                    ticker_fetch!(self, CHART, as_chart, Chart, fetch_chart, interval, range)?;
418                let chart = Self::chart_from_provider_data(data, Some(interval), Some(range));
419                if self.cache_mode.enabled() {
420                    let mut cache = self.chart_cache.write().await;
421                    self.cache_insert(&mut cache, key, chart.clone());
422                }
423                Ok(chart)
424            })
425            .await
426    }
427
428    /// Get chart data for a custom start/end timestamp range.
429    pub async fn chart_range(&self, interval: Interval, start: i64, end: i64) -> Result<Chart> {
430        if start >= end {
431            return Err(FinanceError::InvalidParameter {
432                param: "end".into(),
433                reason: format!("end ({end}) must be > start ({start})"),
434            });
435        }
436        let data = ticker_fetch!(
437            self,
438            CHART,
439            as_chart,
440            ChartRange,
441            fetch_chart_range,
442            interval,
443            start,
444            end
445        )?;
446        Ok(Self::chart_from_provider_data(data, Some(interval), None))
447    }
448
449    async fn ensure_events(&self) -> Result<()> {
450        {
451            let cache = self.events_cache.read().await;
452            if self.is_cache_fresh(cache.as_ref()) {
453                return Ok(());
454            }
455        }
456        let _guard = self.events_fetch.lock().await;
457        {
458            let cache = self.events_cache.read().await;
459            if self.is_cache_fresh(cache.as_ref()) {
460                return Ok(());
461            }
462        }
463        let events = ticker_fetch!(self, CORPORATE, as_corporate, Events, fetch_events)?;
464        let mut cache = self.events_cache.write().await;
465        *cache = Some(CacheEntry::new(events));
466        Ok(())
467    }
468
469    /// Get dividend history.
470    pub async fn dividends(&self, range: TimeRange) -> Result<Vec<Dividend>> {
471        self.ensure_events().await?;
472        let cache = self.events_cache.read().await;
473        let all = cache
474            .as_ref()
475            .map(|e| e.value.to_dividends())
476            .unwrap_or_default();
477        Ok(filter_by_range(all, range))
478    }
479    /// Compute dividend analytics for the requested time range.
480    pub async fn dividend_analytics(&self, range: TimeRange) -> Result<DividendAnalytics> {
481        let divs = self.dividends(range).await?;
482        Ok(DividendAnalytics::from_dividends(&divs))
483    }
484    /// Get stock split history.
485    pub async fn splits(&self, range: TimeRange) -> Result<Vec<Split>> {
486        self.ensure_events().await?;
487        let cache = self.events_cache.read().await;
488        let all = cache
489            .as_ref()
490            .map(|e| e.value.to_splits())
491            .unwrap_or_default();
492        Ok(filter_by_range(all, range))
493    }
494    /// Get capital gains distribution history.
495    pub async fn capital_gains(&self, range: TimeRange) -> Result<Vec<CapitalGain>> {
496        self.ensure_events().await?;
497        let cache = self.events_cache.read().await;
498        let all = cache
499            .as_ref()
500            .map(|e| e.value.to_capital_gains())
501            .unwrap_or_default();
502        Ok(filter_by_range(all, range))
503    }
504
505    /// Get analyst recommendations and similar symbols.
506    pub async fn recommendations(&self, limit: u32) -> Result<Recommendation> {
507        if limit == 0 {
508            return Err(FinanceError::InvalidParameter {
509                param: "limit".into(),
510                reason: "limit must be > 0".into(),
511            });
512        }
513        let sym = self.symbol.clone();
514        let (provider_id, items) = self
515            .providers
516            .fetch(Capability::CORPORATE, move |p| {
517                let sym = sym.clone();
518                let p = p.clone();
519                async move {
520                    let r = p
521                        .as_corporate()
522                        .ok_or_else(|| {
523                            p.not_supported(crate::providers::Operation::Recommendations)
524                        })?
525                        .fetch_similar_symbols(&sym, limit)
526                        .await?;
527                    Ok((p.id(), r))
528                }
529            })
530            .await?;
531        Ok(recommendation_from_similar(
532            self.symbol.to_string(),
533            Some(provider_id),
534            items,
535            Some(limit),
536        ))
537    }
538
539    /// Get news articles for this symbol.
540    pub async fn news(&self) -> Result<Vec<News>> {
541        {
542            let cache = self.news_cache.read().await;
543            if let Some(e) = cache.as_ref()
544                && self.is_cache_fresh(Some(e))
545            {
546                return Ok(e.value.clone());
547            }
548        }
549        let _guard = self.news_fetch.lock().await;
550        {
551            let cache = self.news_cache.read().await;
552            if let Some(e) = cache.as_ref()
553                && self.is_cache_fresh(Some(e))
554            {
555                return Ok(e.value.clone());
556            }
557        }
558        let data = ticker_fetch!(self, CORPORATE, as_corporate, News, fetch_news)?;
559        let news = data;
560        // Score titles before translation — VADER is English-lexicon based.
561        #[cfg(feature = "sentiment")]
562        let news = {
563            let mut news = news;
564            for article in news.iter_mut() {
565                article.sentiment = Some(crate::models::sentiment::analyze(&article.title));
566            }
567            news
568        };
569        #[cfg(feature = "translation")]
570        let news = {
571            let mut news = news;
572            self.translate_response(&mut news).await?;
573            news
574        };
575        if self.cache_mode.enabled() {
576            let mut c = self.news_cache.write().await;
577            *c = Some(CacheEntry::new(news.clone()));
578        }
579        Ok(news)
580    }
581
582    /// Average sentiment across recent news headlines for this symbol.
583    ///
584    /// Positive = net bullish coverage, negative = net bearish. Returns a
585    /// neutral, zero-confidence score when there are no headlines.
586    ///
587    /// Only available when the `sentiment` feature is enabled.
588    #[cfg(feature = "sentiment")]
589    pub async fn news_sentiment(&self) -> Result<crate::models::sentiment::Sentiment> {
590        let news = self.news().await?;
591        let scores: Vec<f64> = news
592            .iter()
593            .filter_map(|n| n.sentiment.as_ref().map(|s| s.score))
594            .collect();
595        Ok(crate::models::sentiment::aggregate(&scores)
596            .unwrap_or_else(crate::models::sentiment::Sentiment::neutral))
597    }
598
599    /// Get the options chain.
600    pub async fn options(&self, date: Option<i64>) -> Result<Options> {
601        {
602            let cache = self.options_cache.read().await;
603            if let Some(e) = cache.get(&date)
604                && self.is_cache_fresh(Some(e))
605            {
606                return Ok(e.value.clone());
607            }
608        }
609        self.options_guards
610            .dedup(date, || async {
611                {
612                    let cache = self.options_cache.read().await;
613                    if let Some(e) = cache.get(&date)
614                        && self.is_cache_fresh(Some(e))
615                    {
616                        return Ok(e.value.clone());
617                    }
618                }
619                let opts = ticker_fetch!(self, OPTIONS, as_options, Options, fetch_options, date)?;
620                if self.cache_mode.enabled() {
621                    let mut c = self.options_cache.write().await;
622                    self.cache_insert(&mut c, date, opts.clone());
623                }
624                Ok(opts)
625            })
626            .await
627    }
628
629    /// Get financial statements.
630    pub async fn financials(
631        &self,
632        stmt_type: StatementType,
633        frequency: Frequency,
634    ) -> Result<FinancialStatement> {
635        let key = (stmt_type, frequency);
636        {
637            let cache = self.financials_cache.read().await;
638            if let Some(e) = cache.get(&key)
639                && self.is_cache_fresh(Some(e))
640            {
641                return Ok(e.value.clone());
642            }
643        }
644        self.financials_guards
645            .dedup(key, || async {
646                {
647                    let cache = self.financials_cache.read().await;
648                    if let Some(e) = cache.get(&key)
649                        && self.is_cache_fresh(Some(e))
650                    {
651                        return Ok(e.value.clone());
652                    }
653                }
654                let stmt = ticker_fetch!(
655                    self,
656                    FUNDAMENTALS,
657                    as_fundamentals,
658                    Financials,
659                    fetch_financials,
660                    stmt_type,
661                    frequency
662                )?;
663                if self.cache_mode.enabled() {
664                    let mut c = self.financials_cache.write().await;
665                    self.cache_insert(&mut c, key, stmt.clone());
666                }
667                Ok(stmt)
668            })
669            .await
670    }
671
672    #[cfg(feature = "indicators")]
673    /// Calculate all technical indicators from chart data.
674    pub async fn indicators(
675        &self,
676        interval: Interval,
677        range: TimeRange,
678    ) -> Result<indicators::IndicatorsSummary> {
679        let key = (interval, range);
680        {
681            let cache = self.indicators_cache.read().await;
682            if let Some(e) = cache.get(&key)
683                && self.is_cache_fresh(Some(e))
684            {
685                return Ok(e.value.clone());
686            }
687        }
688        self.indicators_guards
689            .dedup(key, || async {
690                {
691                    let cache = self.indicators_cache.read().await;
692                    if let Some(e) = cache.get(&key)
693                        && self.is_cache_fresh(Some(e))
694                    {
695                        return Ok(e.value.clone());
696                    }
697                }
698                let chart = self.chart(interval, range).await?;
699                let ind = indicators::summary::calculate_indicators(&chart.candles);
700                if self.cache_mode.enabled() {
701                    let mut c = self.indicators_cache.write().await;
702                    self.cache_insert(&mut c, key, ind.clone());
703                }
704                Ok(ind)
705            })
706            .await
707    }
708
709    /// Get SEC EDGAR filing history for this symbol.
710    ///
711    /// Always uses EDGAR directly — this is an EDGAR-specific API (CIK-based submission
712    /// history and XBRL company facts) that no other provider replicates. For routable
713    /// provider-agnostic filing data use [`filings`](Self::filings) instead.
714    pub async fn edgar_submissions(&self) -> Result<EdgarSubmissions> {
715        {
716            let cache = self.edgar_submissions_cache.read().await;
717            if let Some(e) = cache.as_ref()
718                && self.is_cache_fresh(Some(e))
719            {
720                return Ok(e.value.clone());
721            }
722        }
723        let _guard = self.edgar_submissions_fetch.lock().await;
724        {
725            let cache = self.edgar_submissions_cache.read().await;
726            if let Some(e) = cache.as_ref()
727                && self.is_cache_fresh(Some(e))
728            {
729                return Ok(e.value.clone());
730            }
731        }
732        let subs = edgar::submissions_for_symbol(&self.symbol).await?;
733        if self.cache_mode.enabled() {
734            let mut c = self.edgar_submissions_cache.write().await;
735            *c = Some(CacheEntry::new(subs.clone()));
736        }
737        Ok(subs)
738    }
739
740    /// Get SEC EDGAR company facts (structured XBRL financial data).
741    ///
742    /// Always uses EDGAR directly — XBRL `us-gaap`/`ifrs`/`dei` fact data is unique
743    /// to the SEC's EDGAR API. For routable filing data use [`filings`](Self::filings).
744    pub async fn edgar_company_facts(&self) -> Result<CompanyFacts> {
745        {
746            let cache = self.edgar_facts_cache.read().await;
747            if let Some(e) = cache.as_ref()
748                && self.is_cache_fresh(Some(e))
749            {
750                return Ok(e.value.clone());
751            }
752        }
753        let _guard = self.edgar_facts_fetch.lock().await;
754        {
755            let cache = self.edgar_facts_cache.read().await;
756            if let Some(e) = cache.as_ref()
757                && self.is_cache_fresh(Some(e))
758            {
759                return Ok(e.value.clone());
760            }
761        }
762        let facts = edgar::company_facts_for_symbol(&self.symbol).await?;
763        if self.cache_mode.enabled() {
764            let mut c = self.edgar_facts_cache.write().await;
765            *c = Some(CacheEntry::new(facts.clone()));
766        }
767        Ok(facts)
768    }
769
770    /// Fetch SEC filings via the configured [`Capability::FILINGS`] provider.
771    ///
772    /// Routes through the provider system; EDGAR is always available as a fallback
773    /// (auto-injected when no explicit FILINGS route is set). To prefer Polygon:
774    /// `.route(Capability::FILINGS, [Provider::Polygon, Provider::Edgar])`.
775    ///
776    /// For the full EDGAR submissions response or structured XBRL data, use
777    /// [`edgar_submissions`](Self::edgar_submissions) / [`edgar_company_facts`](Self::edgar_company_facts).
778    pub async fn filings(&self) -> Result<ProviderFilings> {
779        ticker_fetch!(self, FILINGS, as_filings, Filings, fetch_filings)
780    }
781
782    /// Fetch short-interest settlement reports via the configured
783    /// [`Capability::FUNDAMENTALS`] provider. The default Yahoo route derives
784    /// the current and prior-month snapshots from key statistics (keyless);
785    /// route to Polygon for the full bi-monthly history:
786    /// `.route(Capability::FUNDAMENTALS, [Provider::Polygon, Provider::Yahoo])`.
787    pub async fn short_interest(&self) -> Result<Vec<crate::models::fundamentals::ShortInterest>> {
788        ticker_fetch!(
789            self,
790            FUNDAMENTALS,
791            as_fundamentals,
792            ShortInterest,
793            fetch_short_interest
794        )
795    }
796
797    /// Fetch daily short-volume data via the configured
798    /// [`Capability::FUNDAMENTALS`] provider (currently Polygon only).
799    pub async fn short_volume(&self) -> Result<Vec<crate::models::fundamentals::ShortVolume>> {
800        ticker_fetch!(
801            self,
802            FUNDAMENTALS,
803            as_fundamentals,
804            ShortVolume,
805            fetch_short_volume
806        )
807    }
808
809    /// Fetch share float and shares outstanding via the configured
810    /// [`Capability::FUNDAMENTALS`] provider (Yahoo-derived on the default
811    /// route; Polygon serves it too).
812    pub async fn share_float(&self) -> Result<crate::models::fundamentals::ShareFloat> {
813        ticker_fetch!(
814            self,
815            FUNDAMENTALS,
816            as_fundamentals,
817            ShareFloat,
818            fetch_share_float
819        )
820    }
821
822    /// Fetch the company's own press releases via the configured
823    /// [`Capability::CORPORATE`] provider (currently FMP only). Distinct from
824    /// [`news`](Self::news), which returns press coverage.
825    pub async fn press_releases(
826        &self,
827        limit: u32,
828    ) -> Result<Vec<crate::models::corporate::press_release::PressRelease>> {
829        ticker_fetch!(
830            self,
831            CORPORATE,
832            as_corporate,
833            PressReleases,
834            fetch_press_releases,
835            limit
836        )
837    }
838
839    /// Fetch the aggregated analyst price-target consensus (high/low/mean/median)
840    /// via the configured [`Capability::FUNDAMENTALS`] provider (currently FMP
841    /// only). Route with
842    /// `.route(Capability::FUNDAMENTALS, [Provider::Fmp, Provider::Yahoo])`.
843    pub async fn price_target_consensus(
844        &self,
845    ) -> Result<crate::models::fundamentals::PriceTargetConsensus> {
846        ticker_fetch!(
847            self,
848            FUNDAMENTALS,
849            as_fundamentals,
850            PriceTargetConsensus,
851            fetch_price_target_consensus
852        )
853    }
854
855    /// Fetch price-target publication activity over trailing windows (last
856    /// month/quarter/year/all time) via the configured
857    /// [`Capability::FUNDAMENTALS`] provider (currently FMP only).
858    pub async fn price_target_summary(
859        &self,
860    ) -> Result<crate::models::fundamentals::PriceTargetSummary> {
861        ticker_fetch!(
862            self,
863            FUNDAMENTALS,
864            as_fundamentals,
865            PriceTargetSummary,
866            fetch_price_target_summary
867        )
868    }
869
870    /// Fetch the aggregated analyst rating consensus (grade distribution plus a
871    /// headline label) via the configured [`Capability::FUNDAMENTALS`] provider
872    /// (currently FMP only). Distinct from
873    /// [`recommendations`](Self::recommendations), which returns similar symbols.
874    pub async fn rating_consensus(&self) -> Result<crate::models::fundamentals::RatingConsensus> {
875        ticker_fetch!(
876            self,
877            FUNDAMENTALS,
878            as_fundamentals,
879            RatingConsensus,
880            fetch_rating_consensus
881        )
882    }
883
884    /// Fetch the trailing-twelve-month key-metrics snapshot via the configured
885    /// [`Capability::FUNDAMENTALS`] provider (currently FMP only).
886    ///
887    /// A TTM snapshot is a single always-current rollup, so callers do not need
888    /// to fetch the latest fiscal period and reason about whether it is still
889    /// current — see [`financials`](Self::financials) for the period series.
890    pub async fn key_metrics_ttm(&self) -> Result<crate::models::fundamentals::KeyMetricsTtm> {
891        ticker_fetch!(
892            self,
893            FUNDAMENTALS,
894            as_fundamentals,
895            KeyMetricsTtm,
896            fetch_key_metrics_ttm
897        )
898    }
899
900    /// Fetch the trailing-twelve-month financial-ratios snapshot via the
901    /// configured [`Capability::FUNDAMENTALS`] provider (currently FMP only).
902    pub async fn ratios_ttm(&self) -> Result<crate::models::fundamentals::FinancialRatiosTtm> {
903        ticker_fetch!(
904            self,
905            FUNDAMENTALS,
906            as_fundamentals,
907            RatiosTtm,
908            fetch_ratios_ttm
909        )
910    }
911
912    /// Fetch reported executive compensation (most recent fiscal year first)
913    /// via the configured [`Capability::CORPORATE`] provider (currently FMP
914    /// only). Extracted from DEF 14A proxy statements, so it lags the filing.
915    pub async fn executive_compensation(
916        &self,
917    ) -> Result<Vec<crate::models::corporate::governance::ExecutiveCompensation>> {
918        ticker_fetch!(
919            self,
920            CORPORATE,
921            as_corporate,
922            ExecutiveCompensation,
923            fetch_executive_compensation
924        )
925    }
926
927    /// Fetch reported employee headcount history (most recent period first) via
928    /// the configured [`Capability::CORPORATE`] provider (currently FMP only).
929    /// Taken from 10-K cover pages, so it is annual.
930    pub async fn employee_count(
931        &self,
932    ) -> Result<Vec<crate::models::corporate::governance::EmployeeCount>> {
933        ticker_fetch!(
934            self,
935            CORPORATE,
936            as_corporate,
937            EmployeeCount,
938            fetch_employee_count
939        )
940    }
941
942    /// Fetch this fund's profile and portfolio holdings via the configured
943    /// [`Capability::FUNDAMENTALS`] provider (currently Alpha Vantage only,
944    /// and the only wired source of ETF holdings at all).
945    ///
946    /// Holdings come back heaviest-first. Errors for a symbol that is not a
947    /// fund.
948    pub async fn etf_profile(&self) -> Result<crate::models::fundamentals::EtfProfile> {
949        ticker_fetch!(
950            self,
951            FUNDAMENTALS,
952            as_fundamentals,
953            EtfProfile,
954            fetch_etf_profile
955        )
956    }
957
958    /// Fetch earnings-surprise history (most recent first) via the configured
959    /// [`Capability::FUNDAMENTALS`] provider (FMP or Alpha Vantage).
960    pub async fn earnings_surprises(
961        &self,
962    ) -> Result<Vec<crate::models::fundamentals::EarningsSurprise>> {
963        ticker_fetch!(
964            self,
965            FUNDAMENTALS,
966            as_fundamentals,
967            EarningsSurprises,
968            fetch_earnings_surprises
969        )
970    }
971
972    /// Fetch the raw per-analyst grade-action history via the configured
973    /// [`Capability::FUNDAMENTALS`] provider (currently FMP only). Distinct
974    /// from [`rating_consensus`](Self::rating_consensus), which returns the
975    /// aggregated rollup over this same history.
976    pub async fn grading_actions(&self) -> Result<Vec<crate::models::fundamentals::GradingAction>> {
977        ticker_fetch!(
978            self,
979            FUNDAMENTALS,
980            as_fundamentals,
981            GradingHistory,
982            fetch_grading_history
983        )
984    }
985
986    /// Fetch the company's identity/classification profile via the
987    /// configured [`Capability::FUNDAMENTALS`] provider (currently Alpha
988    /// Vantage only).
989    pub async fn company_profile(&self) -> Result<crate::models::fundamentals::CompanyProfile> {
990        ticker_fetch!(
991            self,
992            FUNDAMENTALS,
993            as_fundamentals,
994            CompanyProfile,
995            fetch_company_profile
996        )
997    }
998
999    /// Fetch an earnings call transcript, provider-neutral shape, via the
1000    /// configured [`Capability::CORPORATE`] provider (Yahoo or Alpha
1001    /// Vantage). `quarter` and `year` narrow to a specific call; Alpha
1002    /// Vantage requires both, Yahoo defaults to the latest when omitted.
1003    /// Distinct from the Yahoo-only, richer
1004    /// [`finance::earnings_transcript`](crate::finance::earnings_transcript).
1005    pub async fn earnings_transcript(
1006        &self,
1007        quarter: Option<&str>,
1008        year: Option<i32>,
1009    ) -> Result<crate::models::corporate::earnings_transcript::EarningsTranscript> {
1010        ticker_fetch!(
1011            self,
1012            CORPORATE,
1013            as_corporate,
1014            EarningsTranscript,
1015            fetch_earnings_transcript,
1016            quarter,
1017            year
1018        )
1019    }
1020
1021    #[cfg(feature = "indicators")]
1022    /// Calculate a specific technical indicator over a time range.
1023    pub async fn indicator(
1024        &self,
1025        indicator: indicators::Indicator,
1026        interval: Interval,
1027        range: TimeRange,
1028    ) -> Result<indicators::IndicatorResult> {
1029        let chart = self.chart(interval, range).await?;
1030        Ok(indicators::compute_indicator(indicator, &chart)?)
1031    }
1032
1033    #[cfg(feature = "backtesting")]
1034    /// Run a backtest with the given strategy and configuration.
1035    pub async fn backtest<S: backtesting::Strategy>(
1036        &self,
1037        strategy: S,
1038        interval: Interval,
1039        range: TimeRange,
1040        config: Option<backtesting::BacktestConfig>,
1041    ) -> backtesting::Result<backtesting::BacktestResult> {
1042        let config = config.unwrap_or_default();
1043        config.validate()?;
1044        // Chart and dividends hit disjoint caches and disjoint capabilities
1045        // (CHART vs CORPORATE), so neither warms the other.
1046        let (chart, dividends) = tokio::join!(self.chart(interval, range), self.dividends(range));
1047        let chart = chart.map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
1048        let dividends = dividends.unwrap_or_default();
1049        backtesting::BacktestEngine::new(config).run_with_dividends(
1050            &self.symbol,
1051            &chart.candles,
1052            strategy,
1053            &dividends,
1054        )
1055    }
1056
1057    #[cfg(feature = "backtesting")]
1058    /// Run a backtest and compare performance against a benchmark symbol.
1059    pub async fn backtest_with_benchmark<S: backtesting::Strategy>(
1060        &self,
1061        strategy: S,
1062        interval: Interval,
1063        range: TimeRange,
1064        config: Option<backtesting::BacktestConfig>,
1065        benchmark: &str,
1066    ) -> backtesting::Result<backtesting::BacktestResult> {
1067        let config = config.unwrap_or_default();
1068        config.validate()?;
1069        let bench_fut = async {
1070            let bench_ticker = Ticker::new(benchmark).await?;
1071            bench_ticker.chart(interval, range).await
1072        };
1073        // `join!`, not `try_join!`: both charts are awaited to completion and the
1074        // errors resolved in a fixed order, so the surfaced error is always the
1075        // primary symbol's rather than whichever future happened to fail first.
1076        let (chart, bench_chart, dividends) = tokio::join!(
1077            self.chart(interval, range),
1078            bench_fut,
1079            self.dividends(range)
1080        );
1081        let chart = chart.map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
1082        let bench_chart =
1083            bench_chart.map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
1084        let dividends = dividends.unwrap_or_default();
1085        backtesting::BacktestEngine::new(config).run_with_benchmark(
1086            &self.symbol,
1087            &chart.candles,
1088            strategy,
1089            &dividends,
1090            benchmark,
1091            &bench_chart.candles,
1092        )
1093    }
1094
1095    #[cfg(feature = "risk")]
1096    /// Compute a risk summary for this symbol.
1097    pub async fn risk(
1098        &self,
1099        interval: Interval,
1100        range: TimeRange,
1101        benchmark: Option<&str>,
1102    ) -> Result<risk::RiskSummary> {
1103        let bench_fut = async {
1104            let Some(sym) = benchmark else {
1105                return Result::Ok(None);
1106            };
1107            let bt = Ticker::new(sym).await?;
1108            let bench_chart = bt.chart(interval, range).await?;
1109            Result::Ok(Some(risk::candles_to_returns(&bench_chart.candles)))
1110        };
1111        // `join!`, not `try_join!`: resolving in a fixed order keeps the primary
1112        // symbol's error as the surfaced one, matching the previous sequential
1113        // `self.chart(..).await?` ordering.
1114        let (chart, bench_returns) = tokio::join!(self.chart(interval, range), bench_fut);
1115        let chart = chart?;
1116        let bench_returns = bench_returns?;
1117        Ok(risk::compute_risk_summary(
1118            &chart.candles,
1119            bench_returns.as_deref(),
1120        ))
1121    }
1122
1123    /// Aggregate upcoming financial events for this ticker into a single
1124    /// time-sorted list.
1125    ///
1126    /// Combines earnings, ex-dividend and dividend-payment dates with standard
1127    /// monthly options expirations, plus — when the `fred` feature is enabled —
1128    /// a curated set of major economic releases (CPI, NFP, GDP, …). Limited to
1129    /// the forward window `[now, now + range]` and sorted ascending by
1130    /// timestamp.
1131    ///
1132    /// Options are best-effort: a symbol with no listed options contributes no
1133    /// expiration events rather than failing the call.
1134    pub async fn calendar(
1135        &self,
1136        range: TimeRange,
1137    ) -> Result<Vec<crate::models::calendar::CalendarEvent>> {
1138        let now = chrono::Utc::now().timestamp();
1139        let window = (now, now + range.approx_duration_secs());
1140
1141        // The FRED economic-release fetch is independent of the per-symbol
1142        // quote/options work, so run all three concurrently.
1143        #[cfg(feature = "fred")]
1144        let (calendar_events, options, releases) = tokio::join!(
1145            self.calendar_events(),
1146            self.options(None),
1147            crate::adapters::fred::release_dates(),
1148        );
1149        #[cfg(not(feature = "fred"))]
1150        let (calendar_events, options) = tokio::join!(self.calendar_events(), self.options(None));
1151
1152        let calendar_events = calendar_events?;
1153        let options = options.ok();
1154
1155        let mut events = crate::models::calendar::build_symbol_events(
1156            &self.symbol,
1157            calendar_events.as_ref(),
1158            options.as_ref(),
1159            window,
1160        );
1161
1162        #[cfg(feature = "fred")]
1163        if let Ok(releases) = releases {
1164            events.extend(crate::models::calendar::build_economic_events(
1165                releases, window,
1166            ));
1167        }
1168
1169        crate::models::calendar::sort_events(&mut events);
1170        Ok(events)
1171    }
1172
1173    async fn ensure_quote(
1174        &self,
1175    ) -> Result<tokio::sync::RwLockReadGuard<'_, Option<CacheEntry<QuoteSummaryResponse>>>> {
1176        {
1177            let cache = self.quote_cache.read().await;
1178            if self.is_cache_fresh(cache.as_ref()) {
1179                return Ok(cache);
1180            }
1181        }
1182        let _guard = self.quote_fetch.lock().await;
1183        {
1184            let cache = self.quote_cache.read().await;
1185            if self.is_cache_fresh(cache.as_ref()) {
1186                return Ok(cache);
1187            }
1188        }
1189        let summary = ticker_fetch!(self, QUOTE, as_quote, Quote, fetch_quote)?;
1190        {
1191            let mut cache = self.quote_cache.write().await;
1192            *cache = Some(CacheEntry::new(summary));
1193        }
1194        Ok(self.quote_cache.read().await)
1195    }
1196}
1197
1198super::macros::define_quote_accessors! {
1199    /// Regular, pre- and post-market price, plus the day's range and volume.
1200    price -> Price, price,
1201    summary_detail -> SummaryDetail, summary_detail,
1202    financial_data -> FinancialData, financial_data,
1203    key_stats -> DefaultKeyStatistics, default_key_statistics,
1204    asset_profile -> AssetProfile, asset_profile,
1205    calendar_events -> CalendarEvents, calendar_events,
1206    earnings -> Earnings, earnings,
1207    earnings_trend -> EarningsTrend, earnings_trend,
1208    earnings_history -> EarningsHistory, earnings_history,
1209    recommendation_trend -> RecommendationTrend, recommendation_trend,
1210    insider_holders -> InsiderHolders, insider_holders,
1211    insider_transactions -> InsiderTransactions, insider_transactions,
1212    institution_ownership -> InstitutionOwnership, institution_ownership,
1213    fund_ownership -> FundOwnership, fund_ownership,
1214    major_holders -> MajorHoldersBreakdown, major_holders_breakdown,
1215    share_purchase_activity -> NetSharePurchaseActivity, net_share_purchase_activity,
1216    quote_type -> QuoteTypeData, quote_type,
1217    summary_profile -> SummaryProfile, summary_profile,
1218    sec_filings -> SecFilings, sec_filings,
1219    grading_history -> UpgradeDowngradeHistory, upgrade_downgrade_history,
1220    fund_performance -> FundPerformance, fund_performance,
1221    fund_profile -> FundProfile, fund_profile,
1222    top_holdings -> TopHoldings, top_holdings,
1223    index_trend -> IndexTrend, index_trend,
1224    industry_trend -> IndustryTrend, industry_trend,
1225    sector_trend -> SectorTrend, sector_trend,
1226    equity_performance -> EquityPerformance, equity_performance,
1227}
1228
1229#[cfg(test)]
1230mod tests {
1231    use super::*;
1232    use crate::providers::mock::{CountingProvider, provider_set};
1233
1234    #[tokio::test]
1235    async fn default_caches_quote_across_accessors() {
1236        let provider = CountingProvider::new();
1237        let ticker = Ticker::builder("AAPL")
1238            .with_provider_set(provider_set(Arc::clone(&provider)))
1239            .build()
1240            .await
1241            .unwrap();
1242
1243        let _ = ticker.price().await.unwrap();
1244        let _ = ticker.summary_detail().await.unwrap();
1245        let _ = ticker.asset_profile().await.unwrap();
1246
1247        assert_eq!(provider.quotes(), 1);
1248    }
1249
1250    #[tokio::test]
1251    async fn no_cache_refetches_every_accessor() {
1252        let provider = CountingProvider::new();
1253        let ticker = Ticker::builder("AAPL")
1254            .with_provider_set(provider_set(Arc::clone(&provider)))
1255            .no_cache()
1256            .build()
1257            .await
1258            .unwrap();
1259
1260        let _ = ticker.price().await.unwrap();
1261        let _ = ticker.summary_detail().await.unwrap();
1262        let _ = ticker.asset_profile().await.unwrap();
1263
1264        assert_eq!(provider.quotes(), 3);
1265    }
1266
1267    #[tokio::test]
1268    async fn charts_cache_per_interval_and_range() {
1269        let provider = CountingProvider::new();
1270        let ticker = Ticker::builder("AAPL")
1271            .with_provider_set(provider_set(Arc::clone(&provider)))
1272            .build()
1273            .await
1274            .unwrap();
1275
1276        let _ = ticker
1277            .chart(Interval::OneDay, TimeRange::OneMonth)
1278            .await
1279            .unwrap();
1280        let _ = ticker
1281            .chart(Interval::OneDay, TimeRange::OneMonth)
1282            .await
1283            .unwrap();
1284        assert_eq!(provider.charts(), 1);
1285
1286        let _ = ticker
1287            .chart(Interval::OneDay, TimeRange::OneYear)
1288            .await
1289            .unwrap();
1290        assert_eq!(provider.charts(), 2);
1291    }
1292
1293    #[tokio::test]
1294    async fn unresolved_logo_is_not_cached() {
1295        let provider = CountingProvider::new();
1296        let ticker = Ticker::builder("AAPL")
1297            .with_provider_set(provider_set(Arc::clone(&provider)))
1298            .logo()
1299            .build()
1300            .await
1301            .unwrap();
1302
1303        let _: Quote<crate::format::Raw> = ticker.quote().await.unwrap();
1304        assert!(
1305            ticker.logo_cache.read().await.is_none(),
1306            "an unresolved logo must not be cached, or one blip is permanent"
1307        );
1308    }
1309
1310    #[tokio::test]
1311    async fn concurrent_chart_misses_dedup_to_one_fetch() {
1312        let provider = CountingProvider::new();
1313        let ticker = Arc::new(
1314            Ticker::builder("AAPL")
1315                .with_provider_set(provider_set(Arc::clone(&provider)))
1316                .build()
1317                .await
1318                .unwrap(),
1319        );
1320
1321        let mut handles = Vec::new();
1322        for _ in 0..8 {
1323            let ticker = Arc::clone(&ticker);
1324            handles.push(tokio::spawn(async move {
1325                ticker
1326                    .chart(Interval::OneDay, TimeRange::OneMonth)
1327                    .await
1328                    .unwrap()
1329            }));
1330        }
1331        for h in handles {
1332            h.await.unwrap();
1333        }
1334
1335        assert_eq!(provider.charts(), 1);
1336    }
1337
1338    #[tokio::test]
1339    async fn concurrent_news_misses_dedup_to_one_fetch() {
1340        let provider = CountingProvider::new();
1341        let ticker = Arc::new(
1342            Ticker::builder("AAPL")
1343                .with_provider_set(provider_set(Arc::clone(&provider)))
1344                .build()
1345                .await
1346                .unwrap(),
1347        );
1348
1349        let mut handles = Vec::new();
1350        for _ in 0..8 {
1351            let ticker = Arc::clone(&ticker);
1352            handles.push(tokio::spawn(async move { ticker.news().await.unwrap() }));
1353        }
1354        for h in handles {
1355            h.await.unwrap();
1356        }
1357
1358        assert_eq!(provider.news(), 1);
1359    }
1360
1361    #[tokio::test(start_paused = true)]
1362    async fn ttl_expires() {
1363        let provider = CountingProvider::new();
1364        let ticker = Ticker::builder("AAPL")
1365            .with_provider_set(provider_set(Arc::clone(&provider)))
1366            .cache(Duration::from_secs(60))
1367            .build()
1368            .await
1369            .unwrap();
1370
1371        let _ = ticker
1372            .chart(Interval::OneDay, TimeRange::OneMonth)
1373            .await
1374            .unwrap();
1375        tokio::time::advance(Duration::from_secs(120)).await;
1376        let _ = ticker
1377            .chart(Interval::OneDay, TimeRange::OneMonth)
1378            .await
1379            .unwrap();
1380
1381        assert_eq!(provider.charts(), 2);
1382    }
1383}