Skip to main content

finance_query/ticker/
core.rs

1//! Ticker implementation for accessing symbol-specific data from Yahoo Finance.
2//!
3//! Provides async interface for fetching quotes, charts, financials, and news.
4
5use super::macros;
6use crate::client::{ClientConfig, YahooClient};
7use crate::constants::{Interval, TimeRange};
8use crate::error::Result;
9use crate::models::chart::events::ChartEvents;
10use crate::models::chart::response::ChartResponse;
11use crate::models::chart::{CapitalGain, Chart, Dividend, Split};
12use crate::models::financials::FinancialStatement;
13use crate::models::options::Options;
14use crate::models::quote::{
15    AssetProfile, CalendarEvents, DefaultKeyStatistics, Earnings, EarningsHistory, EarningsTrend,
16    EquityPerformance, FinancialData, FundOwnership, FundPerformance, FundProfile, IndexTrend,
17    IndustryTrend, InsiderHolders, InsiderTransactions, InstitutionOwnership,
18    MajorHoldersBreakdown, Module, NetSharePurchaseActivity, Price, Quote, QuoteSummaryResponse,
19    QuoteTypeData, RecommendationTrend, SecFilings, SectorTrend, SummaryDetail, SummaryProfile,
20    TopHoldings, UpgradeDowngradeHistory,
21};
22use crate::models::recommendation::Recommendation;
23use crate::models::recommendation::response::RecommendationResponse;
24use crate::utils::{CacheEntry, EVICTION_THRESHOLD, filter_by_range};
25use std::collections::HashMap;
26use std::sync::{Arc, OnceLock};
27use std::time::Duration;
28use tokio::sync::RwLock;
29
30// Type aliases to keep struct definitions readable.
31type Cache<T> = Arc<RwLock<Option<CacheEntry<T>>>>;
32type MapCache<K, V> = Arc<RwLock<HashMap<K, CacheEntry<V>>>>;
33
34/// Opaque handle to a shared Yahoo Finance client session.
35///
36/// Allows multiple [`Ticker`] and [`Tickers`](crate::Tickers) instances to share
37/// a single authenticated session, avoiding redundant authentication handshakes.
38///
39/// Obtain a handle from an existing `Ticker` via [`Ticker::client_handle()`],
40/// then pass it to other builders via `.client()`.
41///
42/// # Example
43///
44/// ```no_run
45/// use finance_query::Ticker;
46///
47/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
48/// let aapl = Ticker::new("AAPL").await?;
49/// let handle = aapl.client_handle();
50///
51/// // Share the same session — no additional auth
52/// let msft = Ticker::builder("MSFT").client(handle.clone()).build().await?;
53/// let googl = Ticker::builder("GOOGL").client(handle).build().await?;
54/// # Ok(())
55/// # }
56/// ```
57#[derive(Clone)]
58pub struct ClientHandle(pub(crate) Arc<YahooClient>);
59
60/// Builder for Ticker
61///
62/// Provides a fluent API for constructing Ticker instances.
63pub struct TickerBuilder {
64    symbol: Arc<str>,
65    config: ClientConfig,
66    shared_client: Option<ClientHandle>,
67    cache_ttl: Option<Duration>,
68    include_logo: bool,
69}
70
71impl TickerBuilder {
72    fn new(symbol: impl Into<String>) -> Self {
73        Self {
74            symbol: symbol.into().into(),
75            config: ClientConfig::default(),
76            shared_client: None,
77            cache_ttl: None,
78            include_logo: false,
79        }
80    }
81
82    /// Set the region (automatically sets correct lang and region)
83    ///
84    /// This is the recommended way to configure regional settings as it ensures
85    /// lang and region are correctly paired.
86    ///
87    /// # Example
88    ///
89    /// ```no_run
90    /// use finance_query::{Ticker, Region};
91    ///
92    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
93    /// let ticker = Ticker::builder("2330.TW")
94    ///     .region(Region::Taiwan)
95    ///     .build()
96    ///     .await?;
97    /// # Ok(())
98    /// # }
99    /// ```
100    pub fn region(mut self, region: crate::constants::Region) -> Self {
101        self.config.lang = region.lang().to_string();
102        self.config.region = region.region().to_string();
103        self
104    }
105
106    /// Set the language code (e.g., "en-US", "ja-JP", "de-DE")
107    ///
108    /// For standard regions, prefer using `.region()` instead to ensure
109    /// correct lang/region pairing.
110    pub fn lang(mut self, lang: impl Into<String>) -> Self {
111        self.config.lang = lang.into();
112        self
113    }
114
115    /// Set the region code (e.g., "US", "JP", "DE")
116    ///
117    /// For standard regions, prefer using `.region()` instead to ensure
118    /// correct lang/region pairing.
119    pub fn region_code(mut self, region: impl Into<String>) -> Self {
120        self.config.region = region.into();
121        self
122    }
123
124    /// Set the HTTP request timeout
125    pub fn timeout(mut self, timeout: Duration) -> Self {
126        self.config.timeout = timeout;
127        self
128    }
129
130    /// Set the proxy URL
131    pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
132        self.config.proxy = Some(proxy.into());
133        self
134    }
135
136    /// Set a complete ClientConfig (overrides any previously set individual config fields)
137    pub fn config(mut self, config: ClientConfig) -> Self {
138        self.config = config;
139        self
140    }
141
142    /// Share an existing authenticated session instead of creating a new one.
143    ///
144    /// This avoids redundant authentication when you need multiple `Ticker`
145    /// instances or want to share a session between `Ticker` and [`crate::Tickers`].
146    ///
147    /// Obtain a [`ClientHandle`] from any existing `Ticker` via
148    /// [`Ticker::client_handle()`].
149    ///
150    /// When set, the builder's `config`, `timeout`, `proxy`, `lang`, and `region`
151    /// settings are ignored (the shared session's configuration is used instead).
152    ///
153    /// # Example
154    ///
155    /// ```no_run
156    /// use finance_query::Ticker;
157    ///
158    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
159    /// let aapl = Ticker::new("AAPL").await?;
160    /// let handle = aapl.client_handle();
161    ///
162    /// let msft = Ticker::builder("MSFT").client(handle.clone()).build().await?;
163    /// let googl = Ticker::builder("GOOGL").client(handle).build().await?;
164    /// # Ok(())
165    /// # }
166    /// ```
167    pub fn client(mut self, handle: ClientHandle) -> Self {
168        self.shared_client = Some(handle);
169        self
170    }
171
172    /// Enable response caching with a time-to-live.
173    ///
174    /// By default caching is **disabled** — every call fetches fresh data.
175    /// When enabled, responses are reused until the TTL expires, then
176    /// automatically re-fetched. Expired entries in map-based caches
177    /// (chart, options, financials) are evicted on the next write to
178    /// limit memory growth.
179    ///
180    /// # Example
181    ///
182    /// ```no_run
183    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
184    /// use finance_query::Ticker;
185    /// use std::time::Duration;
186    ///
187    /// let ticker = Ticker::builder("AAPL")
188    ///     .cache(Duration::from_secs(30))
189    ///     .build()
190    ///     .await?;
191    /// # Ok(())
192    /// # }
193    /// ```
194    pub fn cache(mut self, ttl: Duration) -> Self {
195        self.cache_ttl = Some(ttl);
196        self
197    }
198
199    /// Include company logo URLs in quote responses.
200    ///
201    /// When enabled, `quote()` will fetch logo URLs in parallel with the
202    /// quote summary, adding a small extra request.
203    pub fn logo(mut self) -> Self {
204        self.include_logo = true;
205        self
206    }
207
208    /// Build the Ticker instance
209    pub async fn build(self) -> Result<Ticker> {
210        let client = match self.shared_client {
211            Some(handle) => handle.0,
212            None => Arc::new(YahooClient::new(self.config).await?),
213        };
214
215        Ok(Ticker {
216            symbol: self.symbol,
217            client,
218            cache_ttl: self.cache_ttl,
219            include_logo: self.include_logo,
220            quote_summary: Default::default(),
221            quote_summary_fetch: Arc::new(tokio::sync::Mutex::new(())),
222            chart_cache: Default::default(),
223            events_cache: Default::default(),
224            recommendations_cache: Default::default(),
225            news_cache: Default::default(),
226            options_cache: Default::default(),
227            financials_cache: Default::default(),
228            #[cfg(feature = "indicators")]
229            indicators_cache: Default::default(),
230            edgar_submissions_cache: Default::default(),
231            edgar_facts_cache: Default::default(),
232        })
233    }
234}
235
236/// Ticker for fetching symbol-specific data.
237///
238/// Provides access to quotes, charts, financials, news, and other data for a specific symbol.
239/// Uses smart lazy loading - quote data is fetched once and cached.
240///
241/// # Example
242///
243/// ```no_run
244/// use finance_query::Ticker;
245///
246/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
247/// let ticker = Ticker::new("AAPL").await?;
248///
249/// // Get quote data
250/// let quote = ticker.quote().await?;
251/// println!("Price: {:?}", quote.regular_market_price);
252///
253/// // Get chart data
254/// use finance_query::{Interval, TimeRange};
255/// let chart = ticker.chart(Interval::OneDay, TimeRange::OneMonth).await?;
256/// println!("Candles: {}", chart.candles.len());
257/// # Ok(())
258/// # }
259/// ```
260#[derive(Clone)]
261pub struct Ticker {
262    symbol: Arc<str>,
263    client: Arc<YahooClient>,
264    cache_ttl: Option<Duration>,
265    include_logo: bool,
266    quote_summary: Cache<QuoteSummaryResponse>,
267    quote_summary_fetch: Arc<tokio::sync::Mutex<()>>,
268    chart_cache: MapCache<(Interval, TimeRange), Chart>,
269    events_cache: Cache<ChartEvents>,
270    recommendations_cache: Cache<RecommendationResponse>,
271    news_cache: Cache<Vec<crate::models::news::News>>,
272    options_cache: MapCache<Option<i64>, Options>,
273    financials_cache: MapCache<
274        (crate::constants::StatementType, crate::constants::Frequency),
275        FinancialStatement,
276    >,
277    #[cfg(feature = "indicators")]
278    indicators_cache: MapCache<(Interval, TimeRange), crate::indicators::IndicatorsSummary>,
279    edgar_submissions_cache: Cache<crate::models::edgar::EdgarSubmissions>,
280    edgar_facts_cache: Cache<crate::models::edgar::CompanyFacts>,
281}
282
283impl Ticker {
284    /// Creates a new ticker with default configuration
285    ///
286    /// # Arguments
287    ///
288    /// * `symbol` - Stock symbol (e.g., "AAPL", "MSFT")
289    ///
290    /// # Examples
291    ///
292    /// ```no_run
293    /// use finance_query::Ticker;
294    ///
295    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
296    /// let ticker = Ticker::new("AAPL").await?;
297    /// # Ok(())
298    /// # }
299    /// ```
300    pub async fn new(symbol: impl Into<String>) -> Result<Self> {
301        Self::builder(symbol).build().await
302    }
303
304    /// Creates a new builder for Ticker
305    ///
306    /// Use this for custom configuration (language, region, timeout, proxy).
307    ///
308    /// # Arguments
309    ///
310    /// * `symbol` - Stock symbol (e.g., "AAPL", "MSFT")
311    ///
312    /// # Examples
313    ///
314    /// ```no_run
315    /// use finance_query::Ticker;
316    ///
317    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
318    /// // Simple case with defaults (same as new())
319    /// let ticker = Ticker::builder("AAPL").build().await?;
320    ///
321    /// // With custom configuration
322    /// let ticker = Ticker::builder("AAPL")
323    ///     .lang("ja-JP")
324    ///     .region_code("JP")
325    ///     .build()
326    ///     .await?;
327    /// # Ok(())
328    /// # }
329    /// ```
330    pub fn builder(symbol: impl Into<String>) -> TickerBuilder {
331        TickerBuilder::new(symbol)
332    }
333
334    /// Returns the ticker symbol
335    pub fn symbol(&self) -> &str {
336        &self.symbol
337    }
338
339    /// Returns a shareable handle to this ticker's authenticated session.
340    ///
341    /// Pass the handle to other [`Ticker`] or [`Tickers`](crate::Tickers) builders
342    /// via `.client()` to reuse the same session without re-authenticating.
343    ///
344    /// # Example
345    ///
346    /// ```no_run
347    /// use finance_query::Ticker;
348    ///
349    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
350    /// let aapl = Ticker::new("AAPL").await?;
351    /// let handle = aapl.client_handle();
352    ///
353    /// let msft = Ticker::builder("MSFT").client(handle).build().await?;
354    /// # Ok(())
355    /// # }
356    /// ```
357    pub fn client_handle(&self) -> ClientHandle {
358        ClientHandle(Arc::clone(&self.client))
359    }
360
361    /// Returns `true` if a cache entry exists and has not exceeded the TTL.
362    ///
363    /// Returns `false` when caching is disabled (`cache_ttl` is `None`).
364    #[inline]
365    fn is_cache_fresh<T>(&self, entry: Option<&CacheEntry<T>>) -> bool {
366        CacheEntry::is_fresh_with_ttl(entry, self.cache_ttl)
367    }
368
369    /// Insert into a map cache, amortizing stale-entry eviction.
370    ///
371    /// Only sweeps stale entries when the map exceeds [`EVICTION_THRESHOLD`],
372    /// avoiding O(n) scans on every write.
373    #[inline]
374    fn cache_insert<K: Eq + std::hash::Hash, V>(
375        &self,
376        map: &mut HashMap<K, CacheEntry<V>>,
377        key: K,
378        value: V,
379    ) {
380        if let Some(ttl) = self.cache_ttl {
381            if map.len() >= EVICTION_THRESHOLD {
382                map.retain(|_, entry| entry.is_fresh(ttl));
383            }
384            map.insert(key, CacheEntry::new(value));
385        }
386    }
387
388    /// Helper to construct Recommendation from RecommendationResponse with limit
389    fn build_recommendation_with_limit(
390        &self,
391        response: &RecommendationResponse,
392        limit: u32,
393    ) -> Recommendation {
394        Recommendation {
395            symbol: self.symbol.to_string(),
396            recommendations: response
397                .finance
398                .result
399                .iter()
400                .flat_map(|r| &r.recommended_symbols)
401                .take(limit as usize)
402                .cloned()
403                .collect(),
404        }
405    }
406
407    /// Builds the quote summary URL with all modules.
408    ///
409    /// The module list is computed once and reused across all Ticker instances.
410    fn build_quote_summary_url(&self) -> String {
411        static MODULES_PARAM: OnceLock<String> = OnceLock::new();
412        let modules = MODULES_PARAM.get_or_init(|| {
413            Module::all()
414                .iter()
415                .map(|m| m.as_str())
416                .collect::<Vec<_>>()
417                .join(",")
418        });
419        let url = crate::endpoints::urls::api::quote_summary(&self.symbol);
420        format!("{}?modules={}", url, modules)
421    }
422
423    /// Ensures quote summary is loaded and returns a read guard.
424    ///
425    /// Fast path: read lock only.
426    /// Slow path: serialized fetch (mutex), HTTP I/O with no lock held, brief write lock update.
427    async fn ensure_and_read_quote_summary(
428        &self,
429    ) -> Result<tokio::sync::RwLockReadGuard<'_, Option<CacheEntry<QuoteSummaryResponse>>>> {
430        // Fast path: cache hit
431        {
432            let cache = self.quote_summary.read().await;
433            if self.is_cache_fresh(cache.as_ref()) {
434                return Ok(cache);
435            }
436        }
437
438        // Slow path: serialize fetch operations to prevent duplicate requests
439        let _fetch_guard = self.quote_summary_fetch.lock().await;
440
441        // Double-check: another task may have fetched while we waited on mutex
442        {
443            let cache = self.quote_summary.read().await;
444            if self.is_cache_fresh(cache.as_ref()) {
445                return Ok(cache);
446            }
447        }
448
449        // HTTP I/O with NO lock held — critical for concurrent readers
450        let url = self.build_quote_summary_url();
451        let http_response = self.client.request_with_crumb(&url).await?;
452        let json = http_response.json::<serde_json::Value>().await?;
453        let response = QuoteSummaryResponse::from_json(json, &self.symbol)?;
454
455        // Brief write lock to update cache
456        {
457            let mut cache = self.quote_summary.write().await;
458            *cache = Some(CacheEntry::new(response));
459        }
460
461        // Fetch mutex released automatically, return read guard
462        Ok(self.quote_summary.read().await)
463    }
464}
465
466// Generate quote summary accessor methods using macro to eliminate duplication.
467macros::define_quote_accessors! {
468    /// Get price information
469    price -> Price, price,
470
471    /// Get summary detail
472    summary_detail -> SummaryDetail, summary_detail,
473
474    /// Get financial data
475    financial_data -> FinancialData, financial_data,
476
477    /// Get key statistics
478    key_stats -> DefaultKeyStatistics, default_key_statistics,
479
480    /// Get asset profile
481    asset_profile -> AssetProfile, asset_profile,
482
483    /// Get calendar events
484    calendar_events -> CalendarEvents, calendar_events,
485
486    /// Get earnings
487    earnings -> Earnings, earnings,
488
489    /// Get earnings trend
490    earnings_trend -> EarningsTrend, earnings_trend,
491
492    /// Get earnings history
493    earnings_history -> EarningsHistory, earnings_history,
494
495    /// Get recommendation trend
496    recommendation_trend -> RecommendationTrend, recommendation_trend,
497
498    /// Get insider holders
499    insider_holders -> InsiderHolders, insider_holders,
500
501    /// Get insider transactions
502    insider_transactions -> InsiderTransactions, insider_transactions,
503
504    /// Get institution ownership
505    institution_ownership -> InstitutionOwnership, institution_ownership,
506
507    /// Get fund ownership
508    fund_ownership -> FundOwnership, fund_ownership,
509
510    /// Get major holders breakdown
511    major_holders -> MajorHoldersBreakdown, major_holders_breakdown,
512
513    /// Get net share purchase activity
514    share_purchase_activity -> NetSharePurchaseActivity, net_share_purchase_activity,
515
516    /// Get quote type
517    quote_type -> QuoteTypeData, quote_type,
518
519    /// Get summary profile
520    summary_profile -> SummaryProfile, summary_profile,
521
522    /// Get SEC filings (limited Yahoo Finance data)
523    ///
524    /// **DEPRECATED:** This method returns limited SEC filing metadata from Yahoo Finance.
525    /// For comprehensive filing data directly from SEC EDGAR, use `edgar_submissions()` instead.
526    ///
527    /// To use EDGAR methods:
528    /// ```no_run
529    /// # use finance_query::{Ticker, edgar};
530    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
531    /// edgar::init("user@example.com")?;
532    /// let ticker = Ticker::new("AAPL").await?;
533    /// let submissions = ticker.edgar_submissions().await?;  // Comprehensive EDGAR data
534    /// # Ok(())
535    /// # }
536    /// ```
537    #[deprecated(
538        since = "2.2.0",
539        note = "Use `edgar_submissions()` for comprehensive SEC EDGAR data instead of limited Yahoo Finance metadata"
540    )]
541    sec_filings -> SecFilings, sec_filings,
542
543    /// Get upgrade/downgrade history
544    grading_history -> UpgradeDowngradeHistory, upgrade_downgrade_history,
545
546    /// Get fund performance data (returns, trailing returns, risk statistics)
547    ///
548    /// Primarily relevant for ETFs and mutual funds. Returns `None` for equities.
549    fund_performance -> FundPerformance, fund_performance,
550
551    /// Get fund profile (category, family, fees, legal type)
552    ///
553    /// Primarily relevant for ETFs and mutual funds. Returns `None` for equities.
554    fund_profile -> FundProfile, fund_profile,
555
556    /// Get top holdings for ETFs and mutual funds
557    ///
558    /// Includes top stock/bond holdings with weights and sector weightings.
559    /// Returns `None` for equities.
560    top_holdings -> TopHoldings, top_holdings,
561
562    /// Get index trend data (P/E estimates and growth rates)
563    ///
564    /// Contains trend data for the symbol's associated index.
565    index_trend -> IndexTrend, index_trend,
566
567    /// Get industry trend data
568    ///
569    /// Contains P/E and growth estimates for the symbol's industry.
570    industry_trend -> IndustryTrend, industry_trend,
571
572    /// Get sector trend data
573    ///
574    /// Contains P/E and growth estimates for the symbol's sector.
575    sector_trend -> SectorTrend, sector_trend,
576
577    /// Get equity performance vs benchmark
578    ///
579    /// Performance comparison across multiple time periods.
580    equity_performance -> EquityPerformance, equity_performance,
581}
582
583impl Ticker {
584    /// Get full quote data, optionally including logo URLs.
585    ///
586    /// Use [`TickerBuilder::logo()`](TickerBuilder::logo) to enable logo fetching
587    /// for this ticker instance.
588    ///
589    /// When logos are enabled, fetches both quote summary and logo URL in parallel
590    /// using tokio::join! for minimal latency impact (~0-100ms overhead).
591    pub async fn quote(&self) -> Result<Quote> {
592        let not_found = || crate::error::FinanceError::SymbolNotFound {
593            symbol: Some(self.symbol.to_string()),
594            context: "Quote summary not loaded".to_string(),
595        };
596
597        if self.include_logo {
598            // Ensure quote summary is loaded in background while we fetch logos
599            let (cache_result, logo_result) = tokio::join!(
600                self.ensure_and_read_quote_summary(),
601                self.client.get_logo_url(&self.symbol)
602            );
603            let cache = cache_result?;
604            let entry = cache.as_ref().ok_or_else(not_found)?;
605            let (logo_url, company_logo_url) = logo_result;
606            Ok(Quote::from_response(
607                &entry.value,
608                logo_url,
609                company_logo_url,
610            ))
611        } else {
612            let cache = self.ensure_and_read_quote_summary().await?;
613            let entry = cache.as_ref().ok_or_else(not_found)?;
614            Ok(Quote::from_response(&entry.value, None, None))
615        }
616    }
617
618    /// Get historical chart data
619    pub async fn chart(&self, interval: Interval, range: TimeRange) -> Result<Chart> {
620        // Fast path: return cached Chart directly (no re-parsing)
621        {
622            let cache = self.chart_cache.read().await;
623            if let Some(entry) = cache.get(&(interval, range))
624                && self.is_cache_fresh(Some(entry))
625            {
626                return Ok(entry.value.clone());
627            }
628        }
629
630        // Fetch from Yahoo
631        let json = self.client.get_chart(&self.symbol, interval, range).await?;
632        let chart_result = Self::parse_chart_result(json, &self.symbol)?;
633
634        // Always update events when we have fresh data from Yahoo
635        if let Some(events) = &chart_result.events {
636            let mut events_cache = self.events_cache.write().await;
637            *events_cache = Some(CacheEntry::new(events.clone()));
638        }
639
640        // Materialize Chart from raw result — this is the only place to_candles() runs
641        let chart = Chart {
642            symbol: self.symbol.to_string(),
643            meta: chart_result.meta.clone(),
644            candles: chart_result.to_candles(),
645            interval: Some(interval),
646            range: Some(range),
647        };
648
649        // Only clone when caching is enabled to avoid unnecessary allocations
650        if self.cache_ttl.is_some() {
651            let ret = chart.clone();
652            let mut cache = self.chart_cache.write().await;
653            self.cache_insert(&mut cache, (interval, range), chart);
654            Ok(ret)
655        } else {
656            Ok(chart)
657        }
658    }
659
660    /// Parse a ChartResult from raw JSON, returning a descriptive error on failure.
661    fn parse_chart_result(
662        json: serde_json::Value,
663        symbol: &str,
664    ) -> Result<crate::models::chart::result::ChartResult> {
665        let response = ChartResponse::from_json(json).map_err(|e| {
666            crate::error::FinanceError::ResponseStructureError {
667                field: "chart".to_string(),
668                context: e.to_string(),
669            }
670        })?;
671
672        let results =
673            response
674                .chart
675                .result
676                .ok_or_else(|| crate::error::FinanceError::SymbolNotFound {
677                    symbol: Some(symbol.to_string()),
678                    context: "Chart data not found".to_string(),
679                })?;
680
681        results
682            .into_iter()
683            .next()
684            .ok_or_else(|| crate::error::FinanceError::SymbolNotFound {
685                symbol: Some(symbol.to_string()),
686                context: "Chart data empty".to_string(),
687            })
688    }
689
690    /// Get historical chart data for a custom date range.
691    ///
692    /// Unlike [`chart()`](Self::chart) which uses predefined time ranges,
693    /// this method accepts absolute start/end timestamps for precise date control.
694    ///
695    /// Results are **not cached** since custom ranges have unbounded key space.
696    ///
697    /// # Arguments
698    ///
699    /// * `interval` - Time interval between data points
700    /// * `start` - Start date as Unix timestamp (seconds since epoch)
701    /// * `end` - End date as Unix timestamp (seconds since epoch)
702    ///
703    /// # Example
704    ///
705    /// ```no_run
706    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
707    /// use finance_query::{Ticker, Interval};
708    /// use chrono::NaiveDate;
709    ///
710    /// let ticker = Ticker::new("AAPL").await?;
711    ///
712    /// // Q3 2024
713    /// let start = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap()
714    ///     .and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp();
715    /// let end = NaiveDate::from_ymd_opt(2024, 9, 30).unwrap()
716    ///     .and_hms_opt(23, 59, 59).unwrap().and_utc().timestamp();
717    ///
718    /// let chart = ticker.chart_range(Interval::OneDay, start, end).await?;
719    /// println!("Q3 2024 candles: {}", chart.candles.len());
720    /// # Ok(())
721    /// # }
722    /// ```
723    pub async fn chart_range(&self, interval: Interval, start: i64, end: i64) -> Result<Chart> {
724        let json = self
725            .client
726            .get_chart_range(&self.symbol, interval, start, end)
727            .await?;
728        let chart_result = Self::parse_chart_result(json, &self.symbol)?;
729
730        // Always update events when we have fresh data from Yahoo
731        if let Some(events) = &chart_result.events {
732            let mut events_cache = self.events_cache.write().await;
733            *events_cache = Some(CacheEntry::new(events.clone()));
734        }
735
736        Ok(Chart {
737            symbol: self.symbol.to_string(),
738            meta: chart_result.meta.clone(),
739            candles: chart_result.to_candles(),
740            interval: Some(interval),
741            range: None,
742        })
743    }
744
745    /// Ensures events data is loaded (fetches events only if not cached)
746    async fn ensure_events_loaded(&self) -> Result<()> {
747        // Quick read check
748        {
749            let cache = self.events_cache.read().await;
750            if self.is_cache_fresh(cache.as_ref()) {
751                return Ok(());
752            }
753        }
754
755        // Fetch events using max range with 1d interval to get all historical events
756        // Using 1d interval minimizes candle count compared to shorter intervals
757        let json = crate::endpoints::chart::fetch(
758            &self.client,
759            &self.symbol,
760            Interval::OneDay,
761            TimeRange::Max,
762        )
763        .await?;
764        let chart_result = Self::parse_chart_result(json, &self.symbol)?;
765
766        // Write to events cache unconditionally for temporary storage during this method
767        // Note: when cache_ttl is None, is_cache_fresh() returns false, so this will
768        // be refetched on the next call to dividends()/splits()/capital_gains().
769        // Cache empty ChartEvents when Yahoo returns no events to prevent infinite refetch loops
770        let mut events_cache = self.events_cache.write().await;
771        *events_cache = Some(CacheEntry::new(chart_result.events.unwrap_or_default()));
772
773        Ok(())
774    }
775
776    /// Get dividend history
777    ///
778    /// Returns historical dividend payments sorted by date.
779    /// Events are lazily loaded (fetched once, then filtered by range).
780    ///
781    /// # Arguments
782    ///
783    /// * `range` - Time range to filter dividends
784    ///
785    /// # Example
786    ///
787    /// ```no_run
788    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
789    /// use finance_query::{Ticker, TimeRange};
790    ///
791    /// let ticker = Ticker::new("AAPL").await?;
792    ///
793    /// // Get all dividends
794    /// let all = ticker.dividends(TimeRange::Max).await?;
795    ///
796    /// // Get last year's dividends
797    /// let recent = ticker.dividends(TimeRange::OneYear).await?;
798    /// # Ok(())
799    /// # }
800    /// ```
801    pub async fn dividends(&self, range: TimeRange) -> Result<Vec<Dividend>> {
802        self.ensure_events_loaded().await?;
803
804        let cache = self.events_cache.read().await;
805        let all = cache
806            .as_ref()
807            .map(|e| e.value.to_dividends())
808            .unwrap_or_default();
809
810        Ok(filter_by_range(all, range))
811    }
812
813    /// Compute dividend analytics for the requested time range.
814    ///
815    /// Calculates statistics on the dividend history: total paid, payment count,
816    /// average payment, and Compound Annual Growth Rate (CAGR).
817    ///
818    /// **CAGR note:** requires at least two payments spanning at least one calendar year.
819    ///
820    /// # Arguments
821    ///
822    /// * `range` - Time range to analyse
823    ///
824    /// # Example
825    ///
826    /// ```no_run
827    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
828    /// use finance_query::{Ticker, TimeRange};
829    ///
830    /// let ticker = Ticker::new("AAPL").await?;
831    /// let analytics = ticker.dividend_analytics(TimeRange::FiveYears).await?;
832    ///
833    /// println!("Total paid: ${:.2}", analytics.total_paid);
834    /// println!("Payments:   {}", analytics.payment_count);
835    /// if let Some(cagr) = analytics.cagr {
836    ///     println!("CAGR:       {:.1}%", cagr * 100.0);
837    /// }
838    /// # Ok(())
839    /// # }
840    /// ```
841    pub async fn dividend_analytics(
842        &self,
843        range: TimeRange,
844    ) -> Result<crate::models::chart::DividendAnalytics> {
845        let dividends = self.dividends(range).await?;
846        Ok(crate::models::chart::DividendAnalytics::from_dividends(
847            &dividends,
848        ))
849    }
850
851    /// Get stock split history
852    ///
853    /// Returns historical stock splits sorted by date.
854    /// Events are lazily loaded (fetched once, then filtered by range).
855    ///
856    /// # Arguments
857    ///
858    /// * `range` - Time range to filter splits
859    ///
860    /// # Example
861    ///
862    /// ```no_run
863    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
864    /// use finance_query::{Ticker, TimeRange};
865    ///
866    /// let ticker = Ticker::new("NVDA").await?;
867    ///
868    /// // Get all splits
869    /// let all = ticker.splits(TimeRange::Max).await?;
870    ///
871    /// // Get last 5 years
872    /// let recent = ticker.splits(TimeRange::FiveYears).await?;
873    /// # Ok(())
874    /// # }
875    /// ```
876    pub async fn splits(&self, range: TimeRange) -> Result<Vec<Split>> {
877        self.ensure_events_loaded().await?;
878
879        let cache = self.events_cache.read().await;
880        let all = cache
881            .as_ref()
882            .map(|e| e.value.to_splits())
883            .unwrap_or_default();
884
885        Ok(filter_by_range(all, range))
886    }
887
888    /// Get capital gains distribution history
889    ///
890    /// Returns historical capital gain distributions sorted by date.
891    /// This is primarily relevant for mutual funds and ETFs.
892    /// Events are lazily loaded (fetched once, then filtered by range).
893    ///
894    /// # Arguments
895    ///
896    /// * `range` - Time range to filter capital gains
897    ///
898    /// # Example
899    ///
900    /// ```no_run
901    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
902    /// use finance_query::{Ticker, TimeRange};
903    ///
904    /// let ticker = Ticker::new("VFIAX").await?;
905    ///
906    /// // Get all capital gains
907    /// let all = ticker.capital_gains(TimeRange::Max).await?;
908    ///
909    /// // Get last 2 years
910    /// let recent = ticker.capital_gains(TimeRange::TwoYears).await?;
911    /// # Ok(())
912    /// # }
913    /// ```
914    pub async fn capital_gains(&self, range: TimeRange) -> Result<Vec<CapitalGain>> {
915        self.ensure_events_loaded().await?;
916
917        let cache = self.events_cache.read().await;
918        let all = cache
919            .as_ref()
920            .map(|e| e.value.to_capital_gains())
921            .unwrap_or_default();
922
923        Ok(filter_by_range(all, range))
924    }
925
926    /// Calculate all technical indicators from chart data
927    ///
928    /// # Arguments
929    ///
930    /// * `interval` - The time interval for each candle
931    /// * `range` - The time range to fetch data for
932    ///
933    /// # Returns
934    ///
935    /// Returns `IndicatorsSummary` containing all calculated indicators.
936    ///
937    /// # Example
938    ///
939    /// ```no_run
940    /// use finance_query::{Ticker, Interval, TimeRange};
941    ///
942    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
943    /// let ticker = Ticker::new("AAPL").await?;
944    /// let indicators = ticker.indicators(Interval::OneDay, TimeRange::OneYear).await?;
945    ///
946    /// println!("RSI(14): {:?}", indicators.rsi_14);
947    /// println!("MACD: {:?}", indicators.macd);
948    /// # Ok(())
949    /// # }
950    /// ```
951    #[cfg(feature = "indicators")]
952    pub async fn indicators(
953        &self,
954        interval: Interval,
955        range: TimeRange,
956    ) -> Result<crate::indicators::IndicatorsSummary> {
957        // Check cache first (read lock)
958        {
959            let cache = self.indicators_cache.read().await;
960            if let Some(entry) = cache.get(&(interval, range))
961                && self.is_cache_fresh(Some(entry))
962            {
963                return Ok(entry.value.clone());
964            }
965        }
966
967        // Fetch chart data (this is also cached!)
968        let chart = self.chart(interval, range).await?;
969
970        // Calculate indicators from candles
971        let indicators = crate::indicators::summary::calculate_indicators(&chart.candles);
972
973        // Only clone when caching is enabled
974        if self.cache_ttl.is_some() {
975            let mut cache = self.indicators_cache.write().await;
976            self.cache_insert(&mut cache, (interval, range), indicators.clone());
977            Ok(indicators)
978        } else {
979            Ok(indicators)
980        }
981    }
982
983    /// Calculate a specific technical indicator over a time range.
984    ///
985    /// Returns the full time series for the requested indicator, not just the latest value.
986    /// This is useful when you need historical indicator values for analysis or charting.
987    ///
988    /// # Arguments
989    ///
990    /// * `indicator` - The indicator to calculate (from `crate::indicators::Indicator`)
991    /// * `interval` - Time interval for candles (1d, 1h, etc.)
992    /// * `range` - Time range for historical data
993    ///
994    /// # Returns
995    ///
996    /// An `IndicatorResult` containing the full time series. Access the data using match:
997    /// - `IndicatorResult::Series(values)` - for simple indicators (SMA, EMA, RSI, ATR, OBV, VWAP, WMA)
998    /// - `IndicatorResult::Macd(data)` - for MACD (macd_line, signal_line, histogram)
999    /// - `IndicatorResult::Bollinger(data)` - for Bollinger Bands (upper, middle, lower)
1000    ///
1001    /// # Example
1002    ///
1003    /// ```no_run
1004    /// use finance_query::{Ticker, Interval, TimeRange};
1005    /// use finance_query::indicators::{Indicator, IndicatorResult};
1006    ///
1007    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1008    /// let ticker = Ticker::new("AAPL").await?;
1009    ///
1010    /// // Calculate 14-period RSI
1011    /// let result = ticker.indicator(
1012    ///     Indicator::Rsi(14),
1013    ///     Interval::OneDay,
1014    ///     TimeRange::ThreeMonths
1015    /// ).await?;
1016    ///
1017    /// match result {
1018    ///     IndicatorResult::Series(values) => {
1019    ///         println!("Latest RSI: {:?}", values.last());
1020    ///     }
1021    ///     _ => {}
1022    /// }
1023    ///
1024    /// // Calculate MACD
1025    /// let macd_result = ticker.indicator(
1026    ///     Indicator::Macd { fast: 12, slow: 26, signal: 9 },
1027    ///     Interval::OneDay,
1028    ///     TimeRange::SixMonths
1029    /// ).await?;
1030    ///
1031    /// # Ok(())
1032    /// # }
1033    /// ```
1034    #[cfg(feature = "indicators")]
1035    pub async fn indicator(
1036        &self,
1037        indicator: crate::indicators::Indicator,
1038        interval: Interval,
1039        range: TimeRange,
1040    ) -> Result<crate::indicators::IndicatorResult> {
1041        use crate::indicators::{Indicator, IndicatorResult};
1042
1043        // Fetch chart data
1044        let chart = self.chart(interval, range).await?;
1045
1046        // Calculate the requested indicator
1047        // Note: Price vectors are extracted lazily within each arm to avoid waste
1048        let result = match indicator {
1049            Indicator::Sma(period) => IndicatorResult::Series(chart.sma(period)),
1050            Indicator::Ema(period) => IndicatorResult::Series(chart.ema(period)),
1051            Indicator::Rsi(period) => IndicatorResult::Series(chart.rsi(period)?),
1052            Indicator::Macd { fast, slow, signal } => {
1053                IndicatorResult::Macd(chart.macd(fast, slow, signal)?)
1054            }
1055            Indicator::Bollinger { period, std_dev } => {
1056                IndicatorResult::Bollinger(chart.bollinger_bands(period, std_dev)?)
1057            }
1058            Indicator::Atr(period) => IndicatorResult::Series(chart.atr(period)?),
1059            Indicator::Obv => {
1060                let closes = chart.close_prices();
1061                let volumes = chart.volumes();
1062                IndicatorResult::Series(crate::indicators::obv(&closes, &volumes)?)
1063            }
1064            Indicator::Vwap => {
1065                let highs = chart.high_prices();
1066                let lows = chart.low_prices();
1067                let closes = chart.close_prices();
1068                let volumes = chart.volumes();
1069                IndicatorResult::Series(crate::indicators::vwap(&highs, &lows, &closes, &volumes)?)
1070            }
1071            Indicator::Wma(period) => {
1072                let closes = chart.close_prices();
1073                IndicatorResult::Series(crate::indicators::wma(&closes, period)?)
1074            }
1075            Indicator::Dema(period) => {
1076                let closes = chart.close_prices();
1077                IndicatorResult::Series(crate::indicators::dema(&closes, period)?)
1078            }
1079            Indicator::Tema(period) => {
1080                let closes = chart.close_prices();
1081                IndicatorResult::Series(crate::indicators::tema(&closes, period)?)
1082            }
1083            Indicator::Hma(period) => {
1084                let closes = chart.close_prices();
1085                IndicatorResult::Series(crate::indicators::hma(&closes, period)?)
1086            }
1087            Indicator::Vwma(period) => {
1088                let closes = chart.close_prices();
1089                let volumes = chart.volumes();
1090                IndicatorResult::Series(crate::indicators::vwma(&closes, &volumes, period)?)
1091            }
1092            Indicator::Alma {
1093                period,
1094                offset,
1095                sigma,
1096            } => {
1097                let closes = chart.close_prices();
1098                IndicatorResult::Series(crate::indicators::alma(&closes, period, offset, sigma)?)
1099            }
1100            Indicator::McginleyDynamic(period) => {
1101                let closes = chart.close_prices();
1102                IndicatorResult::Series(crate::indicators::mcginley_dynamic(&closes, period)?)
1103            }
1104            Indicator::Stochastic {
1105                k_period,
1106                k_slow,
1107                d_period,
1108            } => {
1109                let highs = chart.high_prices();
1110                let lows = chart.low_prices();
1111                let closes = chart.close_prices();
1112                IndicatorResult::Stochastic(crate::indicators::stochastic(
1113                    &highs, &lows, &closes, k_period, k_slow, d_period,
1114                )?)
1115            }
1116            Indicator::StochasticRsi {
1117                rsi_period,
1118                stoch_period,
1119                k_period,
1120                d_period,
1121            } => {
1122                let closes = chart.close_prices();
1123                IndicatorResult::Stochastic(crate::indicators::stochastic_rsi(
1124                    &closes,
1125                    rsi_period,
1126                    stoch_period,
1127                    k_period,
1128                    d_period,
1129                )?)
1130            }
1131            Indicator::Cci(period) => {
1132                let highs = chart.high_prices();
1133                let lows = chart.low_prices();
1134                let closes = chart.close_prices();
1135                IndicatorResult::Series(crate::indicators::cci(&highs, &lows, &closes, period)?)
1136            }
1137            Indicator::WilliamsR(period) => {
1138                let highs = chart.high_prices();
1139                let lows = chart.low_prices();
1140                let closes = chart.close_prices();
1141                IndicatorResult::Series(crate::indicators::williams_r(
1142                    &highs, &lows, &closes, period,
1143                )?)
1144            }
1145            Indicator::Roc(period) => {
1146                let closes = chart.close_prices();
1147                IndicatorResult::Series(crate::indicators::roc(&closes, period)?)
1148            }
1149            Indicator::Momentum(period) => {
1150                let closes = chart.close_prices();
1151                IndicatorResult::Series(crate::indicators::momentum(&closes, period)?)
1152            }
1153            Indicator::Cmo(period) => {
1154                let closes = chart.close_prices();
1155                IndicatorResult::Series(crate::indicators::cmo(&closes, period)?)
1156            }
1157            Indicator::AwesomeOscillator { fast, slow } => {
1158                let highs = chart.high_prices();
1159                let lows = chart.low_prices();
1160                IndicatorResult::Series(crate::indicators::awesome_oscillator(
1161                    &highs, &lows, fast, slow,
1162                )?)
1163            }
1164            Indicator::CoppockCurve {
1165                wma_period,
1166                long_roc,
1167                short_roc,
1168            } => {
1169                let closes = chart.close_prices();
1170                IndicatorResult::Series(crate::indicators::coppock_curve(
1171                    &closes, long_roc, short_roc, wma_period,
1172                )?)
1173            }
1174            Indicator::Adx(period) => {
1175                let highs = chart.high_prices();
1176                let lows = chart.low_prices();
1177                let closes = chart.close_prices();
1178                IndicatorResult::Series(crate::indicators::adx(&highs, &lows, &closes, period)?)
1179            }
1180            Indicator::Aroon(period) => {
1181                let highs = chart.high_prices();
1182                let lows = chart.low_prices();
1183                IndicatorResult::Aroon(crate::indicators::aroon(&highs, &lows, period)?)
1184            }
1185            Indicator::Supertrend { period, multiplier } => {
1186                let highs = chart.high_prices();
1187                let lows = chart.low_prices();
1188                let closes = chart.close_prices();
1189                IndicatorResult::SuperTrend(crate::indicators::supertrend(
1190                    &highs, &lows, &closes, period, multiplier,
1191                )?)
1192            }
1193            Indicator::Ichimoku {
1194                conversion,
1195                base,
1196                lagging,
1197                displacement,
1198            } => {
1199                let highs = chart.high_prices();
1200                let lows = chart.low_prices();
1201                let closes = chart.close_prices();
1202                IndicatorResult::Ichimoku(crate::indicators::ichimoku(
1203                    &highs,
1204                    &lows,
1205                    &closes,
1206                    conversion,
1207                    base,
1208                    lagging,
1209                    displacement,
1210                )?)
1211            }
1212            Indicator::ParabolicSar { step, max } => {
1213                let highs = chart.high_prices();
1214                let lows = chart.low_prices();
1215                let closes = chart.close_prices();
1216                IndicatorResult::Series(crate::indicators::parabolic_sar(
1217                    &highs, &lows, &closes, step, max,
1218                )?)
1219            }
1220            Indicator::BullBearPower(period) => {
1221                let highs = chart.high_prices();
1222                let lows = chart.low_prices();
1223                let closes = chart.close_prices();
1224                IndicatorResult::BullBearPower(crate::indicators::bull_bear_power(
1225                    &highs, &lows, &closes, period,
1226                )?)
1227            }
1228            Indicator::ElderRay(period) => {
1229                let highs = chart.high_prices();
1230                let lows = chart.low_prices();
1231                let closes = chart.close_prices();
1232                IndicatorResult::ElderRay(crate::indicators::elder_ray(
1233                    &highs, &lows, &closes, period,
1234                )?)
1235            }
1236            Indicator::KeltnerChannels {
1237                period,
1238                multiplier,
1239                atr_period,
1240            } => {
1241                let highs = chart.high_prices();
1242                let lows = chart.low_prices();
1243                let closes = chart.close_prices();
1244                IndicatorResult::Keltner(crate::indicators::keltner_channels(
1245                    &highs, &lows, &closes, period, atr_period, multiplier,
1246                )?)
1247            }
1248            Indicator::DonchianChannels(period) => {
1249                let highs = chart.high_prices();
1250                let lows = chart.low_prices();
1251                IndicatorResult::Donchian(crate::indicators::donchian_channels(
1252                    &highs, &lows, period,
1253                )?)
1254            }
1255            Indicator::TrueRange => {
1256                let highs = chart.high_prices();
1257                let lows = chart.low_prices();
1258                let closes = chart.close_prices();
1259                IndicatorResult::Series(crate::indicators::true_range(&highs, &lows, &closes)?)
1260            }
1261            Indicator::ChoppinessIndex(period) => {
1262                let highs = chart.high_prices();
1263                let lows = chart.low_prices();
1264                let closes = chart.close_prices();
1265                IndicatorResult::Series(crate::indicators::choppiness_index(
1266                    &highs, &lows, &closes, period,
1267                )?)
1268            }
1269            Indicator::Mfi(period) => {
1270                let highs = chart.high_prices();
1271                let lows = chart.low_prices();
1272                let closes = chart.close_prices();
1273                let volumes = chart.volumes();
1274                IndicatorResult::Series(crate::indicators::mfi(
1275                    &highs, &lows, &closes, &volumes, period,
1276                )?)
1277            }
1278            Indicator::Cmf(period) => {
1279                let highs = chart.high_prices();
1280                let lows = chart.low_prices();
1281                let closes = chart.close_prices();
1282                let volumes = chart.volumes();
1283                IndicatorResult::Series(crate::indicators::cmf(
1284                    &highs, &lows, &closes, &volumes, period,
1285                )?)
1286            }
1287            Indicator::ChaikinOscillator => {
1288                let highs = chart.high_prices();
1289                let lows = chart.low_prices();
1290                let closes = chart.close_prices();
1291                let volumes = chart.volumes();
1292                IndicatorResult::Series(crate::indicators::chaikin_oscillator(
1293                    &highs, &lows, &closes, &volumes,
1294                )?)
1295            }
1296            Indicator::AccumulationDistribution => {
1297                let highs = chart.high_prices();
1298                let lows = chart.low_prices();
1299                let closes = chart.close_prices();
1300                let volumes = chart.volumes();
1301                IndicatorResult::Series(crate::indicators::accumulation_distribution(
1302                    &highs, &lows, &closes, &volumes,
1303                )?)
1304            }
1305            Indicator::BalanceOfPower(period) => {
1306                let opens = chart.open_prices();
1307                let highs = chart.high_prices();
1308                let lows = chart.low_prices();
1309                let closes = chart.close_prices();
1310                IndicatorResult::Series(crate::indicators::balance_of_power(
1311                    &opens, &highs, &lows, &closes, period,
1312                )?)
1313            }
1314        };
1315
1316        Ok(result)
1317    }
1318
1319    /// Get analyst recommendations
1320    pub async fn recommendations(&self, limit: u32) -> Result<Recommendation> {
1321        // Check cache (always fetches max from server, truncated to limit on return)
1322        {
1323            let cache = self.recommendations_cache.read().await;
1324            if let Some(entry) = cache.as_ref()
1325                && self.is_cache_fresh(Some(entry))
1326            {
1327                return Ok(self.build_recommendation_with_limit(&entry.value, limit));
1328            }
1329        }
1330
1331        // Always fetch server maximum (no limit restriction to maximize cache utility)
1332        let json = self.client.get_recommendations(&self.symbol, 15).await?;
1333        let response = RecommendationResponse::from_json(json).map_err(|e| {
1334            crate::error::FinanceError::ResponseStructureError {
1335                field: "finance".to_string(),
1336                context: e.to_string(),
1337            }
1338        })?;
1339
1340        // Cache full response, return truncated result
1341        if self.cache_ttl.is_some() {
1342            let mut cache = self.recommendations_cache.write().await;
1343            *cache = Some(CacheEntry::new(response));
1344            let entry = cache.as_ref().unwrap();
1345            return Ok(self.build_recommendation_with_limit(&entry.value, limit));
1346        }
1347
1348        Ok(self.build_recommendation_with_limit(&response, limit))
1349    }
1350
1351    /// Get financial statements
1352    ///
1353    /// # Arguments
1354    ///
1355    /// * `statement_type` - Type of statement (Income, Balance, CashFlow)
1356    /// * `frequency` - Annual or Quarterly
1357    ///
1358    /// # Example
1359    ///
1360    /// ```no_run
1361    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1362    /// use finance_query::{Ticker, Frequency, StatementType};
1363    ///
1364    /// let ticker = Ticker::new("AAPL").await?;
1365    /// let income = ticker.financials(StatementType::Income, Frequency::Annual).await?;
1366    /// println!("Revenue: {:?}", income.statement.get("TotalRevenue"));
1367    /// # Ok(())
1368    /// # }
1369    /// ```
1370    pub async fn financials(
1371        &self,
1372        statement_type: crate::constants::StatementType,
1373        frequency: crate::constants::Frequency,
1374    ) -> Result<FinancialStatement> {
1375        let cache_key = (statement_type, frequency);
1376
1377        // Check cache
1378        {
1379            let cache = self.financials_cache.read().await;
1380            if let Some(entry) = cache.get(&cache_key)
1381                && self.is_cache_fresh(Some(entry))
1382            {
1383                return Ok(entry.value.clone());
1384            }
1385        }
1386
1387        // Fetch financials
1388        let financials = self
1389            .client
1390            .get_financials(&self.symbol, statement_type, frequency)
1391            .await?;
1392
1393        // Only clone when caching is enabled
1394        if self.cache_ttl.is_some() {
1395            let mut cache = self.financials_cache.write().await;
1396            self.cache_insert(&mut cache, cache_key, financials.clone());
1397            Ok(financials)
1398        } else {
1399            Ok(financials)
1400        }
1401    }
1402
1403    /// Get news articles for this symbol
1404    ///
1405    /// # Example
1406    ///
1407    /// ```no_run
1408    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1409    /// use finance_query::Ticker;
1410    ///
1411    /// let ticker = Ticker::new("AAPL").await?;
1412    /// let news = ticker.news().await?;
1413    /// for article in news {
1414    ///     println!("{}: {}", article.source, article.title);
1415    /// }
1416    /// # Ok(())
1417    /// # }
1418    /// ```
1419    pub async fn news(&self) -> Result<Vec<crate::models::news::News>> {
1420        // Check cache
1421        {
1422            let cache = self.news_cache.read().await;
1423            if self.is_cache_fresh(cache.as_ref()) {
1424                return Ok(cache.as_ref().unwrap().value.clone());
1425            }
1426        }
1427
1428        // Fetch news
1429        let news = crate::scrapers::stockanalysis::scrape_symbol_news(&self.symbol).await?;
1430
1431        // Only clone when caching is enabled
1432        if self.cache_ttl.is_some() {
1433            let mut cache = self.news_cache.write().await;
1434            *cache = Some(CacheEntry::new(news.clone()));
1435            Ok(news)
1436        } else {
1437            Ok(news)
1438        }
1439    }
1440
1441    /// Get options chain
1442    pub async fn options(&self, date: Option<i64>) -> Result<Options> {
1443        // Check cache
1444        {
1445            let cache = self.options_cache.read().await;
1446            if let Some(entry) = cache.get(&date)
1447                && self.is_cache_fresh(Some(entry))
1448            {
1449                return Ok(entry.value.clone());
1450            }
1451        }
1452
1453        // Fetch options
1454        let json = self.client.get_options(&self.symbol, date).await?;
1455        let options: Options = serde_json::from_value(json).map_err(|e| {
1456            crate::error::FinanceError::ResponseStructureError {
1457                field: "options".to_string(),
1458                context: e.to_string(),
1459            }
1460        })?;
1461
1462        // Only clone when caching is enabled
1463        if self.cache_ttl.is_some() {
1464            let mut cache = self.options_cache.write().await;
1465            self.cache_insert(&mut cache, date, options.clone());
1466            Ok(options)
1467        } else {
1468            Ok(options)
1469        }
1470    }
1471
1472    /// Run a backtest with the given strategy and configuration.
1473    ///
1474    /// # Arguments
1475    ///
1476    /// * `strategy` - Trading strategy implementing the Strategy trait
1477    /// * `interval` - Candle interval (1d, 1h, etc.)
1478    /// * `range` - Time range for historical data
1479    /// * `config` - Backtest configuration (optional, uses defaults if None)
1480    ///
1481    /// # Example
1482    ///
1483    /// ```no_run
1484    /// use finance_query::{Ticker, Interval, TimeRange};
1485    /// use finance_query::backtesting::{SmaCrossover, BacktestConfig};
1486    ///
1487    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1488    /// let ticker = Ticker::new("AAPL").await?;
1489    ///
1490    /// // Simple backtest with defaults
1491    /// let strategy = SmaCrossover::new(10, 20);
1492    /// let result = ticker.backtest(
1493    ///     strategy,
1494    ///     Interval::OneDay,
1495    ///     TimeRange::OneYear,
1496    ///     None,
1497    /// ).await?;
1498    ///
1499    /// println!("{}", result.summary());
1500    /// println!("Total trades: {}", result.trades.len());
1501    ///
1502    /// // With custom config
1503    /// let config = BacktestConfig::builder()
1504    ///     .initial_capital(50_000.0)
1505    ///     .commission_pct(0.001)
1506    ///     .stop_loss_pct(0.05)
1507    ///     .allow_short(true)
1508    ///     .build()?;
1509    ///
1510    /// let result = ticker.backtest(
1511    ///     SmaCrossover::new(5, 20),
1512    ///     Interval::OneDay,
1513    ///     TimeRange::TwoYears,
1514    ///     Some(config),
1515    /// ).await?;
1516    /// # Ok(())
1517    /// # }
1518    /// ```
1519    #[cfg(feature = "backtesting")]
1520    pub async fn backtest<S: crate::backtesting::Strategy>(
1521        &self,
1522        strategy: S,
1523        interval: Interval,
1524        range: TimeRange,
1525        config: Option<crate::backtesting::BacktestConfig>,
1526    ) -> crate::backtesting::Result<crate::backtesting::BacktestResult> {
1527        use crate::backtesting::BacktestEngine;
1528
1529        let config = config.unwrap_or_default();
1530        config.validate()?;
1531
1532        // Fetch chart data — also populates the events cache used by dividends()
1533        let chart = self
1534            .chart(interval, range)
1535            .await
1536            .map_err(|e| crate::backtesting::BacktestError::ChartError(e.to_string()))?;
1537
1538        // Fetch dividends from the events cache (no extra network request after chart())
1539        let dividends = self.dividends(range).await.unwrap_or_default();
1540
1541        // Run backtest engine with dividend data
1542        let engine = BacktestEngine::new(config);
1543        engine.run_with_dividends(&self.symbol, &chart.candles, strategy, &dividends)
1544    }
1545
1546    /// Run a backtest and compare performance against a benchmark symbol.
1547    ///
1548    /// Fetches both the symbol chart and the benchmark chart concurrently, then
1549    /// runs the backtest and populates [`BacktestResult::benchmark`] with
1550    /// comparison metrics (alpha, beta, information ratio, buy-and-hold return).
1551    ///
1552    /// Requires the **`backtesting`** feature flag.
1553    ///
1554    /// # Arguments
1555    ///
1556    /// * `strategy` - The strategy to backtest
1557    /// * `interval` - Candle interval
1558    /// * `range` - Historical range
1559    /// * `config` - Optional backtest configuration (uses defaults if `None`)
1560    /// * `benchmark` - Symbol to use as benchmark (e.g. `"SPY"`)
1561    #[cfg(feature = "backtesting")]
1562    pub async fn backtest_with_benchmark<S: crate::backtesting::Strategy>(
1563        &self,
1564        strategy: S,
1565        interval: Interval,
1566        range: TimeRange,
1567        config: Option<crate::backtesting::BacktestConfig>,
1568        benchmark: &str,
1569    ) -> crate::backtesting::Result<crate::backtesting::BacktestResult> {
1570        use crate::backtesting::BacktestEngine;
1571
1572        let config = config.unwrap_or_default();
1573        config.validate()?;
1574
1575        // Fetch the symbol chart and benchmark chart concurrently
1576        let benchmark_ticker = crate::Ticker::new(benchmark)
1577            .await
1578            .map_err(|e| crate::backtesting::BacktestError::ChartError(e.to_string()))?;
1579
1580        let (chart, bench_chart) = tokio::try_join!(
1581            self.chart(interval, range),
1582            benchmark_ticker.chart(interval, range),
1583        )
1584        .map_err(|e| crate::backtesting::BacktestError::ChartError(e.to_string()))?;
1585
1586        // Fetch dividends from events cache (no extra network request after chart())
1587        let dividends = self.dividends(range).await.unwrap_or_default();
1588
1589        let engine = BacktestEngine::new(config);
1590        engine.run_with_benchmark(
1591            &self.symbol,
1592            &chart.candles,
1593            strategy,
1594            &dividends,
1595            benchmark,
1596            &bench_chart.candles,
1597        )
1598    }
1599
1600    // ========================================================================
1601    // Risk Analytics
1602    // ========================================================================
1603
1604    /// Compute a risk summary for this symbol.
1605    ///
1606    /// Requires the **`risk`** feature flag.
1607    ///
1608    /// Calculates Value at Risk, Sharpe/Sortino/Calmar ratios, and maximum drawdown
1609    /// from close-to-close returns derived from the requested chart data.
1610    ///
1611    /// # Arguments
1612    ///
1613    /// * `interval` - Candle interval (use `Interval::OneDay` for daily risk metrics)
1614    /// * `range` - Historical range to analyse
1615    /// * `benchmark` - Optional symbol to use as the benchmark for beta calculation
1616    ///
1617    /// # Example
1618    ///
1619    /// ```no_run
1620    /// use finance_query::{Ticker, Interval, TimeRange};
1621    ///
1622    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1623    /// let ticker = Ticker::new("AAPL").await?;
1624    ///
1625    /// // Risk vs no benchmark
1626    /// let summary = ticker.risk(Interval::OneDay, TimeRange::OneYear, None).await?;
1627    /// println!("VaR 95%:      {:.2}%", summary.var_95 * 100.0);
1628    /// println!("Max drawdown: {:.2}%", summary.max_drawdown * 100.0);
1629    ///
1630    /// // Risk with S&P 500 as benchmark
1631    /// let summary = ticker.risk(Interval::OneDay, TimeRange::OneYear, Some("^GSPC")).await?;
1632    /// println!("Beta: {:?}", summary.beta);
1633    /// # Ok(())
1634    /// # }
1635    /// ```
1636    #[cfg(feature = "risk")]
1637    pub async fn risk(
1638        &self,
1639        interval: Interval,
1640        range: TimeRange,
1641        benchmark: Option<&str>,
1642    ) -> Result<crate::risk::RiskSummary> {
1643        let chart = self.chart(interval, range).await?;
1644
1645        let benchmark_returns = if let Some(sym) = benchmark {
1646            let bench_ticker = Ticker::new(sym).await?;
1647            let bench_chart = bench_ticker.chart(interval, range).await?;
1648            Some(crate::risk::candles_to_returns(&bench_chart.candles))
1649        } else {
1650            None
1651        };
1652
1653        Ok(crate::risk::compute_risk_summary(
1654            &chart.candles,
1655            benchmark_returns.as_deref(),
1656        ))
1657    }
1658
1659    // ========================================================================
1660    // SEC EDGAR
1661    // ========================================================================
1662
1663    /// Get SEC EDGAR filing history for this symbol.
1664    ///
1665    /// Returns company metadata and recent filings. Results are cached for
1666    /// the lifetime of this `Ticker` instance.
1667    ///
1668    /// Requires EDGAR to be initialized via `edgar::init(email)`.
1669    ///
1670    /// # Example
1671    ///
1672    /// ```no_run
1673    /// use finance_query::{Ticker, edgar};
1674    ///
1675    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1676    /// edgar::init("user@example.com")?;
1677    /// let ticker = Ticker::new("AAPL").await?;
1678    ///
1679    /// let submissions = ticker.edgar_submissions().await?;
1680    /// println!("Company: {:?}", submissions.name);
1681    /// # Ok(())
1682    /// # }
1683    /// ```
1684    pub async fn edgar_submissions(&self) -> Result<crate::models::edgar::EdgarSubmissions> {
1685        // Check cache
1686        {
1687            let cache = self.edgar_submissions_cache.read().await;
1688            if self.is_cache_fresh(cache.as_ref()) {
1689                return Ok(cache.as_ref().unwrap().value.clone());
1690            }
1691        }
1692
1693        // Fetch using singleton
1694        let cik = crate::edgar::resolve_cik(&self.symbol).await?;
1695        let submissions = crate::edgar::submissions(cik).await?;
1696
1697        // Only clone when caching is enabled
1698        if self.cache_ttl.is_some() {
1699            let mut cache = self.edgar_submissions_cache.write().await;
1700            *cache = Some(CacheEntry::new(submissions.clone()));
1701            Ok(submissions)
1702        } else {
1703            Ok(submissions)
1704        }
1705    }
1706
1707    /// Get SEC EDGAR company facts (structured XBRL financial data) for this symbol.
1708    ///
1709    /// Returns all extracted XBRL facts organized by taxonomy. Results are cached
1710    /// for the lifetime of this `Ticker` instance.
1711    ///
1712    /// Requires EDGAR to be initialized via `edgar::init(email)`.
1713    ///
1714    /// # Example
1715    ///
1716    /// ```no_run
1717    /// use finance_query::{Ticker, edgar};
1718    ///
1719    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1720    /// edgar::init("user@example.com")?;
1721    /// let ticker = Ticker::new("AAPL").await?;
1722    ///
1723    /// let facts = ticker.edgar_company_facts().await?;
1724    /// if let Some(revenue) = facts.get_us_gaap_fact("Revenue") {
1725    ///     println!("Revenue data points: {:?}", revenue.units.keys().collect::<Vec<_>>());
1726    /// }
1727    /// # Ok(())
1728    /// # }
1729    /// ```
1730    pub async fn edgar_company_facts(&self) -> Result<crate::models::edgar::CompanyFacts> {
1731        // Check cache
1732        {
1733            let cache = self.edgar_facts_cache.read().await;
1734            if self.is_cache_fresh(cache.as_ref()) {
1735                return Ok(cache.as_ref().unwrap().value.clone());
1736            }
1737        }
1738
1739        // Fetch using singleton
1740        let cik = crate::edgar::resolve_cik(&self.symbol).await?;
1741        let facts = crate::edgar::company_facts(cik).await?;
1742
1743        // Only clone when caching is enabled
1744        if self.cache_ttl.is_some() {
1745            let mut cache = self.edgar_facts_cache.write().await;
1746            *cache = Some(CacheEntry::new(facts.clone()));
1747            Ok(facts)
1748        } else {
1749            Ok(facts)
1750        }
1751    }
1752
1753    // ========================================================================
1754    // Cache Management
1755    // ========================================================================
1756
1757    /// Clear all cached data, forcing fresh fetches on next access.
1758    ///
1759    /// Use this when you need up-to-date data from a long-lived `Ticker` instance.
1760    ///
1761    /// # Example
1762    ///
1763    /// ```no_run
1764    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1765    /// use finance_query::Ticker;
1766    ///
1767    /// let ticker = Ticker::new("AAPL").await?;
1768    /// let quote = ticker.quote().await?; // fetches from API
1769    ///
1770    /// // ... some time later ...
1771    /// ticker.clear_cache().await;
1772    /// let fresh_quote = ticker.quote().await?; // fetches again
1773    /// # Ok(())
1774    /// # }
1775    /// ```
1776    pub async fn clear_cache(&self) {
1777        // Acquire all independent write locks in parallel
1778        tokio::join!(
1779            async {
1780                *self.quote_summary.write().await = None;
1781            },
1782            async {
1783                self.chart_cache.write().await.clear();
1784            },
1785            async {
1786                *self.events_cache.write().await = None;
1787            },
1788            async {
1789                *self.recommendations_cache.write().await = None;
1790            },
1791            async {
1792                *self.news_cache.write().await = None;
1793            },
1794            async {
1795                self.options_cache.write().await.clear();
1796            },
1797            async {
1798                self.financials_cache.write().await.clear();
1799            },
1800            async {
1801                *self.edgar_submissions_cache.write().await = None;
1802            },
1803            async {
1804                *self.edgar_facts_cache.write().await = None;
1805            },
1806            async {
1807                #[cfg(feature = "indicators")]
1808                self.indicators_cache.write().await.clear();
1809            },
1810        );
1811    }
1812
1813    /// Clear only the cached quote summary data.
1814    ///
1815    /// The next call to any quote accessor (e.g., `price()`, `financial_data()`)
1816    /// will re-fetch all quote modules from the API.
1817    pub async fn clear_quote_cache(&self) {
1818        *self.quote_summary.write().await = None;
1819    }
1820
1821    /// Clear only the cached chart and events data.
1822    ///
1823    /// The next call to `chart()`, `dividends()`, `splits()`, or `capital_gains()`
1824    /// will re-fetch from the API.
1825    pub async fn clear_chart_cache(&self) {
1826        tokio::join!(
1827            async {
1828                self.chart_cache.write().await.clear();
1829            },
1830            async {
1831                *self.events_cache.write().await = None;
1832            },
1833            async {
1834                #[cfg(feature = "indicators")]
1835                self.indicators_cache.write().await.clear();
1836            }
1837        );
1838    }
1839}