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: _k_slow,
1107                d_period,
1108            } => {
1109                // TODO: k_slow parameter not yet used (no smoothing applied)
1110                let highs = chart.high_prices();
1111                let lows = chart.low_prices();
1112                let closes = chart.close_prices();
1113                IndicatorResult::Stochastic(crate::indicators::stochastic(
1114                    &highs, &lows, &closes, k_period, d_period,
1115                )?)
1116            }
1117            Indicator::StochasticRsi {
1118                rsi_period,
1119                stoch_period,
1120                k_period: _k_period,
1121                d_period: _d_period,
1122            } => {
1123                // TODO: k_period/d_period smoothing not yet implemented
1124                let closes = chart.close_prices();
1125                IndicatorResult::Series(crate::indicators::stochastic_rsi(
1126                    &closes,
1127                    rsi_period,
1128                    stoch_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 {
1158                fast: _fast,
1159                slow: _slow,
1160            } => {
1161                // TODO: custom fast/slow periods not yet supported; uses defaults (5, 34)
1162                let highs = chart.high_prices();
1163                let lows = chart.low_prices();
1164                IndicatorResult::Series(crate::indicators::awesome_oscillator(&highs, &lows)?)
1165            }
1166            Indicator::CoppockCurve {
1167                wma_period: _wma_period,
1168                long_roc: _long_roc,
1169                short_roc: _short_roc,
1170            } => {
1171                // TODO: custom wma_period/long_roc/short_roc not yet supported; uses defaults (10, 14, 11)
1172                let closes = chart.close_prices();
1173                IndicatorResult::Series(crate::indicators::coppock_curve(&closes)?)
1174            }
1175            Indicator::Adx(period) => {
1176                let highs = chart.high_prices();
1177                let lows = chart.low_prices();
1178                let closes = chart.close_prices();
1179                IndicatorResult::Series(crate::indicators::adx(&highs, &lows, &closes, period)?)
1180            }
1181            Indicator::Aroon(period) => {
1182                let highs = chart.high_prices();
1183                let lows = chart.low_prices();
1184                IndicatorResult::Aroon(crate::indicators::aroon(&highs, &lows, period)?)
1185            }
1186            Indicator::Supertrend { period, multiplier } => {
1187                let highs = chart.high_prices();
1188                let lows = chart.low_prices();
1189                let closes = chart.close_prices();
1190                IndicatorResult::SuperTrend(crate::indicators::supertrend(
1191                    &highs, &lows, &closes, period, multiplier,
1192                )?)
1193            }
1194            Indicator::Ichimoku {
1195                conversion: _conversion,
1196                base: _base,
1197                lagging: _lagging,
1198                displacement: _displacement,
1199            } => {
1200                // TODO: custom periods not yet supported; uses traditional values (9, 26, 52, 26)
1201                let highs = chart.high_prices();
1202                let lows = chart.low_prices();
1203                let closes = chart.close_prices();
1204                IndicatorResult::Ichimoku(crate::indicators::ichimoku(&highs, &lows, &closes)?)
1205            }
1206            Indicator::ParabolicSar { step, max } => {
1207                let highs = chart.high_prices();
1208                let lows = chart.low_prices();
1209                let closes = chart.close_prices();
1210                IndicatorResult::Series(crate::indicators::parabolic_sar(
1211                    &highs, &lows, &closes, step, max,
1212                )?)
1213            }
1214            Indicator::BullBearPower(_period) => {
1215                // TODO: period parameter not yet used; currently uses EMA(13) internally
1216                let highs = chart.high_prices();
1217                let lows = chart.low_prices();
1218                let closes = chart.close_prices();
1219                IndicatorResult::BullBearPower(crate::indicators::bull_bear_power(
1220                    &highs, &lows, &closes,
1221                )?)
1222            }
1223            Indicator::ElderRay(_period) => {
1224                // TODO: period parameter not yet used; currently uses EMA(13) internally
1225                let highs = chart.high_prices();
1226                let lows = chart.low_prices();
1227                let closes = chart.close_prices();
1228                IndicatorResult::ElderRay(crate::indicators::elder_ray(&highs, &lows, &closes)?)
1229            }
1230            Indicator::KeltnerChannels {
1231                period,
1232                multiplier,
1233                atr_period,
1234            } => {
1235                let highs = chart.high_prices();
1236                let lows = chart.low_prices();
1237                let closes = chart.close_prices();
1238                IndicatorResult::Keltner(crate::indicators::keltner_channels(
1239                    &highs, &lows, &closes, period, atr_period, multiplier,
1240                )?)
1241            }
1242            Indicator::DonchianChannels(period) => {
1243                let highs = chart.high_prices();
1244                let lows = chart.low_prices();
1245                IndicatorResult::Donchian(crate::indicators::donchian_channels(
1246                    &highs, &lows, period,
1247                )?)
1248            }
1249            Indicator::TrueRange => {
1250                let highs = chart.high_prices();
1251                let lows = chart.low_prices();
1252                let closes = chart.close_prices();
1253                IndicatorResult::Series(crate::indicators::true_range(&highs, &lows, &closes)?)
1254            }
1255            Indicator::ChoppinessIndex(period) => {
1256                let highs = chart.high_prices();
1257                let lows = chart.low_prices();
1258                let closes = chart.close_prices();
1259                IndicatorResult::Series(crate::indicators::choppiness_index(
1260                    &highs, &lows, &closes, period,
1261                )?)
1262            }
1263            Indicator::Mfi(period) => {
1264                let highs = chart.high_prices();
1265                let lows = chart.low_prices();
1266                let closes = chart.close_prices();
1267                let volumes = chart.volumes();
1268                IndicatorResult::Series(crate::indicators::mfi(
1269                    &highs, &lows, &closes, &volumes, period,
1270                )?)
1271            }
1272            Indicator::Cmf(period) => {
1273                let highs = chart.high_prices();
1274                let lows = chart.low_prices();
1275                let closes = chart.close_prices();
1276                let volumes = chart.volumes();
1277                IndicatorResult::Series(crate::indicators::cmf(
1278                    &highs, &lows, &closes, &volumes, period,
1279                )?)
1280            }
1281            Indicator::ChaikinOscillator => {
1282                let highs = chart.high_prices();
1283                let lows = chart.low_prices();
1284                let closes = chart.close_prices();
1285                let volumes = chart.volumes();
1286                IndicatorResult::Series(crate::indicators::chaikin_oscillator(
1287                    &highs, &lows, &closes, &volumes,
1288                )?)
1289            }
1290            Indicator::AccumulationDistribution => {
1291                let highs = chart.high_prices();
1292                let lows = chart.low_prices();
1293                let closes = chart.close_prices();
1294                let volumes = chart.volumes();
1295                IndicatorResult::Series(crate::indicators::accumulation_distribution(
1296                    &highs, &lows, &closes, &volumes,
1297                )?)
1298            }
1299            Indicator::BalanceOfPower(period) => {
1300                let opens = chart.open_prices();
1301                let highs = chart.high_prices();
1302                let lows = chart.low_prices();
1303                let closes = chart.close_prices();
1304                IndicatorResult::Series(crate::indicators::balance_of_power(
1305                    &opens, &highs, &lows, &closes, period,
1306                )?)
1307            }
1308        };
1309
1310        Ok(result)
1311    }
1312
1313    /// Get analyst recommendations
1314    pub async fn recommendations(&self, limit: u32) -> Result<Recommendation> {
1315        // Check cache (always fetches max from server, truncated to limit on return)
1316        {
1317            let cache = self.recommendations_cache.read().await;
1318            if let Some(entry) = cache.as_ref()
1319                && self.is_cache_fresh(Some(entry))
1320            {
1321                return Ok(self.build_recommendation_with_limit(&entry.value, limit));
1322            }
1323        }
1324
1325        // Always fetch server maximum (no limit restriction to maximize cache utility)
1326        let json = self.client.get_recommendations(&self.symbol, 15).await?;
1327        let response = RecommendationResponse::from_json(json).map_err(|e| {
1328            crate::error::FinanceError::ResponseStructureError {
1329                field: "finance".to_string(),
1330                context: e.to_string(),
1331            }
1332        })?;
1333
1334        // Cache full response, return truncated result
1335        if self.cache_ttl.is_some() {
1336            let mut cache = self.recommendations_cache.write().await;
1337            *cache = Some(CacheEntry::new(response));
1338            let entry = cache.as_ref().unwrap();
1339            return Ok(self.build_recommendation_with_limit(&entry.value, limit));
1340        }
1341
1342        Ok(self.build_recommendation_with_limit(&response, limit))
1343    }
1344
1345    /// Get financial statements
1346    ///
1347    /// # Arguments
1348    ///
1349    /// * `statement_type` - Type of statement (Income, Balance, CashFlow)
1350    /// * `frequency` - Annual or Quarterly
1351    ///
1352    /// # Example
1353    ///
1354    /// ```no_run
1355    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1356    /// use finance_query::{Ticker, Frequency, StatementType};
1357    ///
1358    /// let ticker = Ticker::new("AAPL").await?;
1359    /// let income = ticker.financials(StatementType::Income, Frequency::Annual).await?;
1360    /// println!("Revenue: {:?}", income.statement.get("TotalRevenue"));
1361    /// # Ok(())
1362    /// # }
1363    /// ```
1364    pub async fn financials(
1365        &self,
1366        statement_type: crate::constants::StatementType,
1367        frequency: crate::constants::Frequency,
1368    ) -> Result<FinancialStatement> {
1369        let cache_key = (statement_type, frequency);
1370
1371        // Check cache
1372        {
1373            let cache = self.financials_cache.read().await;
1374            if let Some(entry) = cache.get(&cache_key)
1375                && self.is_cache_fresh(Some(entry))
1376            {
1377                return Ok(entry.value.clone());
1378            }
1379        }
1380
1381        // Fetch financials
1382        let financials = self
1383            .client
1384            .get_financials(&self.symbol, statement_type, frequency)
1385            .await?;
1386
1387        // Only clone when caching is enabled
1388        if self.cache_ttl.is_some() {
1389            let mut cache = self.financials_cache.write().await;
1390            self.cache_insert(&mut cache, cache_key, financials.clone());
1391            Ok(financials)
1392        } else {
1393            Ok(financials)
1394        }
1395    }
1396
1397    /// Get news articles for this symbol
1398    ///
1399    /// # Example
1400    ///
1401    /// ```no_run
1402    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1403    /// use finance_query::Ticker;
1404    ///
1405    /// let ticker = Ticker::new("AAPL").await?;
1406    /// let news = ticker.news().await?;
1407    /// for article in news {
1408    ///     println!("{}: {}", article.source, article.title);
1409    /// }
1410    /// # Ok(())
1411    /// # }
1412    /// ```
1413    pub async fn news(&self) -> Result<Vec<crate::models::news::News>> {
1414        // Check cache
1415        {
1416            let cache = self.news_cache.read().await;
1417            if self.is_cache_fresh(cache.as_ref()) {
1418                return Ok(cache.as_ref().unwrap().value.clone());
1419            }
1420        }
1421
1422        // Fetch news
1423        let news = crate::scrapers::stockanalysis::scrape_symbol_news(&self.symbol).await?;
1424
1425        // Only clone when caching is enabled
1426        if self.cache_ttl.is_some() {
1427            let mut cache = self.news_cache.write().await;
1428            *cache = Some(CacheEntry::new(news.clone()));
1429            Ok(news)
1430        } else {
1431            Ok(news)
1432        }
1433    }
1434
1435    /// Get options chain
1436    pub async fn options(&self, date: Option<i64>) -> Result<Options> {
1437        // Check cache
1438        {
1439            let cache = self.options_cache.read().await;
1440            if let Some(entry) = cache.get(&date)
1441                && self.is_cache_fresh(Some(entry))
1442            {
1443                return Ok(entry.value.clone());
1444            }
1445        }
1446
1447        // Fetch options
1448        let json = self.client.get_options(&self.symbol, date).await?;
1449        let options: Options = serde_json::from_value(json).map_err(|e| {
1450            crate::error::FinanceError::ResponseStructureError {
1451                field: "options".to_string(),
1452                context: e.to_string(),
1453            }
1454        })?;
1455
1456        // Only clone when caching is enabled
1457        if self.cache_ttl.is_some() {
1458            let mut cache = self.options_cache.write().await;
1459            self.cache_insert(&mut cache, date, options.clone());
1460            Ok(options)
1461        } else {
1462            Ok(options)
1463        }
1464    }
1465
1466    /// Run a backtest with the given strategy and configuration.
1467    ///
1468    /// # Arguments
1469    ///
1470    /// * `strategy` - Trading strategy implementing the Strategy trait
1471    /// * `interval` - Candle interval (1d, 1h, etc.)
1472    /// * `range` - Time range for historical data
1473    /// * `config` - Backtest configuration (optional, uses defaults if None)
1474    ///
1475    /// # Example
1476    ///
1477    /// ```no_run
1478    /// use finance_query::{Ticker, Interval, TimeRange};
1479    /// use finance_query::backtesting::{SmaCrossover, BacktestConfig};
1480    ///
1481    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1482    /// let ticker = Ticker::new("AAPL").await?;
1483    ///
1484    /// // Simple backtest with defaults
1485    /// let strategy = SmaCrossover::new(10, 20);
1486    /// let result = ticker.backtest(
1487    ///     strategy,
1488    ///     Interval::OneDay,
1489    ///     TimeRange::OneYear,
1490    ///     None,
1491    /// ).await?;
1492    ///
1493    /// println!("{}", result.summary());
1494    /// println!("Total trades: {}", result.trades.len());
1495    ///
1496    /// // With custom config
1497    /// let config = BacktestConfig::builder()
1498    ///     .initial_capital(50_000.0)
1499    ///     .commission_pct(0.001)
1500    ///     .stop_loss_pct(0.05)
1501    ///     .allow_short(true)
1502    ///     .build()?;
1503    ///
1504    /// let result = ticker.backtest(
1505    ///     SmaCrossover::new(5, 20).with_short(true),
1506    ///     Interval::OneDay,
1507    ///     TimeRange::TwoYears,
1508    ///     Some(config),
1509    /// ).await?;
1510    /// # Ok(())
1511    /// # }
1512    /// ```
1513    #[cfg(feature = "backtesting")]
1514    pub async fn backtest<S: crate::backtesting::Strategy>(
1515        &self,
1516        strategy: S,
1517        interval: Interval,
1518        range: TimeRange,
1519        config: Option<crate::backtesting::BacktestConfig>,
1520    ) -> crate::backtesting::Result<crate::backtesting::BacktestResult> {
1521        use crate::backtesting::BacktestEngine;
1522
1523        let config = config.unwrap_or_default();
1524        config.validate()?;
1525
1526        // Fetch chart data
1527        let chart = self
1528            .chart(interval, range)
1529            .await
1530            .map_err(|e| crate::backtesting::BacktestError::ChartError(e.to_string()))?;
1531
1532        // Run backtest engine
1533        let engine = BacktestEngine::new(config);
1534        engine.run(&self.symbol, &chart.candles, strategy)
1535    }
1536
1537    // ========================================================================
1538    // Risk Analytics
1539    // ========================================================================
1540
1541    /// Compute a risk summary for this symbol.
1542    ///
1543    /// Requires the **`risk`** feature flag.
1544    ///
1545    /// Calculates Value at Risk, Sharpe/Sortino/Calmar ratios, and maximum drawdown
1546    /// from close-to-close returns derived from the requested chart data.
1547    ///
1548    /// # Arguments
1549    ///
1550    /// * `interval` - Candle interval (use `Interval::OneDay` for daily risk metrics)
1551    /// * `range` - Historical range to analyse
1552    /// * `benchmark` - Optional symbol to use as the benchmark for beta calculation
1553    ///
1554    /// # Example
1555    ///
1556    /// ```no_run
1557    /// use finance_query::{Ticker, Interval, TimeRange};
1558    ///
1559    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1560    /// let ticker = Ticker::new("AAPL").await?;
1561    ///
1562    /// // Risk vs no benchmark
1563    /// let summary = ticker.risk(Interval::OneDay, TimeRange::OneYear, None).await?;
1564    /// println!("VaR 95%:      {:.2}%", summary.var_95 * 100.0);
1565    /// println!("Max drawdown: {:.2}%", summary.max_drawdown * 100.0);
1566    ///
1567    /// // Risk with S&P 500 as benchmark
1568    /// let summary = ticker.risk(Interval::OneDay, TimeRange::OneYear, Some("^GSPC")).await?;
1569    /// println!("Beta: {:?}", summary.beta);
1570    /// # Ok(())
1571    /// # }
1572    /// ```
1573    #[cfg(feature = "risk")]
1574    pub async fn risk(
1575        &self,
1576        interval: Interval,
1577        range: TimeRange,
1578        benchmark: Option<&str>,
1579    ) -> Result<crate::risk::RiskSummary> {
1580        let chart = self.chart(interval, range).await?;
1581
1582        let benchmark_returns = if let Some(sym) = benchmark {
1583            let bench_ticker = Ticker::new(sym).await?;
1584            let bench_chart = bench_ticker.chart(interval, range).await?;
1585            Some(crate::risk::candles_to_returns(&bench_chart.candles))
1586        } else {
1587            None
1588        };
1589
1590        Ok(crate::risk::compute_risk_summary(
1591            &chart.candles,
1592            benchmark_returns.as_deref(),
1593        ))
1594    }
1595
1596    // ========================================================================
1597    // SEC EDGAR
1598    // ========================================================================
1599
1600    /// Get SEC EDGAR filing history for this symbol.
1601    ///
1602    /// Returns company metadata and recent filings. Results are cached for
1603    /// the lifetime of this `Ticker` instance.
1604    ///
1605    /// Requires EDGAR to be initialized via `edgar::init(email)`.
1606    ///
1607    /// # Example
1608    ///
1609    /// ```no_run
1610    /// use finance_query::{Ticker, edgar};
1611    ///
1612    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1613    /// edgar::init("user@example.com")?;
1614    /// let ticker = Ticker::new("AAPL").await?;
1615    ///
1616    /// let submissions = ticker.edgar_submissions().await?;
1617    /// println!("Company: {:?}", submissions.name);
1618    /// # Ok(())
1619    /// # }
1620    /// ```
1621    pub async fn edgar_submissions(&self) -> Result<crate::models::edgar::EdgarSubmissions> {
1622        // Check cache
1623        {
1624            let cache = self.edgar_submissions_cache.read().await;
1625            if self.is_cache_fresh(cache.as_ref()) {
1626                return Ok(cache.as_ref().unwrap().value.clone());
1627            }
1628        }
1629
1630        // Fetch using singleton
1631        let cik = crate::edgar::resolve_cik(&self.symbol).await?;
1632        let submissions = crate::edgar::submissions(cik).await?;
1633
1634        // Only clone when caching is enabled
1635        if self.cache_ttl.is_some() {
1636            let mut cache = self.edgar_submissions_cache.write().await;
1637            *cache = Some(CacheEntry::new(submissions.clone()));
1638            Ok(submissions)
1639        } else {
1640            Ok(submissions)
1641        }
1642    }
1643
1644    /// Get SEC EDGAR company facts (structured XBRL financial data) for this symbol.
1645    ///
1646    /// Returns all extracted XBRL facts organized by taxonomy. Results are cached
1647    /// for the lifetime of this `Ticker` instance.
1648    ///
1649    /// Requires EDGAR to be initialized via `edgar::init(email)`.
1650    ///
1651    /// # Example
1652    ///
1653    /// ```no_run
1654    /// use finance_query::{Ticker, edgar};
1655    ///
1656    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1657    /// edgar::init("user@example.com")?;
1658    /// let ticker = Ticker::new("AAPL").await?;
1659    ///
1660    /// let facts = ticker.edgar_company_facts().await?;
1661    /// if let Some(revenue) = facts.get_us_gaap_fact("Revenue") {
1662    ///     println!("Revenue data points: {:?}", revenue.units.keys().collect::<Vec<_>>());
1663    /// }
1664    /// # Ok(())
1665    /// # }
1666    /// ```
1667    pub async fn edgar_company_facts(&self) -> Result<crate::models::edgar::CompanyFacts> {
1668        // Check cache
1669        {
1670            let cache = self.edgar_facts_cache.read().await;
1671            if self.is_cache_fresh(cache.as_ref()) {
1672                return Ok(cache.as_ref().unwrap().value.clone());
1673            }
1674        }
1675
1676        // Fetch using singleton
1677        let cik = crate::edgar::resolve_cik(&self.symbol).await?;
1678        let facts = crate::edgar::company_facts(cik).await?;
1679
1680        // Only clone when caching is enabled
1681        if self.cache_ttl.is_some() {
1682            let mut cache = self.edgar_facts_cache.write().await;
1683            *cache = Some(CacheEntry::new(facts.clone()));
1684            Ok(facts)
1685        } else {
1686            Ok(facts)
1687        }
1688    }
1689
1690    // ========================================================================
1691    // Cache Management
1692    // ========================================================================
1693
1694    /// Clear all cached data, forcing fresh fetches on next access.
1695    ///
1696    /// Use this when you need up-to-date data from a long-lived `Ticker` instance.
1697    ///
1698    /// # Example
1699    ///
1700    /// ```no_run
1701    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1702    /// use finance_query::Ticker;
1703    ///
1704    /// let ticker = Ticker::new("AAPL").await?;
1705    /// let quote = ticker.quote().await?; // fetches from API
1706    ///
1707    /// // ... some time later ...
1708    /// ticker.clear_cache().await;
1709    /// let fresh_quote = ticker.quote().await?; // fetches again
1710    /// # Ok(())
1711    /// # }
1712    /// ```
1713    pub async fn clear_cache(&self) {
1714        // Acquire all independent write locks in parallel
1715        tokio::join!(
1716            async {
1717                *self.quote_summary.write().await = None;
1718            },
1719            async {
1720                self.chart_cache.write().await.clear();
1721            },
1722            async {
1723                *self.events_cache.write().await = None;
1724            },
1725            async {
1726                *self.recommendations_cache.write().await = None;
1727            },
1728            async {
1729                *self.news_cache.write().await = None;
1730            },
1731            async {
1732                self.options_cache.write().await.clear();
1733            },
1734            async {
1735                self.financials_cache.write().await.clear();
1736            },
1737            async {
1738                *self.edgar_submissions_cache.write().await = None;
1739            },
1740            async {
1741                *self.edgar_facts_cache.write().await = None;
1742            },
1743            async {
1744                #[cfg(feature = "indicators")]
1745                self.indicators_cache.write().await.clear();
1746            },
1747        );
1748    }
1749
1750    /// Clear only the cached quote summary data.
1751    ///
1752    /// The next call to any quote accessor (e.g., `price()`, `financial_data()`)
1753    /// will re-fetch all quote modules from the API.
1754    pub async fn clear_quote_cache(&self) {
1755        *self.quote_summary.write().await = None;
1756    }
1757
1758    /// Clear only the cached chart and events data.
1759    ///
1760    /// The next call to `chart()`, `dividends()`, `splits()`, or `capital_gains()`
1761    /// will re-fetch from the API.
1762    pub async fn clear_chart_cache(&self) {
1763        tokio::join!(
1764            async {
1765                self.chart_cache.write().await.clear();
1766            },
1767            async {
1768                *self.events_cache.write().await = None;
1769            },
1770            async {
1771                #[cfg(feature = "indicators")]
1772                self.indicators_cache.write().await.clear();
1773            }
1774        );
1775    }
1776}