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 /// Get stock split history
814 ///
815 /// Returns historical stock splits sorted by date.
816 /// Events are lazily loaded (fetched once, then filtered by range).
817 ///
818 /// # Arguments
819 ///
820 /// * `range` - Time range to filter splits
821 ///
822 /// # Example
823 ///
824 /// ```no_run
825 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
826 /// use finance_query::{Ticker, TimeRange};
827 ///
828 /// let ticker = Ticker::new("NVDA").await?;
829 ///
830 /// // Get all splits
831 /// let all = ticker.splits(TimeRange::Max).await?;
832 ///
833 /// // Get last 5 years
834 /// let recent = ticker.splits(TimeRange::FiveYears).await?;
835 /// # Ok(())
836 /// # }
837 /// ```
838 pub async fn splits(&self, range: TimeRange) -> Result<Vec<Split>> {
839 self.ensure_events_loaded().await?;
840
841 let cache = self.events_cache.read().await;
842 let all = cache
843 .as_ref()
844 .map(|e| e.value.to_splits())
845 .unwrap_or_default();
846
847 Ok(filter_by_range(all, range))
848 }
849
850 /// Get capital gains distribution history
851 ///
852 /// Returns historical capital gain distributions sorted by date.
853 /// This is primarily relevant for mutual funds and ETFs.
854 /// Events are lazily loaded (fetched once, then filtered by range).
855 ///
856 /// # Arguments
857 ///
858 /// * `range` - Time range to filter capital gains
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("VFIAX").await?;
867 ///
868 /// // Get all capital gains
869 /// let all = ticker.capital_gains(TimeRange::Max).await?;
870 ///
871 /// // Get last 2 years
872 /// let recent = ticker.capital_gains(TimeRange::TwoYears).await?;
873 /// # Ok(())
874 /// # }
875 /// ```
876 pub async fn capital_gains(&self, range: TimeRange) -> Result<Vec<CapitalGain>> {
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_capital_gains())
883 .unwrap_or_default();
884
885 Ok(filter_by_range(all, range))
886 }
887
888 /// Calculate all technical indicators from chart data
889 ///
890 /// # Arguments
891 ///
892 /// * `interval` - The time interval for each candle
893 /// * `range` - The time range to fetch data for
894 ///
895 /// # Returns
896 ///
897 /// Returns `IndicatorsSummary` containing all calculated indicators.
898 ///
899 /// # Example
900 ///
901 /// ```no_run
902 /// use finance_query::{Ticker, Interval, TimeRange};
903 ///
904 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
905 /// let ticker = Ticker::new("AAPL").await?;
906 /// let indicators = ticker.indicators(Interval::OneDay, TimeRange::OneYear).await?;
907 ///
908 /// println!("RSI(14): {:?}", indicators.rsi_14);
909 /// println!("MACD: {:?}", indicators.macd);
910 /// # Ok(())
911 /// # }
912 /// ```
913 #[cfg(feature = "indicators")]
914 pub async fn indicators(
915 &self,
916 interval: Interval,
917 range: TimeRange,
918 ) -> Result<crate::indicators::IndicatorsSummary> {
919 // Check cache first (read lock)
920 {
921 let cache = self.indicators_cache.read().await;
922 if let Some(entry) = cache.get(&(interval, range))
923 && self.is_cache_fresh(Some(entry))
924 {
925 return Ok(entry.value.clone());
926 }
927 }
928
929 // Fetch chart data (this is also cached!)
930 let chart = self.chart(interval, range).await?;
931
932 // Calculate indicators from candles
933 let indicators = crate::indicators::summary::calculate_indicators(&chart.candles);
934
935 // Only clone when caching is enabled
936 if self.cache_ttl.is_some() {
937 let mut cache = self.indicators_cache.write().await;
938 self.cache_insert(&mut cache, (interval, range), indicators.clone());
939 Ok(indicators)
940 } else {
941 Ok(indicators)
942 }
943 }
944
945 /// Calculate a specific technical indicator over a time range.
946 ///
947 /// Returns the full time series for the requested indicator, not just the latest value.
948 /// This is useful when you need historical indicator values for analysis or charting.
949 ///
950 /// # Arguments
951 ///
952 /// * `indicator` - The indicator to calculate (from `crate::indicators::Indicator`)
953 /// * `interval` - Time interval for candles (1d, 1h, etc.)
954 /// * `range` - Time range for historical data
955 ///
956 /// # Returns
957 ///
958 /// An `IndicatorResult` containing the full time series. Access the data using match:
959 /// - `IndicatorResult::Series(values)` - for simple indicators (SMA, EMA, RSI, ATR, OBV, VWAP, WMA)
960 /// - `IndicatorResult::Macd(data)` - for MACD (macd_line, signal_line, histogram)
961 /// - `IndicatorResult::Bollinger(data)` - for Bollinger Bands (upper, middle, lower)
962 ///
963 /// # Example
964 ///
965 /// ```no_run
966 /// use finance_query::{Ticker, Interval, TimeRange};
967 /// use finance_query::indicators::{Indicator, IndicatorResult};
968 ///
969 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
970 /// let ticker = Ticker::new("AAPL").await?;
971 ///
972 /// // Calculate 14-period RSI
973 /// let result = ticker.indicator(
974 /// Indicator::Rsi(14),
975 /// Interval::OneDay,
976 /// TimeRange::ThreeMonths
977 /// ).await?;
978 ///
979 /// match result {
980 /// IndicatorResult::Series(values) => {
981 /// println!("Latest RSI: {:?}", values.last());
982 /// }
983 /// _ => {}
984 /// }
985 ///
986 /// // Calculate MACD
987 /// let macd_result = ticker.indicator(
988 /// Indicator::Macd { fast: 12, slow: 26, signal: 9 },
989 /// Interval::OneDay,
990 /// TimeRange::SixMonths
991 /// ).await?;
992 ///
993 /// # Ok(())
994 /// # }
995 /// ```
996 #[cfg(feature = "indicators")]
997 pub async fn indicator(
998 &self,
999 indicator: crate::indicators::Indicator,
1000 interval: Interval,
1001 range: TimeRange,
1002 ) -> Result<crate::indicators::IndicatorResult> {
1003 use crate::indicators::{Indicator, IndicatorResult};
1004
1005 // Fetch chart data
1006 let chart = self.chart(interval, range).await?;
1007
1008 // Calculate the requested indicator
1009 // Note: Price vectors are extracted lazily within each arm to avoid waste
1010 let result = match indicator {
1011 Indicator::Sma(period) => IndicatorResult::Series(chart.sma(period)),
1012 Indicator::Ema(period) => IndicatorResult::Series(chart.ema(period)),
1013 Indicator::Rsi(period) => IndicatorResult::Series(chart.rsi(period)?),
1014 Indicator::Macd { fast, slow, signal } => {
1015 IndicatorResult::Macd(chart.macd(fast, slow, signal)?)
1016 }
1017 Indicator::Bollinger { period, std_dev } => {
1018 IndicatorResult::Bollinger(chart.bollinger_bands(period, std_dev)?)
1019 }
1020 Indicator::Atr(period) => IndicatorResult::Series(chart.atr(period)?),
1021 Indicator::Obv => {
1022 let closes = chart.close_prices();
1023 let volumes = chart.volumes();
1024 IndicatorResult::Series(crate::indicators::obv(&closes, &volumes)?)
1025 }
1026 Indicator::Vwap => {
1027 let highs = chart.high_prices();
1028 let lows = chart.low_prices();
1029 let closes = chart.close_prices();
1030 let volumes = chart.volumes();
1031 IndicatorResult::Series(crate::indicators::vwap(&highs, &lows, &closes, &volumes)?)
1032 }
1033 Indicator::Wma(period) => {
1034 let closes = chart.close_prices();
1035 IndicatorResult::Series(crate::indicators::wma(&closes, period)?)
1036 }
1037 Indicator::Dema(period) => {
1038 let closes = chart.close_prices();
1039 IndicatorResult::Series(crate::indicators::dema(&closes, period)?)
1040 }
1041 Indicator::Tema(period) => {
1042 let closes = chart.close_prices();
1043 IndicatorResult::Series(crate::indicators::tema(&closes, period)?)
1044 }
1045 Indicator::Hma(period) => {
1046 let closes = chart.close_prices();
1047 IndicatorResult::Series(crate::indicators::hma(&closes, period)?)
1048 }
1049 Indicator::Vwma(period) => {
1050 let closes = chart.close_prices();
1051 let volumes = chart.volumes();
1052 IndicatorResult::Series(crate::indicators::vwma(&closes, &volumes, period)?)
1053 }
1054 Indicator::Alma {
1055 period,
1056 offset,
1057 sigma,
1058 } => {
1059 let closes = chart.close_prices();
1060 IndicatorResult::Series(crate::indicators::alma(&closes, period, offset, sigma)?)
1061 }
1062 Indicator::McginleyDynamic(period) => {
1063 let closes = chart.close_prices();
1064 IndicatorResult::Series(crate::indicators::mcginley_dynamic(&closes, period)?)
1065 }
1066 Indicator::Stochastic {
1067 k_period,
1068 k_slow: _k_slow,
1069 d_period,
1070 } => {
1071 // TODO: k_slow parameter not yet used (no smoothing applied)
1072 let highs = chart.high_prices();
1073 let lows = chart.low_prices();
1074 let closes = chart.close_prices();
1075 IndicatorResult::Stochastic(crate::indicators::stochastic(
1076 &highs, &lows, &closes, k_period, d_period,
1077 )?)
1078 }
1079 Indicator::StochasticRsi {
1080 rsi_period,
1081 stoch_period,
1082 k_period: _k_period,
1083 d_period: _d_period,
1084 } => {
1085 // TODO: k_period/d_period smoothing not yet implemented
1086 let closes = chart.close_prices();
1087 IndicatorResult::Series(crate::indicators::stochastic_rsi(
1088 &closes,
1089 rsi_period,
1090 stoch_period,
1091 )?)
1092 }
1093 Indicator::Cci(period) => {
1094 let highs = chart.high_prices();
1095 let lows = chart.low_prices();
1096 let closes = chart.close_prices();
1097 IndicatorResult::Series(crate::indicators::cci(&highs, &lows, &closes, period)?)
1098 }
1099 Indicator::WilliamsR(period) => {
1100 let highs = chart.high_prices();
1101 let lows = chart.low_prices();
1102 let closes = chart.close_prices();
1103 IndicatorResult::Series(crate::indicators::williams_r(
1104 &highs, &lows, &closes, period,
1105 )?)
1106 }
1107 Indicator::Roc(period) => {
1108 let closes = chart.close_prices();
1109 IndicatorResult::Series(crate::indicators::roc(&closes, period)?)
1110 }
1111 Indicator::Momentum(period) => {
1112 let closes = chart.close_prices();
1113 IndicatorResult::Series(crate::indicators::momentum(&closes, period)?)
1114 }
1115 Indicator::Cmo(period) => {
1116 let closes = chart.close_prices();
1117 IndicatorResult::Series(crate::indicators::cmo(&closes, period)?)
1118 }
1119 Indicator::AwesomeOscillator {
1120 fast: _fast,
1121 slow: _slow,
1122 } => {
1123 // TODO: custom fast/slow periods not yet supported; uses defaults (5, 34)
1124 let highs = chart.high_prices();
1125 let lows = chart.low_prices();
1126 IndicatorResult::Series(crate::indicators::awesome_oscillator(&highs, &lows)?)
1127 }
1128 Indicator::CoppockCurve {
1129 wma_period: _wma_period,
1130 long_roc: _long_roc,
1131 short_roc: _short_roc,
1132 } => {
1133 // TODO: custom wma_period/long_roc/short_roc not yet supported; uses defaults (10, 14, 11)
1134 let closes = chart.close_prices();
1135 IndicatorResult::Series(crate::indicators::coppock_curve(&closes)?)
1136 }
1137 Indicator::Adx(period) => {
1138 let highs = chart.high_prices();
1139 let lows = chart.low_prices();
1140 let closes = chart.close_prices();
1141 IndicatorResult::Series(crate::indicators::adx(&highs, &lows, &closes, period)?)
1142 }
1143 Indicator::Aroon(period) => {
1144 let highs = chart.high_prices();
1145 let lows = chart.low_prices();
1146 IndicatorResult::Aroon(crate::indicators::aroon(&highs, &lows, period)?)
1147 }
1148 Indicator::Supertrend { period, multiplier } => {
1149 let highs = chart.high_prices();
1150 let lows = chart.low_prices();
1151 let closes = chart.close_prices();
1152 IndicatorResult::SuperTrend(crate::indicators::supertrend(
1153 &highs, &lows, &closes, period, multiplier,
1154 )?)
1155 }
1156 Indicator::Ichimoku {
1157 conversion: _conversion,
1158 base: _base,
1159 lagging: _lagging,
1160 displacement: _displacement,
1161 } => {
1162 // TODO: custom periods not yet supported; uses traditional values (9, 26, 52, 26)
1163 let highs = chart.high_prices();
1164 let lows = chart.low_prices();
1165 let closes = chart.close_prices();
1166 IndicatorResult::Ichimoku(crate::indicators::ichimoku(&highs, &lows, &closes)?)
1167 }
1168 Indicator::ParabolicSar { step, max } => {
1169 let highs = chart.high_prices();
1170 let lows = chart.low_prices();
1171 let closes = chart.close_prices();
1172 IndicatorResult::Series(crate::indicators::parabolic_sar(
1173 &highs, &lows, &closes, step, max,
1174 )?)
1175 }
1176 Indicator::BullBearPower(_period) => {
1177 // TODO: period parameter not yet used; currently uses EMA(13) internally
1178 let highs = chart.high_prices();
1179 let lows = chart.low_prices();
1180 let closes = chart.close_prices();
1181 IndicatorResult::BullBearPower(crate::indicators::bull_bear_power(
1182 &highs, &lows, &closes,
1183 )?)
1184 }
1185 Indicator::ElderRay(_period) => {
1186 // TODO: period parameter not yet used; currently uses EMA(13) internally
1187 let highs = chart.high_prices();
1188 let lows = chart.low_prices();
1189 let closes = chart.close_prices();
1190 IndicatorResult::ElderRay(crate::indicators::elder_ray(&highs, &lows, &closes)?)
1191 }
1192 Indicator::KeltnerChannels {
1193 period,
1194 multiplier,
1195 atr_period,
1196 } => {
1197 let highs = chart.high_prices();
1198 let lows = chart.low_prices();
1199 let closes = chart.close_prices();
1200 IndicatorResult::Keltner(crate::indicators::keltner_channels(
1201 &highs, &lows, &closes, period, atr_period, multiplier,
1202 )?)
1203 }
1204 Indicator::DonchianChannels(period) => {
1205 let highs = chart.high_prices();
1206 let lows = chart.low_prices();
1207 IndicatorResult::Donchian(crate::indicators::donchian_channels(
1208 &highs, &lows, period,
1209 )?)
1210 }
1211 Indicator::TrueRange => {
1212 let highs = chart.high_prices();
1213 let lows = chart.low_prices();
1214 let closes = chart.close_prices();
1215 IndicatorResult::Series(crate::indicators::true_range(&highs, &lows, &closes)?)
1216 }
1217 Indicator::ChoppinessIndex(period) => {
1218 let highs = chart.high_prices();
1219 let lows = chart.low_prices();
1220 let closes = chart.close_prices();
1221 IndicatorResult::Series(crate::indicators::choppiness_index(
1222 &highs, &lows, &closes, period,
1223 )?)
1224 }
1225 Indicator::Mfi(period) => {
1226 let highs = chart.high_prices();
1227 let lows = chart.low_prices();
1228 let closes = chart.close_prices();
1229 let volumes = chart.volumes();
1230 IndicatorResult::Series(crate::indicators::mfi(
1231 &highs, &lows, &closes, &volumes, period,
1232 )?)
1233 }
1234 Indicator::Cmf(period) => {
1235 let highs = chart.high_prices();
1236 let lows = chart.low_prices();
1237 let closes = chart.close_prices();
1238 let volumes = chart.volumes();
1239 IndicatorResult::Series(crate::indicators::cmf(
1240 &highs, &lows, &closes, &volumes, period,
1241 )?)
1242 }
1243 Indicator::ChaikinOscillator => {
1244 let highs = chart.high_prices();
1245 let lows = chart.low_prices();
1246 let closes = chart.close_prices();
1247 let volumes = chart.volumes();
1248 IndicatorResult::Series(crate::indicators::chaikin_oscillator(
1249 &highs, &lows, &closes, &volumes,
1250 )?)
1251 }
1252 Indicator::AccumulationDistribution => {
1253 let highs = chart.high_prices();
1254 let lows = chart.low_prices();
1255 let closes = chart.close_prices();
1256 let volumes = chart.volumes();
1257 IndicatorResult::Series(crate::indicators::accumulation_distribution(
1258 &highs, &lows, &closes, &volumes,
1259 )?)
1260 }
1261 Indicator::BalanceOfPower(period) => {
1262 let opens = chart.open_prices();
1263 let highs = chart.high_prices();
1264 let lows = chart.low_prices();
1265 let closes = chart.close_prices();
1266 IndicatorResult::Series(crate::indicators::balance_of_power(
1267 &opens, &highs, &lows, &closes, period,
1268 )?)
1269 }
1270 };
1271
1272 Ok(result)
1273 }
1274
1275 /// Get analyst recommendations
1276 pub async fn recommendations(&self, limit: u32) -> Result<Recommendation> {
1277 // Check cache (always fetches max from server, truncated to limit on return)
1278 {
1279 let cache = self.recommendations_cache.read().await;
1280 if let Some(entry) = cache.as_ref()
1281 && self.is_cache_fresh(Some(entry))
1282 {
1283 return Ok(self.build_recommendation_with_limit(&entry.value, limit));
1284 }
1285 }
1286
1287 // Always fetch server maximum (no limit restriction to maximize cache utility)
1288 let json = self.client.get_recommendations(&self.symbol, 15).await?;
1289 let response = RecommendationResponse::from_json(json).map_err(|e| {
1290 crate::error::FinanceError::ResponseStructureError {
1291 field: "finance".to_string(),
1292 context: e.to_string(),
1293 }
1294 })?;
1295
1296 // Cache full response, return truncated result
1297 if self.cache_ttl.is_some() {
1298 let mut cache = self.recommendations_cache.write().await;
1299 *cache = Some(CacheEntry::new(response));
1300 let entry = cache.as_ref().unwrap();
1301 return Ok(self.build_recommendation_with_limit(&entry.value, limit));
1302 }
1303
1304 Ok(self.build_recommendation_with_limit(&response, limit))
1305 }
1306
1307 /// Get financial statements
1308 ///
1309 /// # Arguments
1310 ///
1311 /// * `statement_type` - Type of statement (Income, Balance, CashFlow)
1312 /// * `frequency` - Annual or Quarterly
1313 ///
1314 /// # Example
1315 ///
1316 /// ```no_run
1317 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1318 /// use finance_query::{Ticker, Frequency, StatementType};
1319 ///
1320 /// let ticker = Ticker::new("AAPL").await?;
1321 /// let income = ticker.financials(StatementType::Income, Frequency::Annual).await?;
1322 /// println!("Revenue: {:?}", income.statement.get("TotalRevenue"));
1323 /// # Ok(())
1324 /// # }
1325 /// ```
1326 pub async fn financials(
1327 &self,
1328 statement_type: crate::constants::StatementType,
1329 frequency: crate::constants::Frequency,
1330 ) -> Result<FinancialStatement> {
1331 let cache_key = (statement_type, frequency);
1332
1333 // Check cache
1334 {
1335 let cache = self.financials_cache.read().await;
1336 if let Some(entry) = cache.get(&cache_key)
1337 && self.is_cache_fresh(Some(entry))
1338 {
1339 return Ok(entry.value.clone());
1340 }
1341 }
1342
1343 // Fetch financials
1344 let financials = self
1345 .client
1346 .get_financials(&self.symbol, statement_type, frequency)
1347 .await?;
1348
1349 // Only clone when caching is enabled
1350 if self.cache_ttl.is_some() {
1351 let mut cache = self.financials_cache.write().await;
1352 self.cache_insert(&mut cache, cache_key, financials.clone());
1353 Ok(financials)
1354 } else {
1355 Ok(financials)
1356 }
1357 }
1358
1359 /// Get news articles for this symbol
1360 ///
1361 /// # Example
1362 ///
1363 /// ```no_run
1364 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1365 /// use finance_query::Ticker;
1366 ///
1367 /// let ticker = Ticker::new("AAPL").await?;
1368 /// let news = ticker.news().await?;
1369 /// for article in news {
1370 /// println!("{}: {}", article.source, article.title);
1371 /// }
1372 /// # Ok(())
1373 /// # }
1374 /// ```
1375 pub async fn news(&self) -> Result<Vec<crate::models::news::News>> {
1376 // Check cache
1377 {
1378 let cache = self.news_cache.read().await;
1379 if self.is_cache_fresh(cache.as_ref()) {
1380 return Ok(cache.as_ref().unwrap().value.clone());
1381 }
1382 }
1383
1384 // Fetch news
1385 let news = crate::scrapers::stockanalysis::scrape_symbol_news(&self.symbol).await?;
1386
1387 // Only clone when caching is enabled
1388 if self.cache_ttl.is_some() {
1389 let mut cache = self.news_cache.write().await;
1390 *cache = Some(CacheEntry::new(news.clone()));
1391 Ok(news)
1392 } else {
1393 Ok(news)
1394 }
1395 }
1396
1397 /// Get options chain
1398 pub async fn options(&self, date: Option<i64>) -> Result<Options> {
1399 // Check cache
1400 {
1401 let cache = self.options_cache.read().await;
1402 if let Some(entry) = cache.get(&date)
1403 && self.is_cache_fresh(Some(entry))
1404 {
1405 return Ok(entry.value.clone());
1406 }
1407 }
1408
1409 // Fetch options
1410 let json = self.client.get_options(&self.symbol, date).await?;
1411 let options: Options = serde_json::from_value(json).map_err(|e| {
1412 crate::error::FinanceError::ResponseStructureError {
1413 field: "options".to_string(),
1414 context: e.to_string(),
1415 }
1416 })?;
1417
1418 // Only clone when caching is enabled
1419 if self.cache_ttl.is_some() {
1420 let mut cache = self.options_cache.write().await;
1421 self.cache_insert(&mut cache, date, options.clone());
1422 Ok(options)
1423 } else {
1424 Ok(options)
1425 }
1426 }
1427
1428 /// Run a backtest with the given strategy and configuration.
1429 ///
1430 /// # Arguments
1431 ///
1432 /// * `strategy` - Trading strategy implementing the Strategy trait
1433 /// * `interval` - Candle interval (1d, 1h, etc.)
1434 /// * `range` - Time range for historical data
1435 /// * `config` - Backtest configuration (optional, uses defaults if None)
1436 ///
1437 /// # Example
1438 ///
1439 /// ```no_run
1440 /// use finance_query::{Ticker, Interval, TimeRange};
1441 /// use finance_query::backtesting::{SmaCrossover, BacktestConfig};
1442 ///
1443 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1444 /// let ticker = Ticker::new("AAPL").await?;
1445 ///
1446 /// // Simple backtest with defaults
1447 /// let strategy = SmaCrossover::new(10, 20);
1448 /// let result = ticker.backtest(
1449 /// strategy,
1450 /// Interval::OneDay,
1451 /// TimeRange::OneYear,
1452 /// None,
1453 /// ).await?;
1454 ///
1455 /// println!("{}", result.summary());
1456 /// println!("Total trades: {}", result.trades.len());
1457 ///
1458 /// // With custom config
1459 /// let config = BacktestConfig::builder()
1460 /// .initial_capital(50_000.0)
1461 /// .commission_pct(0.001)
1462 /// .stop_loss_pct(0.05)
1463 /// .allow_short(true)
1464 /// .build()?;
1465 ///
1466 /// let result = ticker.backtest(
1467 /// SmaCrossover::new(5, 20).with_short(true),
1468 /// Interval::OneDay,
1469 /// TimeRange::TwoYears,
1470 /// Some(config),
1471 /// ).await?;
1472 /// # Ok(())
1473 /// # }
1474 /// ```
1475 #[cfg(feature = "backtesting")]
1476 pub async fn backtest<S: crate::backtesting::Strategy>(
1477 &self,
1478 strategy: S,
1479 interval: Interval,
1480 range: TimeRange,
1481 config: Option<crate::backtesting::BacktestConfig>,
1482 ) -> crate::backtesting::Result<crate::backtesting::BacktestResult> {
1483 use crate::backtesting::BacktestEngine;
1484
1485 let config = config.unwrap_or_default();
1486 config.validate()?;
1487
1488 // Fetch chart data
1489 let chart = self
1490 .chart(interval, range)
1491 .await
1492 .map_err(|e| crate::backtesting::BacktestError::ChartError(e.to_string()))?;
1493
1494 // Run backtest engine
1495 let engine = BacktestEngine::new(config);
1496 engine.run(&self.symbol, &chart.candles, strategy)
1497 }
1498
1499 // ========================================================================
1500 // SEC EDGAR
1501 // ========================================================================
1502
1503 /// Get SEC EDGAR filing history for this symbol.
1504 ///
1505 /// Returns company metadata and recent filings. Results are cached for
1506 /// the lifetime of this `Ticker` instance.
1507 ///
1508 /// Requires EDGAR to be initialized via `edgar::init(email)`.
1509 ///
1510 /// # Example
1511 ///
1512 /// ```no_run
1513 /// use finance_query::{Ticker, edgar};
1514 ///
1515 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1516 /// edgar::init("user@example.com")?;
1517 /// let ticker = Ticker::new("AAPL").await?;
1518 ///
1519 /// let submissions = ticker.edgar_submissions().await?;
1520 /// println!("Company: {:?}", submissions.name);
1521 /// # Ok(())
1522 /// # }
1523 /// ```
1524 pub async fn edgar_submissions(&self) -> Result<crate::models::edgar::EdgarSubmissions> {
1525 // Check cache
1526 {
1527 let cache = self.edgar_submissions_cache.read().await;
1528 if self.is_cache_fresh(cache.as_ref()) {
1529 return Ok(cache.as_ref().unwrap().value.clone());
1530 }
1531 }
1532
1533 // Fetch using singleton
1534 let cik = crate::edgar::resolve_cik(&self.symbol).await?;
1535 let submissions = crate::edgar::submissions(cik).await?;
1536
1537 // Only clone when caching is enabled
1538 if self.cache_ttl.is_some() {
1539 let mut cache = self.edgar_submissions_cache.write().await;
1540 *cache = Some(CacheEntry::new(submissions.clone()));
1541 Ok(submissions)
1542 } else {
1543 Ok(submissions)
1544 }
1545 }
1546
1547 /// Get SEC EDGAR company facts (structured XBRL financial data) for this symbol.
1548 ///
1549 /// Returns all extracted XBRL facts organized by taxonomy. Results are cached
1550 /// for the lifetime of this `Ticker` instance.
1551 ///
1552 /// Requires EDGAR to be initialized via `edgar::init(email)`.
1553 ///
1554 /// # Example
1555 ///
1556 /// ```no_run
1557 /// use finance_query::{Ticker, edgar};
1558 ///
1559 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1560 /// edgar::init("user@example.com")?;
1561 /// let ticker = Ticker::new("AAPL").await?;
1562 ///
1563 /// let facts = ticker.edgar_company_facts().await?;
1564 /// if let Some(revenue) = facts.get_us_gaap_fact("Revenue") {
1565 /// println!("Revenue data points: {:?}", revenue.units.keys().collect::<Vec<_>>());
1566 /// }
1567 /// # Ok(())
1568 /// # }
1569 /// ```
1570 pub async fn edgar_company_facts(&self) -> Result<crate::models::edgar::CompanyFacts> {
1571 // Check cache
1572 {
1573 let cache = self.edgar_facts_cache.read().await;
1574 if self.is_cache_fresh(cache.as_ref()) {
1575 return Ok(cache.as_ref().unwrap().value.clone());
1576 }
1577 }
1578
1579 // Fetch using singleton
1580 let cik = crate::edgar::resolve_cik(&self.symbol).await?;
1581 let facts = crate::edgar::company_facts(cik).await?;
1582
1583 // Only clone when caching is enabled
1584 if self.cache_ttl.is_some() {
1585 let mut cache = self.edgar_facts_cache.write().await;
1586 *cache = Some(CacheEntry::new(facts.clone()));
1587 Ok(facts)
1588 } else {
1589 Ok(facts)
1590 }
1591 }
1592
1593 // ========================================================================
1594 // Cache Management
1595 // ========================================================================
1596
1597 /// Clear all cached data, forcing fresh fetches on next access.
1598 ///
1599 /// Use this when you need up-to-date data from a long-lived `Ticker` instance.
1600 ///
1601 /// # Example
1602 ///
1603 /// ```no_run
1604 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1605 /// use finance_query::Ticker;
1606 ///
1607 /// let ticker = Ticker::new("AAPL").await?;
1608 /// let quote = ticker.quote().await?; // fetches from API
1609 ///
1610 /// // ... some time later ...
1611 /// ticker.clear_cache().await;
1612 /// let fresh_quote = ticker.quote().await?; // fetches again
1613 /// # Ok(())
1614 /// # }
1615 /// ```
1616 pub async fn clear_cache(&self) {
1617 // Acquire all independent write locks in parallel
1618 tokio::join!(
1619 async {
1620 *self.quote_summary.write().await = None;
1621 },
1622 async {
1623 self.chart_cache.write().await.clear();
1624 },
1625 async {
1626 *self.events_cache.write().await = None;
1627 },
1628 async {
1629 *self.recommendations_cache.write().await = None;
1630 },
1631 async {
1632 *self.news_cache.write().await = None;
1633 },
1634 async {
1635 self.options_cache.write().await.clear();
1636 },
1637 async {
1638 self.financials_cache.write().await.clear();
1639 },
1640 async {
1641 *self.edgar_submissions_cache.write().await = None;
1642 },
1643 async {
1644 *self.edgar_facts_cache.write().await = None;
1645 },
1646 async {
1647 #[cfg(feature = "indicators")]
1648 self.indicators_cache.write().await.clear();
1649 },
1650 );
1651 }
1652
1653 /// Clear only the cached quote summary data.
1654 ///
1655 /// The next call to any quote accessor (e.g., `price()`, `financial_data()`)
1656 /// will re-fetch all quote modules from the API.
1657 pub async fn clear_quote_cache(&self) {
1658 *self.quote_summary.write().await = None;
1659 }
1660
1661 /// Clear only the cached chart and events data.
1662 ///
1663 /// The next call to `chart()`, `dividends()`, `splits()`, or `capital_gains()`
1664 /// will re-fetch from the API.
1665 pub async fn clear_chart_cache(&self) {
1666 tokio::join!(
1667 async {
1668 self.chart_cache.write().await.clear();
1669 },
1670 async {
1671 *self.events_cache.write().await = None;
1672 },
1673 async {
1674 #[cfg(feature = "indicators")]
1675 self.indicators_cache.write().await.clear();
1676 }
1677 );
1678 }
1679}