Skip to main content

finance_query/ticker/
core.rs

1//! Symbol-specific data access from multiple providers.
2
3use crate::adapters::yahoo::client::{ClientConfig, YahooClient};
4#[cfg(feature = "backtesting")]
5use crate::backtesting;
6use crate::constants::{Frequency, Interval, Region, StatementType, TimeRange};
7use crate::edgar;
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 crate::providers::types::recommendation_from_similar;
30use crate::providers::yahoo::YahooProvider;
31use crate::providers::{
32    Capability, Fetch, Provider, ProviderAdapter, ProviderSet, Routes, build_providers,
33};
34#[cfg(feature = "risk")]
35use crate::risk;
36use crate::utils::{CacheEntry, EVICTION_THRESHOLD, filter_by_range};
37use std::collections::HashMap;
38use std::sync::Arc;
39use std::time::Duration;
40use tokio::sync::RwLock;
41
42type Cache<T> = Arc<RwLock<Option<CacheEntry<T>>>>;
43type MapCache<K, V> = Arc<RwLock<HashMap<K, CacheEntry<V>>>>;
44
45/// Opaque handle to a shared Yahoo Finance client session.
46///
47/// Allows multiple [`Ticker`] and [`Tickers`](crate::Tickers) instances to share
48/// one authenticated session, avoiding redundant auth handshakes.
49///
50/// Obtain via [`Ticker::client_handle`] or [`Tickers::client_handle`], then
51/// pass to other builders via `.client(handle)`.
52///
53/// # Example
54///
55/// ```no_run
56/// use finance_query::Ticker;
57///
58/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
59/// let aapl = Ticker::new("AAPL").await?;
60/// let handle = aapl.client_handle();
61///
62/// let msft = Ticker::builder("MSFT").client(handle.clone()).build().await?;
63/// let googl = Ticker::builder("GOOGL").client(handle).build().await?;
64/// # Ok(())
65/// # }
66/// ```
67#[derive(Clone)]
68pub struct ClientHandle(pub(crate) Arc<YahooClient>);
69/// Builder for constructing a [`Ticker`] with optional configuration.
70///
71/// Construct via [`Ticker::builder`]. All builder methods are optional;
72/// call [`build`](TickerBuilder::build) to finalize.
73pub struct TickerBuilder {
74    symbol: Arc<str>,
75    config: ClientConfig,
76    shared_client: Option<ClientHandle>,
77    injected_providers: Option<Arc<ProviderSet>>,
78    cache_ttl: Option<Duration>,
79    include_logo: bool,
80}
81
82impl TickerBuilder {
83    fn new(symbol: impl Into<String>) -> Self {
84        Self {
85            symbol: symbol.into().into(),
86            config: ClientConfig::default(),
87            shared_client: None,
88            injected_providers: None,
89            cache_ttl: None,
90            include_logo: false,
91        }
92    }
93    /// Set the region (automatically sets correct lang and region).
94    pub fn region(mut self, region: Region) -> Self {
95        self.config.lang = region.lang().to_string();
96        self.config.region = region.region().to_string();
97        self
98    }
99    /// Set the language code (e.g., "en-US", "ja-JP").
100    pub fn lang(mut self, lang: impl Into<String>) -> Self {
101        self.config.lang = lang.into();
102        self
103    }
104    /// Set the region code (e.g., "US", "JP").
105    pub fn region_code(mut self, r: impl Into<String>) -> Self {
106        self.config.region = r.into();
107        self
108    }
109    /// Set the HTTP request timeout.
110    pub fn timeout(mut self, t: Duration) -> Self {
111        self.config.timeout = t;
112        self
113    }
114    /// Set the proxy URL.
115    pub fn proxy(mut self, p: impl Into<String>) -> Self {
116        self.config.proxy = Some(p.into());
117        self
118    }
119    #[allow(dead_code)]
120    pub(crate) fn config(mut self, c: ClientConfig) -> Self {
121        self.config = c;
122        self
123    }
124    /// Pre-inject a shared provider set (used by [`Providers::stock`](crate::Providers::stock)).
125    pub(crate) fn with_provider_set(mut self, set: Arc<ProviderSet>) -> Self {
126        self.injected_providers = Some(set);
127        self
128    }
129    /// Share an existing authenticated session instead of creating a new one.
130    ///
131    /// Avoids redundant auth handshakes when creating multiple `Ticker` instances.
132    /// Obtain a handle from any existing `Ticker` via [`Ticker::client_handle`].
133    ///
134    /// When set, the builder's `config`, `timeout`, `proxy`, `lang`, and `region`
135    /// settings are ignored — the shared session's configuration is used instead.
136    pub fn client(mut self, handle: ClientHandle) -> Self {
137        self.shared_client = Some(handle);
138        self
139    }
140    /// Enable response caching with a time-to-live.
141    pub fn cache(mut self, ttl: Duration) -> Self {
142        self.cache_ttl = Some(ttl);
143        self
144    }
145    /// Include company logo URLs in quote responses.
146    pub fn logo(mut self) -> Self {
147        self.include_logo = true;
148        self
149    }
150
151    /// Build the Ticker instance.
152    pub async fn build(self) -> Result<Ticker> {
153        #[cfg(feature = "translation")]
154        let translate_lang = {
155            let lang = crate::translation::Lang::parse(&self.config.lang)?;
156            (!lang.is_english()).then_some(lang)
157        };
158        let providers = if let Some(set) = self.injected_providers {
159            set
160        } else if let Some(handle) = self.shared_client {
161            let yahoo = YahooProvider::from_client(handle.0);
162            let client = yahoo.client_arc();
163            Arc::new(ProviderSet::new(
164                vec![Arc::new(yahoo) as Arc<dyn ProviderAdapter>],
165                Some(client),
166                Routes::new(Fetch::Sequential),
167            ))
168        } else {
169            Arc::new(
170                build_providers(
171                    &[Provider::Yahoo],
172                    &self.config,
173                    Routes::new(Fetch::Sequential),
174                )
175                .await?,
176            )
177        };
178        Ok(Ticker {
179            symbol: self.symbol,
180            providers,
181            cache_ttl: self.cache_ttl,
182            include_logo: self.include_logo,
183            #[cfg(feature = "translation")]
184            translate_lang,
185            quote_cache: Default::default(),
186            quote_fetch: Arc::new(tokio::sync::Mutex::new(())),
187            chart_cache: Default::default(),
188            events_cache: Default::default(),
189            news_cache: Default::default(),
190            options_cache: Default::default(),
191            financials_cache: Default::default(),
192            #[cfg(feature = "indicators")]
193            indicators_cache: Default::default(),
194            edgar_submissions_cache: Default::default(),
195            edgar_facts_cache: Default::default(),
196        })
197    }
198}
199
200/// The primary entry point for querying financial data for a single symbol.
201///
202/// Data is fetched on first access and cached. Use the builder pattern
203/// via [`Ticker::builder`] for custom configuration.
204pub struct Ticker {
205    symbol: Arc<str>,
206    providers: Arc<ProviderSet>,
207    cache_ttl: Option<Duration>,
208    include_logo: bool,
209    #[cfg(feature = "translation")]
210    translate_lang: Option<crate::translation::Lang>,
211    quote_cache: Cache<QuoteSummaryResponse>,
212    quote_fetch: Arc<tokio::sync::Mutex<()>>,
213    chart_cache: MapCache<(Interval, TimeRange), Chart>,
214    events_cache: Cache<ChartEvents>,
215    news_cache: Cache<Vec<News>>,
216    options_cache: MapCache<Option<i64>, Options>,
217    financials_cache: MapCache<(StatementType, Frequency), FinancialStatement>,
218    #[cfg(feature = "indicators")]
219    indicators_cache: MapCache<(Interval, TimeRange), indicators::IndicatorsSummary>,
220    edgar_submissions_cache: Cache<EdgarSubmissions>,
221    edgar_facts_cache: Cache<CompanyFacts>,
222}
223
224impl Ticker {
225    /// Creates a new ticker with default configuration.
226    pub async fn new(symbol: impl Into<String>) -> Result<Self> {
227        Self::builder(symbol).build().await
228    }
229    /// Creates a new builder for Ticker.
230    pub fn builder(symbol: impl Into<String>) -> TickerBuilder {
231        TickerBuilder::new(symbol)
232    }
233    /// Returns the ticker symbol.
234    pub fn symbol(&self) -> &str {
235        &self.symbol
236    }
237
238    /// Returns a handle to the underlying Yahoo Finance session.
239    ///
240    /// Pass to other builders via `.client(handle)` to share the authenticated
241    /// session without a new auth handshake.
242    ///
243    /// # Panics
244    ///
245    /// Panics if this ticker was created via [`Providers`](crate::Providers) with
246    /// no Yahoo provider configured. For session sharing across multiple tickers,
247    /// prefer [`Providers::ticker`](crate::Providers::ticker) instead.
248    pub fn client_handle(&self) -> ClientHandle {
249        ClientHandle(
250            self.providers
251                .first_yahoo()
252                .expect("client_handle requires a Yahoo session; use Providers::ticker() for multi-provider tickers"),
253        )
254    }
255
256    #[allow(dead_code)]
257    pub(crate) fn provider_set(&self) -> &Arc<ProviderSet> {
258        &self.providers
259    }
260
261    /// Translate a response value when a non-English language is configured
262    /// (no-op otherwise).
263    #[cfg(feature = "translation")]
264    pub(crate) async fn translate_response<T: crate::translation::Translatable>(
265        &self,
266        value: &mut T,
267    ) -> Result<()> {
268        if let Some(lang) = &self.translate_lang {
269            crate::translation::translate_with(value, lang).await?;
270        }
271        Ok(())
272    }
273
274    fn is_cache_fresh<T>(&self, entry: Option<&CacheEntry<T>>) -> bool {
275        CacheEntry::is_fresh_with_ttl(entry, self.cache_ttl)
276    }
277
278    /// Like `is_cache_fresh`, but works on the shared-cache pattern
279    /// where the entry is populated on first fetch.
280    /// When no TTL is configured, never treats entries as fresh.
281    fn is_shared_cache_fresh<T>(&self, entry: Option<&CacheEntry<T>>) -> bool {
282        match (self.cache_ttl, entry) {
283            (Some(ttl), Some(e)) => e.is_fresh(ttl),
284            _ => false,
285        }
286    }
287    fn cache_insert<K: Eq + std::hash::Hash, V>(
288        &self,
289        map: &mut HashMap<K, CacheEntry<V>>,
290        key: K,
291        value: V,
292    ) {
293        if let Some(ttl) = self.cache_ttl {
294            if map.len() >= EVICTION_THRESHOLD {
295                map.retain(|_, entry| entry.is_fresh(ttl));
296            }
297            map.insert(key, CacheEntry::new(value));
298        }
299    }
300
301    /// Get full quote data, optionally including logo URLs.
302    pub async fn quote<F>(&self) -> Result<Quote<F>>
303    where
304        F: Format,
305        Quote<Both>: Into<Quote<F>>,
306    {
307        let cache = self.ensure_quote().await?;
308        let summary = cache.as_ref().ok_or_else(|| {
309            FinanceError::ApiError("Quote summary cache was empty after fetch".to_string())
310        })?;
311        let (logo_url, company_logo_url) = if self.include_logo {
312            if let Ok(yahoo) = self.providers.first_yahoo() {
313                let logos = yahoo.get_logo_url(&self.symbol).await;
314                (logos.0, logos.1)
315            } else {
316                (None, None)
317            }
318        } else {
319            (None, None)
320        };
321        let quote = Quote::from_response(&summary.value, logo_url, company_logo_url);
322        #[cfg(feature = "translation")]
323        let quote = {
324            drop(cache);
325            let mut quote = quote;
326            self.translate_response(&mut quote).await?;
327            quote
328        };
329        Ok(quote.into())
330    }
331
332    fn chart_from_provider_data(
333        mut data: Chart,
334        interval: Option<Interval>,
335        range: Option<TimeRange>,
336    ) -> Chart {
337        data.interval = interval;
338        data.range = range;
339        data
340    }
341
342    /// Get historical OHLCV chart data.
343    pub async fn chart(&self, interval: Interval, range: TimeRange) -> Result<Chart> {
344        {
345            let cache = self.chart_cache.read().await;
346            if let Some(entry) = cache.get(&(interval, range))
347                && self.is_cache_fresh(Some(entry))
348            {
349                return Ok(entry.value.clone());
350            }
351        }
352        let sym = self.symbol.clone();
353        let data = self
354            .providers
355            .fetch(Capability::CHART, move |p| {
356                let sym = sym.clone();
357                let p = p.clone();
358                async move { p.fetch_chart(&sym, interval, range).await }
359            })
360            .await?;
361        let chart = Self::chart_from_provider_data(data, Some(interval), Some(range));
362        if self.cache_ttl.is_some() {
363            let mut cache = self.chart_cache.write().await;
364            self.cache_insert(&mut cache, (interval, range), chart.clone());
365        }
366        Ok(chart)
367    }
368
369    /// Get chart data for a custom start/end timestamp range.
370    pub async fn chart_range(&self, interval: Interval, start: i64, end: i64) -> Result<Chart> {
371        if start >= end {
372            return Err(FinanceError::InvalidParameter {
373                param: "end".into(),
374                reason: format!("end ({end}) must be > start ({start})"),
375            });
376        }
377        let sym = self.symbol.clone();
378        let data = self
379            .providers
380            .fetch(Capability::CHART, move |p| {
381                let sym = sym.clone();
382                let p = p.clone();
383                async move { p.fetch_chart_range(&sym, interval, start, end).await }
384            })
385            .await?;
386        Ok(Self::chart_from_provider_data(data, Some(interval), None))
387    }
388
389    async fn ensure_events(&self) -> Result<()> {
390        {
391            let cache = self.events_cache.read().await;
392            if self.is_shared_cache_fresh(cache.as_ref()) {
393                return Ok(());
394            }
395        }
396        let sym = self.symbol.clone();
397        let events = self
398            .providers
399            .fetch(Capability::CORPORATE, move |p| {
400                let sym = sym.clone();
401                let p = p.clone();
402                async move { p.fetch_events(&sym).await }
403            })
404            .await?;
405        let mut cache = self.events_cache.write().await;
406        *cache = Some(CacheEntry::new(events));
407        Ok(())
408    }
409
410    /// Get dividend history.
411    pub async fn dividends(&self, range: TimeRange) -> Result<Vec<Dividend>> {
412        self.ensure_events().await?;
413        let cache = self.events_cache.read().await;
414        let all = cache
415            .as_ref()
416            .map(|e| e.value.to_dividends())
417            .unwrap_or_default();
418        Ok(filter_by_range(all, range))
419    }
420    /// Compute dividend analytics for the requested time range.
421    pub async fn dividend_analytics(&self, range: TimeRange) -> Result<DividendAnalytics> {
422        let divs = self.dividends(range).await?;
423        Ok(DividendAnalytics::from_dividends(&divs))
424    }
425    /// Get stock split history.
426    pub async fn splits(&self, range: TimeRange) -> Result<Vec<Split>> {
427        self.ensure_events().await?;
428        let cache = self.events_cache.read().await;
429        let all = cache
430            .as_ref()
431            .map(|e| e.value.to_splits())
432            .unwrap_or_default();
433        Ok(filter_by_range(all, range))
434    }
435    /// Get capital gains distribution history.
436    pub async fn capital_gains(&self, range: TimeRange) -> Result<Vec<CapitalGain>> {
437        self.ensure_events().await?;
438        let cache = self.events_cache.read().await;
439        let all = cache
440            .as_ref()
441            .map(|e| e.value.to_capital_gains())
442            .unwrap_or_default();
443        Ok(filter_by_range(all, range))
444    }
445
446    /// Get analyst recommendations and similar symbols.
447    pub async fn recommendations(&self, limit: u32) -> Result<Recommendation> {
448        if limit == 0 {
449            return Err(FinanceError::InvalidParameter {
450                param: "limit".into(),
451                reason: "limit must be > 0".into(),
452            });
453        }
454        let sym = self.symbol.clone();
455        let (provider_id, items) = self
456            .providers
457            .fetch(Capability::CORPORATE, move |p| {
458                let sym = sym.clone();
459                let p = p.clone();
460                async move {
461                    let r = p.fetch_similar_symbols(&sym, limit).await?;
462                    let provider = Provider::from_id_str(p.id()).ok_or_else(|| {
463                        FinanceError::InternalError(format!("unknown provider id: {}", p.id()))
464                    })?;
465                    Ok((provider, r))
466                }
467            })
468            .await?;
469        Ok(recommendation_from_similar(
470            self.symbol.to_string(),
471            Some(provider_id),
472            items,
473            Some(limit),
474        ))
475    }
476
477    /// Get news articles for this symbol.
478    pub async fn news(&self) -> Result<Vec<News>> {
479        {
480            let cache = self.news_cache.read().await;
481            if let Some(e) = cache.as_ref()
482                && self.is_cache_fresh(Some(e))
483            {
484                return Ok(e.value.clone());
485            }
486        }
487        let sym = self.symbol.clone();
488        let data = self
489            .providers
490            .fetch(Capability::CORPORATE, move |p| {
491                let sym = sym.clone();
492                let p = p.clone();
493                async move { p.fetch_news(&sym).await }
494            })
495            .await?;
496        let news = data;
497        // Score titles before translation — VADER is English-lexicon based.
498        #[cfg(feature = "sentiment")]
499        let news = {
500            let mut news = news;
501            for article in news.iter_mut() {
502                article.sentiment = Some(crate::models::sentiment::analyze(&article.title));
503            }
504            news
505        };
506        #[cfg(feature = "translation")]
507        let news = {
508            let mut news = news;
509            self.translate_response(&mut news).await?;
510            news
511        };
512        if self.cache_ttl.is_some() {
513            let mut c = self.news_cache.write().await;
514            *c = Some(CacheEntry::new(news.clone()));
515        }
516        Ok(news)
517    }
518
519    /// Average sentiment across recent news headlines for this symbol.
520    ///
521    /// Positive = net bullish coverage, negative = net bearish. Returns a
522    /// neutral, zero-confidence score when there are no headlines.
523    ///
524    /// Only available when the `sentiment` feature is enabled.
525    #[cfg(feature = "sentiment")]
526    pub async fn news_sentiment(&self) -> Result<crate::models::sentiment::Sentiment> {
527        let news = self.news().await?;
528        let scores: Vec<f64> = news
529            .iter()
530            .filter_map(|n| n.sentiment.as_ref().map(|s| s.score))
531            .collect();
532        Ok(crate::models::sentiment::aggregate(&scores)
533            .unwrap_or_else(crate::models::sentiment::Sentiment::neutral))
534    }
535
536    /// Get the options chain.
537    pub async fn options(&self, date: Option<i64>) -> Result<Options> {
538        {
539            let cache = self.options_cache.read().await;
540            if let Some(e) = cache.get(&date)
541                && self.is_cache_fresh(Some(e))
542            {
543                return Ok(e.value.clone());
544            }
545        }
546        let sym = self.symbol.clone();
547        let opts = self
548            .providers
549            .fetch(Capability::OPTIONS, move |p| {
550                let sym = sym.clone();
551                let p = p.clone();
552                async move { p.fetch_options(&sym, date).await }
553            })
554            .await?;
555        if self.cache_ttl.is_some() {
556            let mut c = self.options_cache.write().await;
557            self.cache_insert(&mut c, date, opts.clone());
558        }
559        Ok(opts)
560    }
561
562    /// Get financial statements.
563    pub async fn financials(
564        &self,
565        stmt_type: StatementType,
566        frequency: Frequency,
567    ) -> Result<FinancialStatement> {
568        let key = (stmt_type, frequency);
569        {
570            let cache = self.financials_cache.read().await;
571            if let Some(e) = cache.get(&key)
572                && self.is_cache_fresh(Some(e))
573            {
574                return Ok(e.value.clone());
575            }
576        }
577        let sym = self.symbol.clone();
578        let stmt = self
579            .providers
580            .fetch(Capability::FUNDAMENTALS, move |p| {
581                let sym = sym.clone();
582                let p = p.clone();
583                async move { p.fetch_financials(&sym, stmt_type, frequency).await }
584            })
585            .await?;
586        if self.cache_ttl.is_some() {
587            let mut c = self.financials_cache.write().await;
588            self.cache_insert(&mut c, key, stmt.clone());
589        }
590        Ok(stmt)
591    }
592
593    #[cfg(feature = "indicators")]
594    /// Calculate all technical indicators from chart data.
595    pub async fn indicators(
596        &self,
597        interval: Interval,
598        range: TimeRange,
599    ) -> Result<indicators::IndicatorsSummary> {
600        {
601            let cache = self.indicators_cache.read().await;
602            if let Some(e) = cache.get(&(interval, range))
603                && self.is_cache_fresh(Some(e))
604            {
605                return Ok(e.value.clone());
606            }
607        }
608        let chart = self.chart(interval, range).await?;
609        let ind = indicators::summary::calculate_indicators(&chart.candles);
610        if self.cache_ttl.is_some() {
611            let mut c = self.indicators_cache.write().await;
612            self.cache_insert(&mut c, (interval, range), ind.clone());
613        }
614        Ok(ind)
615    }
616
617    /// Get SEC EDGAR filing history for this symbol.
618    ///
619    /// Always uses EDGAR directly — this is an EDGAR-specific API (CIK-based submission
620    /// history and XBRL company facts) that no other provider replicates. For routable
621    /// provider-agnostic filing data use [`filings`](Self::filings) instead.
622    pub async fn edgar_submissions(&self) -> Result<EdgarSubmissions> {
623        {
624            let cache = self.edgar_submissions_cache.read().await;
625            if let Some(e) = cache.as_ref()
626                && self.is_cache_fresh(Some(e))
627            {
628                return Ok(e.value.clone());
629            }
630        }
631        let cik = edgar::resolve_cik(&self.symbol).await?;
632        let subs = edgar::submissions(cik).await?;
633        if self.cache_ttl.is_some() {
634            let mut c = self.edgar_submissions_cache.write().await;
635            *c = Some(CacheEntry::new(subs.clone()));
636        }
637        Ok(subs)
638    }
639
640    /// Get SEC EDGAR company facts (structured XBRL financial data).
641    ///
642    /// Always uses EDGAR directly — XBRL `us-gaap`/`ifrs`/`dei` fact data is unique
643    /// to the SEC's EDGAR API. For routable filing data use [`filings`](Self::filings).
644    pub async fn edgar_company_facts(&self) -> Result<CompanyFacts> {
645        {
646            let cache = self.edgar_facts_cache.read().await;
647            if let Some(e) = cache.as_ref()
648                && self.is_cache_fresh(Some(e))
649            {
650                return Ok(e.value.clone());
651            }
652        }
653        let cik = edgar::resolve_cik(&self.symbol).await?;
654        let facts = edgar::company_facts(cik).await?;
655        if self.cache_ttl.is_some() {
656            let mut c = self.edgar_facts_cache.write().await;
657            *c = Some(CacheEntry::new(facts.clone()));
658        }
659        Ok(facts)
660    }
661
662    /// Fetch SEC filings via the configured [`Capability::FILINGS`] provider.
663    ///
664    /// Routes through the provider system; EDGAR is always available as a fallback
665    /// (auto-injected when no explicit FILINGS route is set). To prefer Polygon:
666    /// `.route(Capability::FILINGS, &[Provider::Polygon, Provider::Edgar])`.
667    ///
668    /// For the full EDGAR submissions response or structured XBRL data, use
669    /// [`edgar_submissions`](Self::edgar_submissions) / [`edgar_company_facts`](Self::edgar_company_facts).
670    pub async fn filings(&self) -> Result<ProviderFilings> {
671        let symbol = self.symbol.clone();
672        self.providers
673            .fetch(Capability::FILINGS, move |p| {
674                let symbol = symbol.clone();
675                let p = p.clone();
676                async move { p.fetch_filings(&symbol).await }
677            })
678            .await
679    }
680
681    #[cfg(feature = "indicators")]
682    /// Calculate a specific technical indicator over a time range.
683    pub async fn indicator(
684        &self,
685        indicator: indicators::Indicator,
686        interval: Interval,
687        range: TimeRange,
688    ) -> Result<indicators::IndicatorResult> {
689        let chart = self.chart(interval, range).await?;
690        let o = chart.open_prices();
691        let h = chart.high_prices();
692        let l = chart.low_prices();
693        let c = chart.close_prices();
694        let v = chart.volumes();
695        use indicators::{Indicator, IndicatorResult};
696        Ok(match indicator {
697            Indicator::Sma(p) => IndicatorResult::Series(chart.sma(p)),
698            Indicator::Ema(p) => IndicatorResult::Series(chart.ema(p)),
699            Indicator::Rsi(p) => IndicatorResult::Series(chart.rsi(p)?),
700            Indicator::Macd { fast, slow, signal } => {
701                IndicatorResult::Macd(chart.macd(fast, slow, signal)?)
702            }
703            Indicator::Bollinger { period, std_dev } => {
704                IndicatorResult::Bollinger(chart.bollinger_bands(period, std_dev)?)
705            }
706            Indicator::Atr(p) => IndicatorResult::Series(chart.atr(p)?),
707            Indicator::Vwap => IndicatorResult::Series(crate::indicators::vwap(&h, &l, &c, &v)?),
708            Indicator::Wma(p) => IndicatorResult::Series(crate::indicators::wma(&c, p)?),
709            Indicator::Obv => IndicatorResult::Series(crate::indicators::obv(&c, &v)?),
710            Indicator::Dema(p) => IndicatorResult::Series(crate::indicators::dema(&c, p)?),
711            Indicator::Tema(p) => IndicatorResult::Series(crate::indicators::tema(&c, p)?),
712            Indicator::Hma(p) => IndicatorResult::Series(crate::indicators::hma(&c, p)?),
713            Indicator::Vwma(p) => IndicatorResult::Series(crate::indicators::vwma(&c, &v, p)?),
714            Indicator::Alma {
715                period,
716                offset,
717                sigma,
718            } => IndicatorResult::Series(crate::indicators::alma(&c, period, offset, sigma)?),
719            Indicator::McginleyDynamic(p) => {
720                IndicatorResult::Series(crate::indicators::mcginley_dynamic(&c, p)?)
721            }
722            Indicator::Stochastic {
723                k_period,
724                k_slow,
725                d_period,
726            } => IndicatorResult::Stochastic(crate::indicators::stochastic(
727                &h, &l, &c, k_period, k_slow, d_period,
728            )?),
729            Indicator::StochasticRsi {
730                rsi_period,
731                stoch_period,
732                k_period,
733                d_period,
734            } => IndicatorResult::Stochastic(crate::indicators::stochastic_rsi(
735                &c,
736                rsi_period,
737                stoch_period,
738                k_period,
739                d_period,
740            )?),
741            Indicator::Cci(p) => IndicatorResult::Series(crate::indicators::cci(&h, &l, &c, p)?),
742            Indicator::WilliamsR(p) => {
743                IndicatorResult::Series(crate::indicators::williams_r(&h, &l, &c, p)?)
744            }
745            Indicator::Roc(p) => IndicatorResult::Series(crate::indicators::roc(&c, p)?),
746            Indicator::Momentum(p) => IndicatorResult::Series(crate::indicators::momentum(&c, p)?),
747            Indicator::Cmo(p) => IndicatorResult::Series(crate::indicators::cmo(&c, p)?),
748            Indicator::AwesomeOscillator { fast, slow } => {
749                IndicatorResult::Series(crate::indicators::awesome_oscillator(&h, &l, fast, slow)?)
750            }
751            Indicator::CoppockCurve {
752                long_roc,
753                short_roc,
754                wma_period,
755            } => IndicatorResult::Series(crate::indicators::coppock_curve(
756                &c, long_roc, short_roc, wma_period,
757            )?),
758            Indicator::Adx(p) => IndicatorResult::Series(crate::indicators::adx(&h, &l, &c, p)?),
759            Indicator::Aroon(p) => IndicatorResult::Aroon(crate::indicators::aroon(&h, &l, p)?),
760            Indicator::Supertrend { period, multiplier } => IndicatorResult::SuperTrend(
761                crate::indicators::supertrend(&h, &l, &c, period, multiplier)?,
762            ),
763            Indicator::Ichimoku {
764                conversion,
765                base,
766                lagging,
767                displacement,
768            } => IndicatorResult::Ichimoku(crate::indicators::ichimoku(
769                &h,
770                &l,
771                &c,
772                conversion,
773                base,
774                lagging,
775                displacement,
776            )?),
777            Indicator::ParabolicSar { step, max } => {
778                IndicatorResult::Series(crate::indicators::parabolic_sar(&h, &l, &c, step, max)?)
779            }
780            Indicator::BullBearPower(p) => {
781                IndicatorResult::BullBearPower(crate::indicators::bull_bear_power(&h, &l, &c, p)?)
782            }
783            Indicator::ElderRay(p) => {
784                IndicatorResult::ElderRay(crate::indicators::elder_ray(&h, &l, &c, p)?)
785            }
786            Indicator::KeltnerChannels {
787                period,
788                multiplier,
789                atr_period,
790            } => IndicatorResult::Keltner(crate::indicators::keltner_channels(
791                &h, &l, &c, period, atr_period, multiplier,
792            )?),
793            Indicator::DonchianChannels(p) => {
794                IndicatorResult::Donchian(crate::indicators::donchian_channels(&h, &l, p)?)
795            }
796            Indicator::TrueRange => {
797                IndicatorResult::Series(crate::indicators::true_range(&h, &l, &c)?)
798            }
799            Indicator::ChoppinessIndex(p) => {
800                IndicatorResult::Series(crate::indicators::choppiness_index(&h, &l, &c, p)?)
801            }
802            Indicator::Mfi(p) => {
803                IndicatorResult::Series(crate::indicators::mfi(&h, &l, &c, &v, p)?)
804            }
805            Indicator::Cmf(p) => {
806                IndicatorResult::Series(crate::indicators::cmf(&h, &l, &c, &v, p)?)
807            }
808            Indicator::ChaikinOscillator => {
809                IndicatorResult::Series(crate::indicators::chaikin_oscillator(&h, &l, &c, &v)?)
810            }
811            Indicator::AccumulationDistribution => IndicatorResult::Series(
812                crate::indicators::accumulation_distribution(&h, &l, &c, &v)?,
813            ),
814            Indicator::BalanceOfPower(p) => {
815                IndicatorResult::Series(crate::indicators::balance_of_power(&o, &h, &l, &c, p)?)
816            }
817        })
818    }
819
820    #[cfg(feature = "backtesting")]
821    /// Run a backtest with the given strategy and configuration.
822    pub async fn backtest<S: backtesting::Strategy>(
823        &self,
824        strategy: S,
825        interval: Interval,
826        range: TimeRange,
827        config: Option<backtesting::BacktestConfig>,
828    ) -> backtesting::Result<backtesting::BacktestResult> {
829        let config = config.unwrap_or_default();
830        config.validate()?;
831        let chart = self
832            .chart(interval, range)
833            .await
834            .map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
835        let dividends = self.dividends(range).await.unwrap_or_default();
836        backtesting::BacktestEngine::new(config).run_with_dividends(
837            &self.symbol,
838            &chart.candles,
839            strategy,
840            &dividends,
841        )
842    }
843
844    #[cfg(feature = "backtesting")]
845    /// Run a backtest and compare performance against a benchmark symbol.
846    pub async fn backtest_with_benchmark<S: backtesting::Strategy>(
847        &self,
848        strategy: S,
849        interval: Interval,
850        range: TimeRange,
851        config: Option<backtesting::BacktestConfig>,
852        benchmark: &str,
853    ) -> backtesting::Result<backtesting::BacktestResult> {
854        let config = config.unwrap_or_default();
855        config.validate()?;
856        let bench_ticker = Ticker::new(benchmark)
857            .await
858            .map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
859        let (chart, bench_chart) = tokio::try_join!(
860            self.chart(interval, range),
861            bench_ticker.chart(interval, range)
862        )
863        .map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
864        let dividends = self.dividends(range).await.unwrap_or_default();
865        backtesting::BacktestEngine::new(config).run_with_benchmark(
866            &self.symbol,
867            &chart.candles,
868            strategy,
869            &dividends,
870            benchmark,
871            &bench_chart.candles,
872        )
873    }
874
875    #[cfg(feature = "risk")]
876    /// Compute a risk summary for this symbol.
877    pub async fn risk(
878        &self,
879        interval: Interval,
880        range: TimeRange,
881        benchmark: Option<&str>,
882    ) -> Result<risk::RiskSummary> {
883        let chart = self.chart(interval, range).await?;
884        let bench_returns = if let Some(sym) = benchmark {
885            let bt = Ticker::new(sym).await?;
886            Some(risk::candles_to_returns(
887                &bt.chart(interval, range).await?.candles,
888            ))
889        } else {
890            None
891        };
892        Ok(risk::compute_risk_summary(
893            &chart.candles,
894            bench_returns.as_deref(),
895        ))
896    }
897
898    async fn ensure_quote(
899        &self,
900    ) -> Result<tokio::sync::RwLockReadGuard<'_, Option<CacheEntry<QuoteSummaryResponse>>>> {
901        {
902            let cache = self.quote_cache.read().await;
903            if self.is_shared_cache_fresh(cache.as_ref()) {
904                return Ok(cache);
905            }
906        }
907        let _guard = self.quote_fetch.lock().await;
908        {
909            let cache = self.quote_cache.read().await;
910            if self.is_shared_cache_fresh(cache.as_ref()) {
911                return Ok(cache);
912            }
913        }
914        let sym = self.symbol.clone();
915        let summary = self
916            .providers
917            .fetch(Capability::QUOTE, move |p| {
918                let sym = sym.clone();
919                let p = p.clone();
920                async move { p.fetch_quote(&sym).await }
921            })
922            .await?;
923        {
924            let mut cache = self.quote_cache.write().await;
925            *cache = Some(CacheEntry::new(summary));
926        }
927        Ok(self.quote_cache.read().await)
928    }
929}
930
931super::macros::define_quote_accessors! {
932    price -> Price, price,
933    summary_detail -> SummaryDetail, summary_detail,
934    financial_data -> FinancialData, financial_data,
935    key_stats -> DefaultKeyStatistics, default_key_statistics,
936    asset_profile -> AssetProfile, asset_profile,
937    calendar_events -> CalendarEvents, calendar_events,
938    earnings -> Earnings, earnings,
939    earnings_trend -> EarningsTrend, earnings_trend,
940    earnings_history -> EarningsHistory, earnings_history,
941    recommendation_trend -> RecommendationTrend, recommendation_trend,
942    insider_holders -> InsiderHolders, insider_holders,
943    insider_transactions -> InsiderTransactions, insider_transactions,
944    institution_ownership -> InstitutionOwnership, institution_ownership,
945    fund_ownership -> FundOwnership, fund_ownership,
946    major_holders -> MajorHoldersBreakdown, major_holders_breakdown,
947    share_purchase_activity -> NetSharePurchaseActivity, net_share_purchase_activity,
948    quote_type -> QuoteTypeData, quote_type,
949    summary_profile -> SummaryProfile, summary_profile,
950    sec_filings -> SecFilings, sec_filings,
951    grading_history -> UpgradeDowngradeHistory, upgrade_downgrade_history,
952    fund_performance -> FundPerformance, fund_performance,
953    fund_profile -> FundProfile, fund_profile,
954    top_holdings -> TopHoldings, top_holdings,
955    index_trend -> IndexTrend, index_trend,
956    industry_trend -> IndustryTrend, industry_trend,
957    sector_trend -> SectorTrend, sector_trend,
958    equity_performance -> EquityPerformance, equity_performance,
959}