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