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