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