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        // For intraday intervals with ranges that exceed Yahoo's native support, bypass
631        // get_chart entirely and use chart_range so chunking is applied automatically.
632        if let Some((max_secs, native_ranges)) = crate::endpoints::chart::intraday_limit(interval)
633            && !native_ranges.contains(&range)
634        {
635            // Small buffer so the request window is strictly inside the hard limit.
636            const RANGE_BOUNDARY_BUFFER_SECS: i64 = 5;
637            let end = crate::utils::now_unix_secs();
638            let start = end - max_secs + RANGE_BOUNDARY_BUFFER_SECS;
639            return self.chart_range(interval, start, end).await;
640        }
641
642        // Fetch from Yahoo
643        let json = self.client.get_chart(&self.symbol, interval, range).await?;
644        let chart_result = Self::parse_chart_result(json, &self.symbol)?;
645
646        // Always update events when we have fresh data from Yahoo
647        if let Some(events) = &chart_result.events {
648            let mut events_cache = self.events_cache.write().await;
649            *events_cache = Some(CacheEntry::new(events.clone()));
650        }
651
652        // Materialize Chart from raw result — this is the only place to_candles() runs
653        let chart = Chart {
654            symbol: self.symbol.to_string(),
655            meta: chart_result.meta.clone(),
656            candles: chart_result.to_candles(),
657            interval: Some(interval),
658            range: Some(range),
659        };
660
661        // Only clone when caching is enabled to avoid unnecessary allocations
662        if self.cache_ttl.is_some() {
663            let mut cache = self.chart_cache.write().await;
664            self.cache_insert(&mut cache, (interval, range), chart.clone());
665            Ok(chart)
666        } else {
667            Ok(chart)
668        }
669    }
670
671    /// Parse a ChartResult from raw JSON, returning a descriptive error on failure.
672    fn parse_chart_result(
673        json: serde_json::Value,
674        symbol: &str,
675    ) -> Result<crate::models::chart::result::ChartResult> {
676        let response = ChartResponse::from_json(json).map_err(|e| {
677            crate::error::FinanceError::ResponseStructureError {
678                field: "chart".to_string(),
679                context: e.to_string(),
680            }
681        })?;
682
683        let results =
684            response
685                .chart
686                .result
687                .ok_or_else(|| crate::error::FinanceError::SymbolNotFound {
688                    symbol: Some(symbol.to_string()),
689                    context: "Chart data not found".to_string(),
690                })?;
691
692        results
693            .into_iter()
694            .next()
695            .ok_or_else(|| crate::error::FinanceError::SymbolNotFound {
696                symbol: Some(symbol.to_string()),
697                context: "Chart data empty".to_string(),
698            })
699    }
700
701    /// Get historical chart data for a custom date range.
702    ///
703    /// Unlike [`chart()`](Self::chart) which uses predefined time ranges,
704    /// this method accepts absolute start/end timestamps for precise date control.
705    ///
706    /// Results are **not cached** since custom ranges have unbounded key space.
707    /// For intraday intervals whose span exceeds Yahoo's per-request limit, the
708    /// range is automatically split into parallel chunks and merged transparently.
709    ///
710    /// # Arguments
711    ///
712    /// * `interval` - Time interval between data points
713    /// * `start` - Start date as Unix timestamp (seconds since epoch)
714    /// * `end` - End date as Unix timestamp (seconds since epoch)
715    ///
716    /// # Example
717    ///
718    /// ```no_run
719    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
720    /// use finance_query::{Ticker, Interval};
721    /// use chrono::NaiveDate;
722    ///
723    /// let ticker = Ticker::new("AAPL").await?;
724    ///
725    /// // Q3 2024
726    /// let start = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap()
727    ///     .and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp();
728    /// let end = NaiveDate::from_ymd_opt(2024, 9, 30).unwrap()
729    ///     .and_hms_opt(23, 59, 59).unwrap().and_utc().timestamp();
730    ///
731    /// let chart = ticker.chart_range(Interval::OneDay, start, end).await?;
732    /// println!("Q3 2024 candles: {}", chart.candles.len());
733    /// # Ok(())
734    /// # }
735    /// ```
736    pub async fn chart_range(&self, interval: Interval, start: i64, end: i64) -> Result<Chart> {
737        if start >= end {
738            return Err(crate::error::FinanceError::InvalidParameter {
739                param: "end".to_string(),
740                reason: format!("`end` ({end}) must be greater than `start` ({start})"),
741            });
742        }
743
744        // Auto-chunk when the span exceeds Yahoo's per-request limit for this interval
745        if let Some(chunk_secs) = crate::endpoints::chart::intraday_chunk_secs(interval)
746            && end - start > chunk_secs
747        {
748            return self
749                .chart_range_chunked(interval, start, end, chunk_secs)
750                .await;
751        }
752
753        let json = self
754            .client
755            .get_chart_range(&self.symbol, interval, start, end)
756            .await?;
757        let chart_result = Self::parse_chart_result(json, &self.symbol)?;
758
759        // Always update events when we have fresh data from Yahoo
760        if let Some(events) = &chart_result.events {
761            let mut events_cache = self.events_cache.write().await;
762            *events_cache = Some(CacheEntry::new(events.clone()));
763        }
764
765        Ok(Chart {
766            symbol: self.symbol.to_string(),
767            meta: chart_result.meta.clone(),
768            candles: chart_result.to_candles(),
769            interval: Some(interval),
770            range: None,
771        })
772    }
773
774    /// Fetch a large intraday date range by splitting it into parallel chunks.
775    ///
776    /// Yahoo Finance rejects requests with a span > ~8 days for sub-hour intervals.
777    /// This splits the window into `chunk_secs`-sized slices, fetches them concurrently,
778    /// and merges the candles (sorted, deduplicated by timestamp).
779    async fn chart_range_chunked(
780        &self,
781        interval: Interval,
782        start: i64,
783        end: i64,
784        chunk_secs: i64,
785    ) -> Result<Chart> {
786        // Build chunk boundaries
787        let mut chunks: Vec<(i64, i64)> = Vec::new();
788        let mut s = start;
789        while s < end {
790            chunks.push((s, (s + chunk_secs).min(end)));
791            s += chunk_secs;
792        }
793
794        // Fetch all chunks in parallel
795        let fetches: Vec<_> = chunks
796            .iter()
797            .map(|&(s, e)| self.client.get_chart_range(&self.symbol, interval, s, e))
798            .collect();
799
800        let results = futures::future::join_all(fetches).await;
801
802        // Parse and merge
803        let mut all_candles: Vec<crate::models::chart::Candle> = Vec::new();
804        let mut base_meta: Option<crate::models::chart::ChartMeta> = None;
805        let mut accumulated_events: Option<crate::models::chart::events::ChartEvents> = None;
806
807        for result in results {
808            // Skip empty chunks (e.g. weekend/holiday windows) rather than failing the whole call
809            let json = match result {
810                Ok(j) => j,
811                Err(e) => {
812                    tracing::warn!("Skipping failed chunk for {}: {}", self.symbol, e);
813                    continue;
814                }
815            };
816            let chart_result = match Self::parse_chart_result(json, &self.symbol) {
817                Ok(r) => r,
818                Err(e) => {
819                    tracing::warn!("Skipping unparseable chunk for {}: {}", self.symbol, e);
820                    continue;
821                }
822            };
823
824            if base_meta.is_none() {
825                base_meta = Some(chart_result.meta.clone());
826            }
827
828            // Accumulate events across all chunks — each chunk covers only its own window,
829            // so events (dividends, splits) may appear in any chunk.
830            let candles = chart_result.to_candles();
831            if let Some(events) = chart_result.events {
832                match &mut accumulated_events {
833                    None => accumulated_events = Some(events),
834                    Some(acc) => {
835                        acc.dividends.extend(events.dividends);
836                        acc.splits.extend(events.splits);
837                        acc.capital_gains.extend(events.capital_gains);
838                    }
839                }
840            }
841            all_candles.extend(candles);
842        }
843
844        // Write merged events cache once after all chunks are processed
845        if let Some(events) = accumulated_events {
846            let mut events_cache = self.events_cache.write().await;
847            *events_cache = Some(CacheEntry::new(events));
848        }
849
850        // Sort and deduplicate (chunk boundaries may overlap by one candle)
851        all_candles.sort_unstable_by_key(|c| c.timestamp);
852        all_candles.dedup_by_key(|c| c.timestamp);
853
854        let meta = base_meta.ok_or_else(|| crate::error::FinanceError::SymbolNotFound {
855            symbol: Some(self.symbol.to_string()),
856            context: "No chart data returned across all chunks".to_string(),
857        })?;
858
859        Ok(Chart {
860            symbol: self.symbol.to_string(),
861            meta,
862            candles: all_candles,
863            interval: Some(interval),
864            range: None,
865        })
866    }
867
868    /// Ensures events data is loaded (fetches events only if not cached)
869    async fn ensure_events_loaded(&self) -> Result<()> {
870        // Quick read check
871        {
872            let cache = self.events_cache.read().await;
873            if self.is_cache_fresh(cache.as_ref()) {
874                return Ok(());
875            }
876        }
877
878        // Fetch events using max range with 1d interval to get all historical events
879        // Using 1d interval minimizes candle count compared to shorter intervals
880        let json = crate::endpoints::chart::fetch(
881            &self.client,
882            &self.symbol,
883            Interval::OneDay,
884            TimeRange::Max,
885        )
886        .await?;
887        let chart_result = Self::parse_chart_result(json, &self.symbol)?;
888
889        // Write to events cache unconditionally for temporary storage during this method
890        // Note: when cache_ttl is None, is_cache_fresh() returns false, so this will
891        // be refetched on the next call to dividends()/splits()/capital_gains().
892        // Cache empty ChartEvents when Yahoo returns no events to prevent infinite refetch loops
893        let mut events_cache = self.events_cache.write().await;
894        *events_cache = Some(CacheEntry::new(chart_result.events.unwrap_or_default()));
895
896        Ok(())
897    }
898
899    /// Get dividend history
900    ///
901    /// Returns historical dividend payments sorted by date.
902    /// Events are lazily loaded (fetched once, then filtered by range).
903    ///
904    /// # Arguments
905    ///
906    /// * `range` - Time range to filter dividends
907    ///
908    /// # Example
909    ///
910    /// ```no_run
911    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
912    /// use finance_query::{Ticker, TimeRange};
913    ///
914    /// let ticker = Ticker::new("AAPL").await?;
915    ///
916    /// // Get all dividends
917    /// let all = ticker.dividends(TimeRange::Max).await?;
918    ///
919    /// // Get last year's dividends
920    /// let recent = ticker.dividends(TimeRange::OneYear).await?;
921    /// # Ok(())
922    /// # }
923    /// ```
924    pub async fn dividends(&self, range: TimeRange) -> Result<Vec<Dividend>> {
925        self.ensure_events_loaded().await?;
926
927        let cache = self.events_cache.read().await;
928        let all = cache
929            .as_ref()
930            .map(|e| e.value.to_dividends())
931            .unwrap_or_default();
932
933        Ok(filter_by_range(all, range))
934    }
935
936    /// Compute dividend analytics for the requested time range.
937    ///
938    /// Calculates statistics on the dividend history: total paid, payment count,
939    /// average payment, and Compound Annual Growth Rate (CAGR).
940    ///
941    /// **CAGR note:** requires at least two payments spanning at least one calendar year.
942    ///
943    /// # Arguments
944    ///
945    /// * `range` - Time range to analyse
946    ///
947    /// # Example
948    ///
949    /// ```no_run
950    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
951    /// use finance_query::{Ticker, TimeRange};
952    ///
953    /// let ticker = Ticker::new("AAPL").await?;
954    /// let analytics = ticker.dividend_analytics(TimeRange::FiveYears).await?;
955    ///
956    /// println!("Total paid: ${:.2}", analytics.total_paid);
957    /// println!("Payments:   {}", analytics.payment_count);
958    /// if let Some(cagr) = analytics.cagr {
959    ///     println!("CAGR:       {:.1}%", cagr * 100.0);
960    /// }
961    /// # Ok(())
962    /// # }
963    /// ```
964    pub async fn dividend_analytics(
965        &self,
966        range: TimeRange,
967    ) -> Result<crate::models::chart::DividendAnalytics> {
968        let dividends = self.dividends(range).await?;
969        Ok(crate::models::chart::DividendAnalytics::from_dividends(
970            &dividends,
971        ))
972    }
973
974    /// Get stock split history
975    ///
976    /// Returns historical stock splits sorted by date.
977    /// Events are lazily loaded (fetched once, then filtered by range).
978    ///
979    /// # Arguments
980    ///
981    /// * `range` - Time range to filter splits
982    ///
983    /// # Example
984    ///
985    /// ```no_run
986    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
987    /// use finance_query::{Ticker, TimeRange};
988    ///
989    /// let ticker = Ticker::new("NVDA").await?;
990    ///
991    /// // Get all splits
992    /// let all = ticker.splits(TimeRange::Max).await?;
993    ///
994    /// // Get last 5 years
995    /// let recent = ticker.splits(TimeRange::FiveYears).await?;
996    /// # Ok(())
997    /// # }
998    /// ```
999    pub async fn splits(&self, range: TimeRange) -> Result<Vec<Split>> {
1000        self.ensure_events_loaded().await?;
1001
1002        let cache = self.events_cache.read().await;
1003        let all = cache
1004            .as_ref()
1005            .map(|e| e.value.to_splits())
1006            .unwrap_or_default();
1007
1008        Ok(filter_by_range(all, range))
1009    }
1010
1011    /// Get capital gains distribution history
1012    ///
1013    /// Returns historical capital gain distributions sorted by date.
1014    /// This is primarily relevant for mutual funds and ETFs.
1015    /// Events are lazily loaded (fetched once, then filtered by range).
1016    ///
1017    /// # Arguments
1018    ///
1019    /// * `range` - Time range to filter capital gains
1020    ///
1021    /// # Example
1022    ///
1023    /// ```no_run
1024    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1025    /// use finance_query::{Ticker, TimeRange};
1026    ///
1027    /// let ticker = Ticker::new("VFIAX").await?;
1028    ///
1029    /// // Get all capital gains
1030    /// let all = ticker.capital_gains(TimeRange::Max).await?;
1031    ///
1032    /// // Get last 2 years
1033    /// let recent = ticker.capital_gains(TimeRange::TwoYears).await?;
1034    /// # Ok(())
1035    /// # }
1036    /// ```
1037    pub async fn capital_gains(&self, range: TimeRange) -> Result<Vec<CapitalGain>> {
1038        self.ensure_events_loaded().await?;
1039
1040        let cache = self.events_cache.read().await;
1041        let all = cache
1042            .as_ref()
1043            .map(|e| e.value.to_capital_gains())
1044            .unwrap_or_default();
1045
1046        Ok(filter_by_range(all, range))
1047    }
1048
1049    /// Calculate all technical indicators from chart data
1050    ///
1051    /// # Arguments
1052    ///
1053    /// * `interval` - The time interval for each candle
1054    /// * `range` - The time range to fetch data for
1055    ///
1056    /// # Returns
1057    ///
1058    /// Returns `IndicatorsSummary` containing all calculated indicators.
1059    ///
1060    /// # Example
1061    ///
1062    /// ```no_run
1063    /// use finance_query::{Ticker, Interval, TimeRange};
1064    ///
1065    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1066    /// let ticker = Ticker::new("AAPL").await?;
1067    /// let indicators = ticker.indicators(Interval::OneDay, TimeRange::OneYear).await?;
1068    ///
1069    /// println!("RSI(14): {:?}", indicators.rsi_14);
1070    /// println!("MACD: {:?}", indicators.macd);
1071    /// # Ok(())
1072    /// # }
1073    /// ```
1074    #[cfg(feature = "indicators")]
1075    pub async fn indicators(
1076        &self,
1077        interval: Interval,
1078        range: TimeRange,
1079    ) -> Result<crate::indicators::IndicatorsSummary> {
1080        // Check cache first (read lock)
1081        {
1082            let cache = self.indicators_cache.read().await;
1083            if let Some(entry) = cache.get(&(interval, range))
1084                && self.is_cache_fresh(Some(entry))
1085            {
1086                return Ok(entry.value.clone());
1087            }
1088        }
1089
1090        // Fetch chart data (this is also cached!)
1091        let chart = self.chart(interval, range).await?;
1092
1093        // Calculate indicators from candles
1094        let indicators = crate::indicators::summary::calculate_indicators(&chart.candles);
1095
1096        // Only clone when caching is enabled
1097        if self.cache_ttl.is_some() {
1098            let mut cache = self.indicators_cache.write().await;
1099            self.cache_insert(&mut cache, (interval, range), indicators.clone());
1100            Ok(indicators)
1101        } else {
1102            Ok(indicators)
1103        }
1104    }
1105
1106    /// Calculate a specific technical indicator over a time range.
1107    ///
1108    /// Returns the full time series for the requested indicator, not just the latest value.
1109    /// This is useful when you need historical indicator values for analysis or charting.
1110    ///
1111    /// # Arguments
1112    ///
1113    /// * `indicator` - The indicator to calculate (from `crate::indicators::Indicator`)
1114    /// * `interval` - Time interval for candles (1d, 1h, etc.)
1115    /// * `range` - Time range for historical data
1116    ///
1117    /// # Returns
1118    ///
1119    /// An `IndicatorResult` containing the full time series. Access the data using match:
1120    /// - `IndicatorResult::Series(values)` - for simple indicators (SMA, EMA, RSI, ATR, OBV, VWAP, WMA)
1121    /// - `IndicatorResult::Macd(data)` - for MACD (macd_line, signal_line, histogram)
1122    /// - `IndicatorResult::Bollinger(data)` - for Bollinger Bands (upper, middle, lower)
1123    ///
1124    /// # Example
1125    ///
1126    /// ```no_run
1127    /// use finance_query::{Ticker, Interval, TimeRange};
1128    /// use finance_query::indicators::{Indicator, IndicatorResult};
1129    ///
1130    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1131    /// let ticker = Ticker::new("AAPL").await?;
1132    ///
1133    /// // Calculate 14-period RSI
1134    /// let result = ticker.indicator(
1135    ///     Indicator::Rsi(14),
1136    ///     Interval::OneDay,
1137    ///     TimeRange::ThreeMonths
1138    /// ).await?;
1139    ///
1140    /// match result {
1141    ///     IndicatorResult::Series(values) => {
1142    ///         println!("Latest RSI: {:?}", values.last());
1143    ///     }
1144    ///     _ => {}
1145    /// }
1146    ///
1147    /// // Calculate MACD
1148    /// let macd_result = ticker.indicator(
1149    ///     Indicator::Macd { fast: 12, slow: 26, signal: 9 },
1150    ///     Interval::OneDay,
1151    ///     TimeRange::SixMonths
1152    /// ).await?;
1153    ///
1154    /// # Ok(())
1155    /// # }
1156    /// ```
1157    #[cfg(feature = "indicators")]
1158    pub async fn indicator(
1159        &self,
1160        indicator: crate::indicators::Indicator,
1161        interval: Interval,
1162        range: TimeRange,
1163    ) -> Result<crate::indicators::IndicatorResult> {
1164        use crate::indicators::{Indicator, IndicatorResult};
1165
1166        // Fetch chart data
1167        let chart = self.chart(interval, range).await?;
1168
1169        // Calculate the requested indicator
1170        // Note: Price vectors are extracted lazily within each arm to avoid waste
1171        let result = match indicator {
1172            Indicator::Sma(period) => IndicatorResult::Series(chart.sma(period)),
1173            Indicator::Ema(period) => IndicatorResult::Series(chart.ema(period)),
1174            Indicator::Rsi(period) => IndicatorResult::Series(chart.rsi(period)?),
1175            Indicator::Macd { fast, slow, signal } => {
1176                IndicatorResult::Macd(chart.macd(fast, slow, signal)?)
1177            }
1178            Indicator::Bollinger { period, std_dev } => {
1179                IndicatorResult::Bollinger(chart.bollinger_bands(period, std_dev)?)
1180            }
1181            Indicator::Atr(period) => IndicatorResult::Series(chart.atr(period)?),
1182            Indicator::Obv => {
1183                let closes = chart.close_prices();
1184                let volumes = chart.volumes();
1185                IndicatorResult::Series(crate::indicators::obv(&closes, &volumes)?)
1186            }
1187            Indicator::Vwap => {
1188                let highs = chart.high_prices();
1189                let lows = chart.low_prices();
1190                let closes = chart.close_prices();
1191                let volumes = chart.volumes();
1192                IndicatorResult::Series(crate::indicators::vwap(&highs, &lows, &closes, &volumes)?)
1193            }
1194            Indicator::Wma(period) => {
1195                let closes = chart.close_prices();
1196                IndicatorResult::Series(crate::indicators::wma(&closes, period)?)
1197            }
1198            Indicator::Dema(period) => {
1199                let closes = chart.close_prices();
1200                IndicatorResult::Series(crate::indicators::dema(&closes, period)?)
1201            }
1202            Indicator::Tema(period) => {
1203                let closes = chart.close_prices();
1204                IndicatorResult::Series(crate::indicators::tema(&closes, period)?)
1205            }
1206            Indicator::Hma(period) => {
1207                let closes = chart.close_prices();
1208                IndicatorResult::Series(crate::indicators::hma(&closes, period)?)
1209            }
1210            Indicator::Vwma(period) => {
1211                let closes = chart.close_prices();
1212                let volumes = chart.volumes();
1213                IndicatorResult::Series(crate::indicators::vwma(&closes, &volumes, period)?)
1214            }
1215            Indicator::Alma {
1216                period,
1217                offset,
1218                sigma,
1219            } => {
1220                let closes = chart.close_prices();
1221                IndicatorResult::Series(crate::indicators::alma(&closes, period, offset, sigma)?)
1222            }
1223            Indicator::McginleyDynamic(period) => {
1224                let closes = chart.close_prices();
1225                IndicatorResult::Series(crate::indicators::mcginley_dynamic(&closes, period)?)
1226            }
1227            Indicator::Stochastic {
1228                k_period,
1229                k_slow,
1230                d_period,
1231            } => {
1232                let highs = chart.high_prices();
1233                let lows = chart.low_prices();
1234                let closes = chart.close_prices();
1235                IndicatorResult::Stochastic(crate::indicators::stochastic(
1236                    &highs, &lows, &closes, k_period, k_slow, d_period,
1237                )?)
1238            }
1239            Indicator::StochasticRsi {
1240                rsi_period,
1241                stoch_period,
1242                k_period,
1243                d_period,
1244            } => {
1245                let closes = chart.close_prices();
1246                IndicatorResult::Stochastic(crate::indicators::stochastic_rsi(
1247                    &closes,
1248                    rsi_period,
1249                    stoch_period,
1250                    k_period,
1251                    d_period,
1252                )?)
1253            }
1254            Indicator::Cci(period) => {
1255                let highs = chart.high_prices();
1256                let lows = chart.low_prices();
1257                let closes = chart.close_prices();
1258                IndicatorResult::Series(crate::indicators::cci(&highs, &lows, &closes, period)?)
1259            }
1260            Indicator::WilliamsR(period) => {
1261                let highs = chart.high_prices();
1262                let lows = chart.low_prices();
1263                let closes = chart.close_prices();
1264                IndicatorResult::Series(crate::indicators::williams_r(
1265                    &highs, &lows, &closes, period,
1266                )?)
1267            }
1268            Indicator::Roc(period) => {
1269                let closes = chart.close_prices();
1270                IndicatorResult::Series(crate::indicators::roc(&closes, period)?)
1271            }
1272            Indicator::Momentum(period) => {
1273                let closes = chart.close_prices();
1274                IndicatorResult::Series(crate::indicators::momentum(&closes, period)?)
1275            }
1276            Indicator::Cmo(period) => {
1277                let closes = chart.close_prices();
1278                IndicatorResult::Series(crate::indicators::cmo(&closes, period)?)
1279            }
1280            Indicator::AwesomeOscillator { fast, slow } => {
1281                let highs = chart.high_prices();
1282                let lows = chart.low_prices();
1283                IndicatorResult::Series(crate::indicators::awesome_oscillator(
1284                    &highs, &lows, fast, slow,
1285                )?)
1286            }
1287            Indicator::CoppockCurve {
1288                wma_period,
1289                long_roc,
1290                short_roc,
1291            } => {
1292                let closes = chart.close_prices();
1293                IndicatorResult::Series(crate::indicators::coppock_curve(
1294                    &closes, long_roc, short_roc, wma_period,
1295                )?)
1296            }
1297            Indicator::Adx(period) => {
1298                let highs = chart.high_prices();
1299                let lows = chart.low_prices();
1300                let closes = chart.close_prices();
1301                IndicatorResult::Series(crate::indicators::adx(&highs, &lows, &closes, period)?)
1302            }
1303            Indicator::Aroon(period) => {
1304                let highs = chart.high_prices();
1305                let lows = chart.low_prices();
1306                IndicatorResult::Aroon(crate::indicators::aroon(&highs, &lows, period)?)
1307            }
1308            Indicator::Supertrend { period, multiplier } => {
1309                let highs = chart.high_prices();
1310                let lows = chart.low_prices();
1311                let closes = chart.close_prices();
1312                IndicatorResult::SuperTrend(crate::indicators::supertrend(
1313                    &highs, &lows, &closes, period, multiplier,
1314                )?)
1315            }
1316            Indicator::Ichimoku {
1317                conversion,
1318                base,
1319                lagging,
1320                displacement,
1321            } => {
1322                let highs = chart.high_prices();
1323                let lows = chart.low_prices();
1324                let closes = chart.close_prices();
1325                IndicatorResult::Ichimoku(crate::indicators::ichimoku(
1326                    &highs,
1327                    &lows,
1328                    &closes,
1329                    conversion,
1330                    base,
1331                    lagging,
1332                    displacement,
1333                )?)
1334            }
1335            Indicator::ParabolicSar { step, max } => {
1336                let highs = chart.high_prices();
1337                let lows = chart.low_prices();
1338                let closes = chart.close_prices();
1339                IndicatorResult::Series(crate::indicators::parabolic_sar(
1340                    &highs, &lows, &closes, step, max,
1341                )?)
1342            }
1343            Indicator::BullBearPower(period) => {
1344                let highs = chart.high_prices();
1345                let lows = chart.low_prices();
1346                let closes = chart.close_prices();
1347                IndicatorResult::BullBearPower(crate::indicators::bull_bear_power(
1348                    &highs, &lows, &closes, period,
1349                )?)
1350            }
1351            Indicator::ElderRay(period) => {
1352                let highs = chart.high_prices();
1353                let lows = chart.low_prices();
1354                let closes = chart.close_prices();
1355                IndicatorResult::ElderRay(crate::indicators::elder_ray(
1356                    &highs, &lows, &closes, period,
1357                )?)
1358            }
1359            Indicator::KeltnerChannels {
1360                period,
1361                multiplier,
1362                atr_period,
1363            } => {
1364                let highs = chart.high_prices();
1365                let lows = chart.low_prices();
1366                let closes = chart.close_prices();
1367                IndicatorResult::Keltner(crate::indicators::keltner_channels(
1368                    &highs, &lows, &closes, period, atr_period, multiplier,
1369                )?)
1370            }
1371            Indicator::DonchianChannels(period) => {
1372                let highs = chart.high_prices();
1373                let lows = chart.low_prices();
1374                IndicatorResult::Donchian(crate::indicators::donchian_channels(
1375                    &highs, &lows, period,
1376                )?)
1377            }
1378            Indicator::TrueRange => {
1379                let highs = chart.high_prices();
1380                let lows = chart.low_prices();
1381                let closes = chart.close_prices();
1382                IndicatorResult::Series(crate::indicators::true_range(&highs, &lows, &closes)?)
1383            }
1384            Indicator::ChoppinessIndex(period) => {
1385                let highs = chart.high_prices();
1386                let lows = chart.low_prices();
1387                let closes = chart.close_prices();
1388                IndicatorResult::Series(crate::indicators::choppiness_index(
1389                    &highs, &lows, &closes, period,
1390                )?)
1391            }
1392            Indicator::Mfi(period) => {
1393                let highs = chart.high_prices();
1394                let lows = chart.low_prices();
1395                let closes = chart.close_prices();
1396                let volumes = chart.volumes();
1397                IndicatorResult::Series(crate::indicators::mfi(
1398                    &highs, &lows, &closes, &volumes, period,
1399                )?)
1400            }
1401            Indicator::Cmf(period) => {
1402                let highs = chart.high_prices();
1403                let lows = chart.low_prices();
1404                let closes = chart.close_prices();
1405                let volumes = chart.volumes();
1406                IndicatorResult::Series(crate::indicators::cmf(
1407                    &highs, &lows, &closes, &volumes, period,
1408                )?)
1409            }
1410            Indicator::ChaikinOscillator => {
1411                let highs = chart.high_prices();
1412                let lows = chart.low_prices();
1413                let closes = chart.close_prices();
1414                let volumes = chart.volumes();
1415                IndicatorResult::Series(crate::indicators::chaikin_oscillator(
1416                    &highs, &lows, &closes, &volumes,
1417                )?)
1418            }
1419            Indicator::AccumulationDistribution => {
1420                let highs = chart.high_prices();
1421                let lows = chart.low_prices();
1422                let closes = chart.close_prices();
1423                let volumes = chart.volumes();
1424                IndicatorResult::Series(crate::indicators::accumulation_distribution(
1425                    &highs, &lows, &closes, &volumes,
1426                )?)
1427            }
1428            Indicator::BalanceOfPower(period) => {
1429                let opens = chart.open_prices();
1430                let highs = chart.high_prices();
1431                let lows = chart.low_prices();
1432                let closes = chart.close_prices();
1433                IndicatorResult::Series(crate::indicators::balance_of_power(
1434                    &opens, &highs, &lows, &closes, period,
1435                )?)
1436            }
1437        };
1438
1439        Ok(result)
1440    }
1441
1442    /// Get analyst recommendations
1443    pub async fn recommendations(&self, limit: u32) -> Result<Recommendation> {
1444        // Check cache (always fetches max from server, truncated to limit on return)
1445        {
1446            let cache = self.recommendations_cache.read().await;
1447            if let Some(entry) = cache.as_ref()
1448                && self.is_cache_fresh(Some(entry))
1449            {
1450                return Ok(self.build_recommendation_with_limit(&entry.value, limit));
1451            }
1452        }
1453
1454        // Always fetch server maximum (no limit restriction to maximize cache utility)
1455        let json = self.client.get_recommendations(&self.symbol, 15).await?;
1456        let response = RecommendationResponse::from_json(json).map_err(|e| {
1457            crate::error::FinanceError::ResponseStructureError {
1458                field: "finance".to_string(),
1459                context: e.to_string(),
1460            }
1461        })?;
1462
1463        // Cache full response, return truncated result
1464        if self.cache_ttl.is_some() {
1465            let mut cache = self.recommendations_cache.write().await;
1466            *cache = Some(CacheEntry::new(response));
1467            let entry = cache.as_ref().expect("just inserted");
1468            return Ok(self.build_recommendation_with_limit(&entry.value, limit));
1469        }
1470
1471        Ok(self.build_recommendation_with_limit(&response, limit))
1472    }
1473
1474    /// Get financial statements
1475    ///
1476    /// # Arguments
1477    ///
1478    /// * `statement_type` - Type of statement (Income, Balance, CashFlow)
1479    /// * `frequency` - Annual or Quarterly
1480    ///
1481    /// # Example
1482    ///
1483    /// ```no_run
1484    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1485    /// use finance_query::{Ticker, Frequency, StatementType};
1486    ///
1487    /// let ticker = Ticker::new("AAPL").await?;
1488    /// let income = ticker.financials(StatementType::Income, Frequency::Annual).await?;
1489    /// println!("Revenue: {:?}", income.statement.get("TotalRevenue"));
1490    /// # Ok(())
1491    /// # }
1492    /// ```
1493    pub async fn financials(
1494        &self,
1495        statement_type: crate::constants::StatementType,
1496        frequency: crate::constants::Frequency,
1497    ) -> Result<FinancialStatement> {
1498        let cache_key = (statement_type, frequency);
1499
1500        // Check cache
1501        {
1502            let cache = self.financials_cache.read().await;
1503            if let Some(entry) = cache.get(&cache_key)
1504                && self.is_cache_fresh(Some(entry))
1505            {
1506                return Ok(entry.value.clone());
1507            }
1508        }
1509
1510        // Fetch financials
1511        let financials = self
1512            .client
1513            .get_financials(&self.symbol, statement_type, frequency)
1514            .await?;
1515
1516        // Only clone when caching is enabled
1517        if self.cache_ttl.is_some() {
1518            let mut cache = self.financials_cache.write().await;
1519            self.cache_insert(&mut cache, cache_key, financials.clone());
1520            Ok(financials)
1521        } else {
1522            Ok(financials)
1523        }
1524    }
1525
1526    /// Get news articles for this symbol
1527    ///
1528    /// # Example
1529    ///
1530    /// ```no_run
1531    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1532    /// use finance_query::Ticker;
1533    ///
1534    /// let ticker = Ticker::new("AAPL").await?;
1535    /// let news = ticker.news().await?;
1536    /// for article in news {
1537    ///     println!("{}: {}", article.source, article.title);
1538    /// }
1539    /// # Ok(())
1540    /// # }
1541    /// ```
1542    pub async fn news(&self) -> Result<Vec<crate::models::news::News>> {
1543        // Check cache
1544        {
1545            let cache = self.news_cache.read().await;
1546            if let Some(entry) = cache.as_ref()
1547                && self.is_cache_fresh(Some(entry))
1548            {
1549                return Ok(entry.value.clone());
1550            }
1551        }
1552
1553        // Fetch news
1554        let news = crate::scrapers::stockanalysis::scrape_symbol_news(&self.symbol).await?;
1555
1556        // Only clone when caching is enabled
1557        if self.cache_ttl.is_some() {
1558            let mut cache = self.news_cache.write().await;
1559            *cache = Some(CacheEntry::new(news.clone()));
1560            Ok(news)
1561        } else {
1562            Ok(news)
1563        }
1564    }
1565
1566    /// Get options chain
1567    pub async fn options(&self, date: Option<i64>) -> Result<Options> {
1568        // Check cache
1569        {
1570            let cache = self.options_cache.read().await;
1571            if let Some(entry) = cache.get(&date)
1572                && self.is_cache_fresh(Some(entry))
1573            {
1574                return Ok(entry.value.clone());
1575            }
1576        }
1577
1578        // Fetch options
1579        let json = self.client.get_options(&self.symbol, date).await?;
1580        let options: Options = serde_json::from_value(json).map_err(|e| {
1581            crate::error::FinanceError::ResponseStructureError {
1582                field: "options".to_string(),
1583                context: e.to_string(),
1584            }
1585        })?;
1586
1587        // Only clone when caching is enabled
1588        if self.cache_ttl.is_some() {
1589            let mut cache = self.options_cache.write().await;
1590            self.cache_insert(&mut cache, date, options.clone());
1591            Ok(options)
1592        } else {
1593            Ok(options)
1594        }
1595    }
1596
1597    /// Run a backtest with the given strategy and configuration.
1598    ///
1599    /// # Arguments
1600    ///
1601    /// * `strategy` - Trading strategy implementing the Strategy trait
1602    /// * `interval` - Candle interval (1d, 1h, etc.)
1603    /// * `range` - Time range for historical data
1604    /// * `config` - Backtest configuration (optional, uses defaults if None)
1605    ///
1606    /// # Example
1607    ///
1608    /// ```no_run
1609    /// use finance_query::{Ticker, Interval, TimeRange};
1610    /// use finance_query::backtesting::{SmaCrossover, BacktestConfig};
1611    ///
1612    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1613    /// let ticker = Ticker::new("AAPL").await?;
1614    ///
1615    /// // Simple backtest with defaults
1616    /// let strategy = SmaCrossover::new(10, 20);
1617    /// let result = ticker.backtest(
1618    ///     strategy,
1619    ///     Interval::OneDay,
1620    ///     TimeRange::OneYear,
1621    ///     None,
1622    /// ).await?;
1623    ///
1624    /// println!("{}", result.summary());
1625    /// println!("Total trades: {}", result.trades.len());
1626    ///
1627    /// // With custom config
1628    /// let config = BacktestConfig::builder()
1629    ///     .initial_capital(50_000.0)
1630    ///     .commission_pct(0.001)
1631    ///     .stop_loss_pct(0.05)
1632    ///     .allow_short(true)
1633    ///     .build()?;
1634    ///
1635    /// let result = ticker.backtest(
1636    ///     SmaCrossover::new(5, 20),
1637    ///     Interval::OneDay,
1638    ///     TimeRange::TwoYears,
1639    ///     Some(config),
1640    /// ).await?;
1641    /// # Ok(())
1642    /// # }
1643    /// ```
1644    #[cfg(feature = "backtesting")]
1645    pub async fn backtest<S: crate::backtesting::Strategy>(
1646        &self,
1647        strategy: S,
1648        interval: Interval,
1649        range: TimeRange,
1650        config: Option<crate::backtesting::BacktestConfig>,
1651    ) -> crate::backtesting::Result<crate::backtesting::BacktestResult> {
1652        use crate::backtesting::BacktestEngine;
1653
1654        let config = config.unwrap_or_default();
1655        config.validate()?;
1656
1657        // Fetch chart data — also populates the events cache used by dividends()
1658        let chart = self
1659            .chart(interval, range)
1660            .await
1661            .map_err(|e| crate::backtesting::BacktestError::ChartError(e.to_string()))?;
1662
1663        // Fetch dividends from the events cache (no extra network request after chart())
1664        let dividends = self.dividends(range).await.unwrap_or_default();
1665
1666        // Run backtest engine with dividend data
1667        let engine = BacktestEngine::new(config);
1668        engine.run_with_dividends(&self.symbol, &chart.candles, strategy, &dividends)
1669    }
1670
1671    /// Run a backtest and compare performance against a benchmark symbol.
1672    ///
1673    /// Fetches both the symbol chart and the benchmark chart concurrently, then
1674    /// runs the backtest and populates [`BacktestResult::benchmark`] with
1675    /// comparison metrics (alpha, beta, information ratio, buy-and-hold return).
1676    ///
1677    /// Requires the **`backtesting`** feature flag.
1678    ///
1679    /// # Arguments
1680    ///
1681    /// * `strategy` - The strategy to backtest
1682    /// * `interval` - Candle interval
1683    /// * `range` - Historical range
1684    /// * `config` - Optional backtest configuration (uses defaults if `None`)
1685    /// * `benchmark` - Symbol to use as benchmark (e.g. `"SPY"`)
1686    #[cfg(feature = "backtesting")]
1687    pub async fn backtest_with_benchmark<S: crate::backtesting::Strategy>(
1688        &self,
1689        strategy: S,
1690        interval: Interval,
1691        range: TimeRange,
1692        config: Option<crate::backtesting::BacktestConfig>,
1693        benchmark: &str,
1694    ) -> crate::backtesting::Result<crate::backtesting::BacktestResult> {
1695        use crate::backtesting::BacktestEngine;
1696
1697        let config = config.unwrap_or_default();
1698        config.validate()?;
1699
1700        // Fetch the symbol chart and benchmark chart concurrently
1701        let benchmark_ticker = crate::Ticker::new(benchmark)
1702            .await
1703            .map_err(|e| crate::backtesting::BacktestError::ChartError(e.to_string()))?;
1704
1705        let (chart, bench_chart) = tokio::try_join!(
1706            self.chart(interval, range),
1707            benchmark_ticker.chart(interval, range),
1708        )
1709        .map_err(|e| crate::backtesting::BacktestError::ChartError(e.to_string()))?;
1710
1711        // Fetch dividends from events cache (no extra network request after chart())
1712        let dividends = self.dividends(range).await.unwrap_or_default();
1713
1714        let engine = BacktestEngine::new(config);
1715        engine.run_with_benchmark(
1716            &self.symbol,
1717            &chart.candles,
1718            strategy,
1719            &dividends,
1720            benchmark,
1721            &bench_chart.candles,
1722        )
1723    }
1724
1725    // ========================================================================
1726    // Risk Analytics
1727    // ========================================================================
1728
1729    /// Compute a risk summary for this symbol.
1730    ///
1731    /// Requires the **`risk`** feature flag.
1732    ///
1733    /// Calculates Value at Risk, Sharpe/Sortino/Calmar ratios, and maximum drawdown
1734    /// from close-to-close returns derived from the requested chart data.
1735    ///
1736    /// # Arguments
1737    ///
1738    /// * `interval` - Candle interval (use `Interval::OneDay` for daily risk metrics)
1739    /// * `range` - Historical range to analyse
1740    /// * `benchmark` - Optional symbol to use as the benchmark for beta calculation
1741    ///
1742    /// # Example
1743    ///
1744    /// ```no_run
1745    /// use finance_query::{Ticker, Interval, TimeRange};
1746    ///
1747    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1748    /// let ticker = Ticker::new("AAPL").await?;
1749    ///
1750    /// // Risk vs no benchmark
1751    /// let summary = ticker.risk(Interval::OneDay, TimeRange::OneYear, None).await?;
1752    /// println!("VaR 95%:      {:.2}%", summary.var_95 * 100.0);
1753    /// println!("Max drawdown: {:.2}%", summary.max_drawdown * 100.0);
1754    ///
1755    /// // Risk with S&P 500 as benchmark
1756    /// let summary = ticker.risk(Interval::OneDay, TimeRange::OneYear, Some("^GSPC")).await?;
1757    /// println!("Beta: {:?}", summary.beta);
1758    /// # Ok(())
1759    /// # }
1760    /// ```
1761    #[cfg(feature = "risk")]
1762    pub async fn risk(
1763        &self,
1764        interval: Interval,
1765        range: TimeRange,
1766        benchmark: Option<&str>,
1767    ) -> Result<crate::risk::RiskSummary> {
1768        let chart = self.chart(interval, range).await?;
1769
1770        let benchmark_returns = if let Some(sym) = benchmark {
1771            let bench_ticker = Ticker::new(sym).await?;
1772            let bench_chart = bench_ticker.chart(interval, range).await?;
1773            Some(crate::risk::candles_to_returns(&bench_chart.candles))
1774        } else {
1775            None
1776        };
1777
1778        Ok(crate::risk::compute_risk_summary(
1779            &chart.candles,
1780            benchmark_returns.as_deref(),
1781        ))
1782    }
1783
1784    // ========================================================================
1785    // SEC EDGAR
1786    // ========================================================================
1787
1788    /// Get SEC EDGAR filing history for this symbol.
1789    ///
1790    /// Returns company metadata and recent filings. Results are cached for
1791    /// the lifetime of this `Ticker` instance.
1792    ///
1793    /// Requires EDGAR to be initialized via `edgar::init(email)`.
1794    ///
1795    /// # Example
1796    ///
1797    /// ```no_run
1798    /// use finance_query::{Ticker, edgar};
1799    ///
1800    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1801    /// edgar::init("user@example.com")?;
1802    /// let ticker = Ticker::new("AAPL").await?;
1803    ///
1804    /// let submissions = ticker.edgar_submissions().await?;
1805    /// println!("Company: {:?}", submissions.name);
1806    /// # Ok(())
1807    /// # }
1808    /// ```
1809    pub async fn edgar_submissions(&self) -> Result<crate::models::edgar::EdgarSubmissions> {
1810        // Check cache
1811        {
1812            let cache = self.edgar_submissions_cache.read().await;
1813            if let Some(entry) = cache.as_ref()
1814                && self.is_cache_fresh(Some(entry))
1815            {
1816                return Ok(entry.value.clone());
1817            }
1818        }
1819
1820        // Fetch using singleton
1821        let cik = crate::edgar::resolve_cik(&self.symbol).await?;
1822        let submissions = crate::edgar::submissions(cik).await?;
1823
1824        // Only clone when caching is enabled
1825        if self.cache_ttl.is_some() {
1826            let mut cache = self.edgar_submissions_cache.write().await;
1827            *cache = Some(CacheEntry::new(submissions.clone()));
1828            Ok(submissions)
1829        } else {
1830            Ok(submissions)
1831        }
1832    }
1833
1834    /// Get SEC EDGAR company facts (structured XBRL financial data) for this symbol.
1835    ///
1836    /// Returns all extracted XBRL facts organized by taxonomy. Results are cached
1837    /// for the lifetime of this `Ticker` instance.
1838    ///
1839    /// Requires EDGAR to be initialized via `edgar::init(email)`.
1840    ///
1841    /// # Example
1842    ///
1843    /// ```no_run
1844    /// use finance_query::{Ticker, edgar};
1845    ///
1846    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1847    /// edgar::init("user@example.com")?;
1848    /// let ticker = Ticker::new("AAPL").await?;
1849    ///
1850    /// let facts = ticker.edgar_company_facts().await?;
1851    /// if let Some(revenue) = facts.get_us_gaap_fact("Revenue") {
1852    ///     println!("Revenue data points: {:?}", revenue.units.keys().collect::<Vec<_>>());
1853    /// }
1854    /// # Ok(())
1855    /// # }
1856    /// ```
1857    pub async fn edgar_company_facts(&self) -> Result<crate::models::edgar::CompanyFacts> {
1858        // Check cache
1859        {
1860            let cache = self.edgar_facts_cache.read().await;
1861            if let Some(entry) = cache.as_ref()
1862                && self.is_cache_fresh(Some(entry))
1863            {
1864                return Ok(entry.value.clone());
1865            }
1866        }
1867
1868        // Fetch using singleton
1869        let cik = crate::edgar::resolve_cik(&self.symbol).await?;
1870        let facts = crate::edgar::company_facts(cik).await?;
1871
1872        // Only clone when caching is enabled
1873        if self.cache_ttl.is_some() {
1874            let mut cache = self.edgar_facts_cache.write().await;
1875            *cache = Some(CacheEntry::new(facts.clone()));
1876            Ok(facts)
1877        } else {
1878            Ok(facts)
1879        }
1880    }
1881
1882    // ========================================================================
1883    // Cache Management
1884    // ========================================================================
1885
1886    /// Clear all cached data, forcing fresh fetches on next access.
1887    ///
1888    /// Use this when you need up-to-date data from a long-lived `Ticker` instance.
1889    ///
1890    /// # Example
1891    ///
1892    /// ```no_run
1893    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1894    /// use finance_query::Ticker;
1895    ///
1896    /// let ticker = Ticker::new("AAPL").await?;
1897    /// let quote = ticker.quote().await?; // fetches from API
1898    ///
1899    /// // ... some time later ...
1900    /// ticker.clear_cache().await;
1901    /// let fresh_quote = ticker.quote().await?; // fetches again
1902    /// # Ok(())
1903    /// # }
1904    /// ```
1905    pub async fn clear_cache(&self) {
1906        // Acquire all independent write locks in parallel
1907        tokio::join!(
1908            async {
1909                *self.quote_summary.write().await = None;
1910            },
1911            async {
1912                self.chart_cache.write().await.clear();
1913            },
1914            async {
1915                *self.events_cache.write().await = None;
1916            },
1917            async {
1918                *self.recommendations_cache.write().await = None;
1919            },
1920            async {
1921                *self.news_cache.write().await = None;
1922            },
1923            async {
1924                self.options_cache.write().await.clear();
1925            },
1926            async {
1927                self.financials_cache.write().await.clear();
1928            },
1929            async {
1930                *self.edgar_submissions_cache.write().await = None;
1931            },
1932            async {
1933                *self.edgar_facts_cache.write().await = None;
1934            },
1935            async {
1936                #[cfg(feature = "indicators")]
1937                self.indicators_cache.write().await.clear();
1938            },
1939        );
1940    }
1941
1942    /// Clear only the cached quote summary data.
1943    ///
1944    /// The next call to any quote accessor (e.g., `price()`, `financial_data()`)
1945    /// will re-fetch all quote modules from the API.
1946    pub async fn clear_quote_cache(&self) {
1947        *self.quote_summary.write().await = None;
1948    }
1949
1950    /// Clear only the cached chart and events data.
1951    ///
1952    /// The next call to `chart()`, `dividends()`, `splits()`, or `capital_gains()`
1953    /// will re-fetch from the API.
1954    pub async fn clear_chart_cache(&self) {
1955        tokio::join!(
1956            async {
1957                self.chart_cache.write().await.clear();
1958            },
1959            async {
1960                *self.events_cache.write().await = None;
1961            },
1962            async {
1963                #[cfg(feature = "indicators")]
1964                self.indicators_cache.write().await.clear();
1965            }
1966        );
1967    }
1968}