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                    Ok((p.id(), r))
463                }
464            })
465            .await?;
466        Ok(recommendation_from_similar(
467            self.symbol.to_string(),
468            Some(provider_id),
469            items,
470            Some(limit),
471        ))
472    }
473
474    /// Get news articles for this symbol.
475    pub async fn news(&self) -> Result<Vec<News>> {
476        {
477            let cache = self.news_cache.read().await;
478            if let Some(e) = cache.as_ref()
479                && self.is_cache_fresh(Some(e))
480            {
481                return Ok(e.value.clone());
482            }
483        }
484        let sym = self.symbol.clone();
485        let data = self
486            .providers
487            .fetch(Capability::CORPORATE, move |p| {
488                let sym = sym.clone();
489                let p = p.clone();
490                async move { p.fetch_news(&sym).await }
491            })
492            .await?;
493        let news = data;
494        // Score titles before translation — VADER is English-lexicon based.
495        #[cfg(feature = "sentiment")]
496        let news = {
497            let mut news = news;
498            for article in news.iter_mut() {
499                article.sentiment = Some(crate::models::sentiment::analyze(&article.title));
500            }
501            news
502        };
503        #[cfg(feature = "translation")]
504        let news = {
505            let mut news = news;
506            self.translate_response(&mut news).await?;
507            news
508        };
509        if self.cache_ttl.is_some() {
510            let mut c = self.news_cache.write().await;
511            *c = Some(CacheEntry::new(news.clone()));
512        }
513        Ok(news)
514    }
515
516    /// Average sentiment across recent news headlines for this symbol.
517    ///
518    /// Positive = net bullish coverage, negative = net bearish. Returns a
519    /// neutral, zero-confidence score when there are no headlines.
520    ///
521    /// Only available when the `sentiment` feature is enabled.
522    #[cfg(feature = "sentiment")]
523    pub async fn news_sentiment(&self) -> Result<crate::models::sentiment::Sentiment> {
524        let news = self.news().await?;
525        let scores: Vec<f64> = news
526            .iter()
527            .filter_map(|n| n.sentiment.as_ref().map(|s| s.score))
528            .collect();
529        Ok(crate::models::sentiment::aggregate(&scores)
530            .unwrap_or_else(crate::models::sentiment::Sentiment::neutral))
531    }
532
533    /// Get the options chain.
534    pub async fn options(&self, date: Option<i64>) -> Result<Options> {
535        {
536            let cache = self.options_cache.read().await;
537            if let Some(e) = cache.get(&date)
538                && self.is_cache_fresh(Some(e))
539            {
540                return Ok(e.value.clone());
541            }
542        }
543        let sym = self.symbol.clone();
544        let opts = self
545            .providers
546            .fetch(Capability::OPTIONS, move |p| {
547                let sym = sym.clone();
548                let p = p.clone();
549                async move { p.fetch_options(&sym, date).await }
550            })
551            .await?;
552        if self.cache_ttl.is_some() {
553            let mut c = self.options_cache.write().await;
554            self.cache_insert(&mut c, date, opts.clone());
555        }
556        Ok(opts)
557    }
558
559    /// Get financial statements.
560    pub async fn financials(
561        &self,
562        stmt_type: StatementType,
563        frequency: Frequency,
564    ) -> Result<FinancialStatement> {
565        let key = (stmt_type, frequency);
566        {
567            let cache = self.financials_cache.read().await;
568            if let Some(e) = cache.get(&key)
569                && self.is_cache_fresh(Some(e))
570            {
571                return Ok(e.value.clone());
572            }
573        }
574        let sym = self.symbol.clone();
575        let stmt = self
576            .providers
577            .fetch(Capability::FUNDAMENTALS, move |p| {
578                let sym = sym.clone();
579                let p = p.clone();
580                async move { p.fetch_financials(&sym, stmt_type, frequency).await }
581            })
582            .await?;
583        if self.cache_ttl.is_some() {
584            let mut c = self.financials_cache.write().await;
585            self.cache_insert(&mut c, key, stmt.clone());
586        }
587        Ok(stmt)
588    }
589
590    #[cfg(feature = "indicators")]
591    /// Calculate all technical indicators from chart data.
592    pub async fn indicators(
593        &self,
594        interval: Interval,
595        range: TimeRange,
596    ) -> Result<indicators::IndicatorsSummary> {
597        {
598            let cache = self.indicators_cache.read().await;
599            if let Some(e) = cache.get(&(interval, range))
600                && self.is_cache_fresh(Some(e))
601            {
602                return Ok(e.value.clone());
603            }
604        }
605        let chart = self.chart(interval, range).await?;
606        let ind = indicators::summary::calculate_indicators(&chart.candles);
607        if self.cache_ttl.is_some() {
608            let mut c = self.indicators_cache.write().await;
609            self.cache_insert(&mut c, (interval, range), ind.clone());
610        }
611        Ok(ind)
612    }
613
614    /// Get SEC EDGAR filing history for this symbol.
615    ///
616    /// Always uses EDGAR directly — this is an EDGAR-specific API (CIK-based submission
617    /// history and XBRL company facts) that no other provider replicates. For routable
618    /// provider-agnostic filing data use [`filings`](Self::filings) instead.
619    pub async fn edgar_submissions(&self) -> Result<EdgarSubmissions> {
620        {
621            let cache = self.edgar_submissions_cache.read().await;
622            if let Some(e) = cache.as_ref()
623                && self.is_cache_fresh(Some(e))
624            {
625                return Ok(e.value.clone());
626            }
627        }
628        let cik = edgar::resolve_cik(&self.symbol).await?;
629        let subs = edgar::submissions(cik).await?;
630        if self.cache_ttl.is_some() {
631            let mut c = self.edgar_submissions_cache.write().await;
632            *c = Some(CacheEntry::new(subs.clone()));
633        }
634        Ok(subs)
635    }
636
637    /// Get SEC EDGAR company facts (structured XBRL financial data).
638    ///
639    /// Always uses EDGAR directly — XBRL `us-gaap`/`ifrs`/`dei` fact data is unique
640    /// to the SEC's EDGAR API. For routable filing data use [`filings`](Self::filings).
641    pub async fn edgar_company_facts(&self) -> Result<CompanyFacts> {
642        {
643            let cache = self.edgar_facts_cache.read().await;
644            if let Some(e) = cache.as_ref()
645                && self.is_cache_fresh(Some(e))
646            {
647                return Ok(e.value.clone());
648            }
649        }
650        let cik = edgar::resolve_cik(&self.symbol).await?;
651        let facts = edgar::company_facts(cik).await?;
652        if self.cache_ttl.is_some() {
653            let mut c = self.edgar_facts_cache.write().await;
654            *c = Some(CacheEntry::new(facts.clone()));
655        }
656        Ok(facts)
657    }
658
659    /// Fetch SEC filings via the configured [`Capability::FILINGS`] provider.
660    ///
661    /// Routes through the provider system; EDGAR is always available as a fallback
662    /// (auto-injected when no explicit FILINGS route is set). To prefer Polygon:
663    /// `.route(Capability::FILINGS, [Provider::Polygon, Provider::Edgar])`.
664    ///
665    /// For the full EDGAR submissions response or structured XBRL data, use
666    /// [`edgar_submissions`](Self::edgar_submissions) / [`edgar_company_facts`](Self::edgar_company_facts).
667    pub async fn filings(&self) -> Result<ProviderFilings> {
668        let symbol = self.symbol.clone();
669        self.providers
670            .fetch(Capability::FILINGS, move |p| {
671                let symbol = symbol.clone();
672                let p = p.clone();
673                async move { p.fetch_filings(&symbol).await }
674            })
675            .await
676    }
677
678    #[cfg(feature = "indicators")]
679    /// Calculate a specific technical indicator over a time range.
680    pub async fn indicator(
681        &self,
682        indicator: indicators::Indicator,
683        interval: Interval,
684        range: TimeRange,
685    ) -> Result<indicators::IndicatorResult> {
686        let chart = self.chart(interval, range).await?;
687        Ok(indicators::compute_indicator(indicator, &chart)?)
688    }
689
690    #[cfg(feature = "backtesting")]
691    /// Run a backtest with the given strategy and configuration.
692    pub async fn backtest<S: backtesting::Strategy>(
693        &self,
694        strategy: S,
695        interval: Interval,
696        range: TimeRange,
697        config: Option<backtesting::BacktestConfig>,
698    ) -> backtesting::Result<backtesting::BacktestResult> {
699        let config = config.unwrap_or_default();
700        config.validate()?;
701        let chart = self
702            .chart(interval, range)
703            .await
704            .map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
705        let dividends = self.dividends(range).await.unwrap_or_default();
706        backtesting::BacktestEngine::new(config).run_with_dividends(
707            &self.symbol,
708            &chart.candles,
709            strategy,
710            &dividends,
711        )
712    }
713
714    #[cfg(feature = "backtesting")]
715    /// Run a backtest and compare performance against a benchmark symbol.
716    pub async fn backtest_with_benchmark<S: backtesting::Strategy>(
717        &self,
718        strategy: S,
719        interval: Interval,
720        range: TimeRange,
721        config: Option<backtesting::BacktestConfig>,
722        benchmark: &str,
723    ) -> backtesting::Result<backtesting::BacktestResult> {
724        let config = config.unwrap_or_default();
725        config.validate()?;
726        let bench_ticker = Ticker::new(benchmark)
727            .await
728            .map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
729        let (chart, bench_chart) = tokio::try_join!(
730            self.chart(interval, range),
731            bench_ticker.chart(interval, range)
732        )
733        .map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
734        let dividends = self.dividends(range).await.unwrap_or_default();
735        backtesting::BacktestEngine::new(config).run_with_benchmark(
736            &self.symbol,
737            &chart.candles,
738            strategy,
739            &dividends,
740            benchmark,
741            &bench_chart.candles,
742        )
743    }
744
745    #[cfg(feature = "risk")]
746    /// Compute a risk summary for this symbol.
747    pub async fn risk(
748        &self,
749        interval: Interval,
750        range: TimeRange,
751        benchmark: Option<&str>,
752    ) -> Result<risk::RiskSummary> {
753        let chart = self.chart(interval, range).await?;
754        let bench_returns = if let Some(sym) = benchmark {
755            let bt = Ticker::new(sym).await?;
756            Some(risk::candles_to_returns(
757                &bt.chart(interval, range).await?.candles,
758            ))
759        } else {
760            None
761        };
762        Ok(risk::compute_risk_summary(
763            &chart.candles,
764            bench_returns.as_deref(),
765        ))
766    }
767
768    /// Aggregate upcoming financial events for this ticker into a single
769    /// time-sorted list.
770    ///
771    /// Combines earnings, ex-dividend and dividend-payment dates with standard
772    /// monthly options expirations, plus — when the `fred` feature is enabled —
773    /// a curated set of major economic releases (CPI, NFP, GDP, …). Limited to
774    /// the forward window `[now, now + range]` and sorted ascending by
775    /// timestamp.
776    ///
777    /// Options are best-effort: a symbol with no listed options contributes no
778    /// expiration events rather than failing the call.
779    pub async fn calendar(
780        &self,
781        range: TimeRange,
782    ) -> Result<Vec<crate::models::calendar::CalendarEvent>> {
783        let now = chrono::Utc::now().timestamp();
784        let window = (now, now + range.approx_duration_secs());
785
786        // The FRED economic-release fetch is independent of the per-symbol
787        // quote/options work, so run all three concurrently.
788        #[cfg(feature = "fred")]
789        let (calendar_events, options, releases) = tokio::join!(
790            self.calendar_events(),
791            self.options(None),
792            crate::adapters::fred::release_dates(),
793        );
794        #[cfg(not(feature = "fred"))]
795        let (calendar_events, options) = tokio::join!(self.calendar_events(), self.options(None));
796
797        let calendar_events = calendar_events?;
798        let options = options.ok();
799
800        let mut events = crate::models::calendar::build_symbol_events(
801            &self.symbol,
802            calendar_events.as_ref(),
803            options.as_ref(),
804            window,
805        );
806
807        #[cfg(feature = "fred")]
808        if let Ok(releases) = releases {
809            events.extend(crate::models::calendar::build_economic_events(
810                releases, window,
811            ));
812        }
813
814        crate::models::calendar::sort_events(&mut events);
815        Ok(events)
816    }
817
818    async fn ensure_quote(
819        &self,
820    ) -> Result<tokio::sync::RwLockReadGuard<'_, Option<CacheEntry<QuoteSummaryResponse>>>> {
821        {
822            let cache = self.quote_cache.read().await;
823            if self.is_shared_cache_fresh(cache.as_ref()) {
824                return Ok(cache);
825            }
826        }
827        let _guard = self.quote_fetch.lock().await;
828        {
829            let cache = self.quote_cache.read().await;
830            if self.is_shared_cache_fresh(cache.as_ref()) {
831                return Ok(cache);
832            }
833        }
834        let sym = self.symbol.clone();
835        let summary = self
836            .providers
837            .fetch(Capability::QUOTE, move |p| {
838                let sym = sym.clone();
839                let p = p.clone();
840                async move { p.fetch_quote(&sym).await }
841            })
842            .await?;
843        {
844            let mut cache = self.quote_cache.write().await;
845            *cache = Some(CacheEntry::new(summary));
846        }
847        Ok(self.quote_cache.read().await)
848    }
849}
850
851super::macros::define_quote_accessors! {
852    price -> Price, price,
853    summary_detail -> SummaryDetail, summary_detail,
854    financial_data -> FinancialData, financial_data,
855    key_stats -> DefaultKeyStatistics, default_key_statistics,
856    asset_profile -> AssetProfile, asset_profile,
857    calendar_events -> CalendarEvents, calendar_events,
858    earnings -> Earnings, earnings,
859    earnings_trend -> EarningsTrend, earnings_trend,
860    earnings_history -> EarningsHistory, earnings_history,
861    recommendation_trend -> RecommendationTrend, recommendation_trend,
862    insider_holders -> InsiderHolders, insider_holders,
863    insider_transactions -> InsiderTransactions, insider_transactions,
864    institution_ownership -> InstitutionOwnership, institution_ownership,
865    fund_ownership -> FundOwnership, fund_ownership,
866    major_holders -> MajorHoldersBreakdown, major_holders_breakdown,
867    share_purchase_activity -> NetSharePurchaseActivity, net_share_purchase_activity,
868    quote_type -> QuoteTypeData, quote_type,
869    summary_profile -> SummaryProfile, summary_profile,
870    sec_filings -> SecFilings, sec_filings,
871    grading_history -> UpgradeDowngradeHistory, upgrade_downgrade_history,
872    fund_performance -> FundPerformance, fund_performance,
873    fund_profile -> FundProfile, fund_profile,
874    top_holdings -> TopHoldings, top_holdings,
875    index_trend -> IndexTrend, index_trend,
876    industry_trend -> IndustryTrend, industry_trend,
877    sector_trend -> SectorTrend, sector_trend,
878    equity_performance -> EquityPerformance, equity_performance,
879}