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 ÷nds,
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}