Skip to main content

finance_query/
finance.rs

1//! Non-symbol-specific Yahoo Finance operations
2//!
3//! This module provides functions for operations that don't require a specific stock symbol,
4//! such as searching for symbols and fetching screener data.
5
6use crate::adapters::yahoo::client::ClientConfig;
7use crate::constants::Region;
8use crate::constants::screeners::Screener;
9use crate::constants::sectors::Sector;
10use crate::error::Result;
11use crate::models::corporate::transcript::{Transcript, TranscriptWithMeta};
12use crate::models::discovery::screeners::ScreenerResults;
13use crate::models::discovery::search::SearchResults;
14use crate::models::market::industries::IndustryData;
15use crate::models::market::sectors::SectorData;
16
17#[cfg(any(feature = "fmp", feature = "alphavantage"))]
18use serde::{Deserialize, Serialize};
19
20// Re-export options for convenience
21pub use crate::adapters::yahoo::discovery::lookup::{LookupOptions, LookupType};
22pub use crate::adapters::yahoo::discovery::search::SearchOptions;
23
24/// Search for stock symbols and companies
25///
26/// # Arguments
27///
28/// * `query` - Search term (company name, symbol, etc.)
29/// * `options` - Search configuration options
30///
31/// # Examples
32///
33/// ```no_run
34/// use finance_query::{finance, SearchOptions, Region};
35///
36/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
37/// // Simple search with defaults
38/// let results = finance::search("Apple", &SearchOptions::default()).await?;
39/// println!("Found {} results", results.result_count());
40///
41/// // Search with custom options
42/// let options = SearchOptions::new()
43///     .quotes_count(10)
44///     .news_count(5)
45///     .enable_research_reports(true)
46///     .region(Region::Canada);
47/// let results = finance::search("NVDA", &options).await?;
48/// println!("Found {} quotes", results.quotes.len());
49/// # Ok(())
50/// # }
51/// ```
52pub async fn search(query: &str, options: &SearchOptions) -> Result<SearchResults> {
53    let client = crate::adapters::yahoo::session::get_or_auth(&ClientConfig::default()).await?;
54    let results = client.search(query, options).await;
55    #[cfg(feature = "sentiment")]
56    let results = results.map(|mut r| {
57        for article in r.news.0.iter_mut() {
58            if let Some(title) = article.title.as_deref() {
59                article.sentiment = Some(crate::models::sentiment::analyze(title));
60            }
61        }
62        r
63    });
64    results
65}
66
67/// Look up symbols by type (equity, ETF, mutual fund, index, future, currency, cryptocurrency)
68///
69/// Unlike search, lookup specializes in discovering tickers filtered by asset type.
70/// Optionally fetches logo URLs via an additional API call.
71///
72/// # Arguments
73///
74/// * `query` - Search term (company name, symbol, etc.)
75/// * `options` - Lookup configuration options
76///
77/// # Examples
78///
79/// ```no_run
80/// use finance_query::{finance, LookupOptions, LookupType, Region};
81///
82/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
83/// // Simple lookup with defaults
84/// let results = finance::lookup("Apple", &LookupOptions::default()).await?;
85/// println!("Found {} results", results.result_count());
86///
87/// // Lookup equities with logos
88/// let options = LookupOptions::new()
89///     .lookup_type(LookupType::Equity)
90///     .count(10)
91///     .include_logo(true);
92/// let results = finance::lookup("NVDA", &options).await?;
93/// for quote in &results.quotes {
94///     println!("{}: {:?}", quote.symbol, quote.logo_url);
95/// }
96/// # Ok(())
97/// # }
98/// ```
99pub async fn lookup(
100    query: &str,
101    options: &LookupOptions,
102) -> Result<crate::models::discovery::lookup::LookupResults> {
103    let client = crate::adapters::yahoo::session::get_or_auth(&ClientConfig::default()).await?;
104    client.lookup(query, options).await
105}
106
107/// Fetch data from a predefined Yahoo Finance screener
108///
109/// Returns stocks/funds matching the criteria of the specified screener type.
110///
111/// # Arguments
112///
113/// * `screener_type` - The predefined screener to use
114/// * `count` - Number of results to return (max 250)
115///
116/// # Examples
117///
118/// ```no_run
119/// use finance_query::{finance, Screener};
120///
121/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
122/// // Get top gainers
123/// let gainers = finance::screener(Screener::DayGainers, 25).await?;
124/// println!("Top gainers: {:#?}", gainers);
125///
126/// // Get most shorted stocks
127/// let shorted = finance::screener(Screener::MostShortedStocks, 25).await?;
128///
129/// // Get growth technology stocks
130/// let tech = finance::screener(Screener::GrowthTechnologyStocks, 25).await?;
131/// # Ok(())
132/// # }
133/// ```
134pub async fn screener(screener_type: Screener, count: u32) -> Result<ScreenerResults> {
135    let client = crate::adapters::yahoo::session::get_or_auth(&ClientConfig::default()).await?;
136    crate::adapters::yahoo::discovery::screeners::fetch(&client, screener_type, count).await
137}
138
139/// Execute a custom screener query
140///
141/// Allows flexible filtering of stocks/funds/ETFs based on various criteria.
142/// Use [`EquityScreenerQuery`][crate::EquityScreenerQuery] for stock screeners
143/// or [`FundScreenerQuery`][crate::FundScreenerQuery] for mutual fund screeners.
144///
145/// # Arguments
146///
147/// * `query` - The custom screener query to execute
148///
149/// # Examples
150///
151/// ```no_run
152/// use finance_query::{finance, EquityField, EquityScreenerQuery, ScreenerFieldExt};
153///
154/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
155/// // Find US large-cap stocks with high volume
156/// let query = EquityScreenerQuery::new()
157///     .size(25)
158///     .sort_by(EquityField::IntradayMarketCap, false)
159///     .add_condition(EquityField::Region.eq_str("us"))
160///     .add_condition(EquityField::AvgDailyVol3M.gt(200_000.0))
161///     .add_condition(EquityField::IntradayMarketCap.gt(10_000_000_000.0));
162///
163/// let result = finance::custom_screener(query).await?;
164/// println!("Found {} stocks", result.quotes.len());
165/// # Ok(())
166/// # }
167/// ```
168pub async fn custom_screener<F: crate::models::discovery::screeners::ScreenerField>(
169    query: crate::models::discovery::screeners::ScreenerQuery<F>,
170) -> Result<ScreenerResults> {
171    let client = crate::adapters::yahoo::session::get_or_auth(&ClientConfig::default()).await?;
172    crate::adapters::yahoo::discovery::screeners::fetch_custom(&client, query).await
173}
174
175/// Get general market news
176///
177/// # Examples
178///
179/// ```no_run
180/// use finance_query::finance;
181///
182/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
183/// let news = finance::news().await?;
184/// for article in news {
185///     println!("{}: {}", article.source, article.title);
186/// }
187/// # Ok(())
188/// # }
189/// ```
190pub async fn news() -> Result<Vec<crate::models::corporate::news::News>> {
191    let news = crate::scrapers::stockanalysis::scrape_general_news().await;
192    #[cfg(feature = "sentiment")]
193    let news = news.map(|mut articles| {
194        for article in articles.iter_mut() {
195            article.sentiment = Some(crate::models::sentiment::analyze(&article.title));
196        }
197        articles
198    });
199    news
200}
201
202/// Get earnings transcript for a symbol
203///
204/// Fetches the earnings call transcript, handling all the complexity internally:
205/// 1. Gets the company ID (quartrId) from the quote_type endpoint
206/// 2. Scrapes available earnings calls
207/// 3. Fetches the requested transcript
208///
209/// # Arguments
210///
211/// * `symbol` - Stock symbol (e.g., "AAPL", "MSFT")
212/// * `quarter` - Optional fiscal quarter (Q1, Q2, Q3, Q4). If None, gets latest.
213/// * `year` - Optional fiscal year. If None, gets latest.
214///
215/// # Examples
216///
217/// ```no_run
218/// use finance_query::finance;
219///
220/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
221/// // Get the latest transcript
222/// let latest = finance::earnings_transcript("AAPL", None, None).await?;
223/// println!("Quarter: {} {}", latest.quarter(), latest.year());
224///
225/// // Get a specific quarter
226/// let q4_2024 = finance::earnings_transcript("AAPL", Some("Q4"), Some(2024)).await?;
227/// # Ok(())
228/// # }
229/// ```
230pub async fn earnings_transcript(
231    symbol: &str,
232    quarter: Option<&str>,
233    year: Option<i32>,
234) -> Result<Transcript> {
235    let client = crate::adapters::yahoo::session::get_or_auth(&ClientConfig::default()).await?;
236    let transcript = crate::adapters::yahoo::corporate::transcripts::fetch_for_symbol(
237        &client, symbol, quarter, year,
238    )
239    .await;
240    #[cfg(feature = "sentiment")]
241    let transcript = transcript.map(|mut t| {
242        t.score_sentiment();
243        t
244    });
245    transcript
246}
247
248/// Get all earnings transcripts for a symbol
249///
250/// Fetches transcripts for all available earnings calls.
251///
252/// # Arguments
253///
254/// * `symbol` - Stock symbol (e.g., "AAPL", "MSFT")
255/// * `limit` - Optional maximum number of transcripts. If None, fetches all.
256///
257/// # Examples
258///
259/// ```no_run
260/// use finance_query::finance;
261///
262/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
263/// // Get all transcripts
264/// let all = finance::earnings_transcripts("AAPL", None).await?;
265///
266/// // Get only the 5 most recent
267/// let recent = finance::earnings_transcripts("AAPL", Some(5)).await?;
268/// for t in &recent {
269///     println!("{}: {} {}", t.title, t.transcript.quarter(), t.transcript.year());
270/// }
271/// # Ok(())
272/// # }
273/// ```
274pub async fn earnings_transcripts(
275    symbol: &str,
276    limit: Option<usize>,
277) -> Result<Vec<TranscriptWithMeta>> {
278    let client = crate::adapters::yahoo::session::get_or_auth(&ClientConfig::default()).await?;
279    let transcripts = crate::adapters::yahoo::corporate::transcripts::fetch_all_for_symbol(
280        &client, symbol, limit,
281    )
282    .await;
283    #[cfg(feature = "sentiment")]
284    let transcripts = transcripts.map(|mut list| {
285        for t in list.iter_mut() {
286            t.transcript.score_sentiment();
287        }
288        list
289    });
290    transcripts
291}
292
293/// Get market hours/status
294///
295/// Returns the current status for various markets.
296///
297/// # Arguments
298///
299/// * `region` - Optional region override. If None, uses default (US).
300///
301/// # Examples
302///
303/// ```no_run
304/// use finance_query::{finance, Region};
305///
306/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
307/// // Get US market hours (default)
308/// let hours = finance::hours(None).await?;
309///
310/// // Get Japan market hours
311/// let jp_hours = finance::hours(Some(Region::Japan)).await?;
312/// # Ok(())
313/// # }
314/// ```
315pub async fn hours(region: Option<Region>) -> Result<crate::models::market::hours::MarketHours> {
316    let client = crate::adapters::yahoo::session::get_or_auth(&ClientConfig::default()).await?;
317    crate::adapters::yahoo::market::hours::fetch(&client, region.map(|r| r.region())).await
318}
319
320/// Get world market indices quotes
321///
322/// Returns quotes for major world indices, optionally filtered by region.
323///
324/// # Arguments
325///
326/// * `region` - Optional region filter. If None, returns all world indices.
327///
328/// # Examples
329///
330/// ```no_run
331/// use finance_query::{finance, IndicesRegion};
332///
333/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
334/// // Get all world indices
335/// let all = finance::indices(None).await?;
336/// println!("Fetched {} indices", all.success_count());
337///
338/// // Get only Americas indices
339/// let americas = finance::indices(Some(IndicesRegion::Americas)).await?;
340/// # Ok(())
341/// # }
342/// ```
343pub async fn indices(
344    region: Option<crate::constants::indices::Region>,
345) -> Result<crate::tickers::BatchQuotesResponse> {
346    use crate::Tickers;
347    use crate::constants::indices::all_symbols;
348
349    let symbols: Vec<&str> = match region {
350        Some(r) => r.symbols().to_vec(),
351        None => all_symbols(),
352    };
353
354    let tickers = Tickers::new(symbols).await?;
355    tickers.quotes().await
356}
357
358/// Fetch detailed sector data from Yahoo Finance
359///
360/// Returns comprehensive sector information including overview, performance,
361/// top companies, ETFs, mutual funds, industries, and research reports.
362///
363/// # Arguments
364///
365/// * `sector_type` - The sector to fetch data for
366///
367/// # Examples
368///
369/// ```no_run
370/// use finance_query::{finance, Sector};
371///
372/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
373/// let sector = finance::sector(Sector::Technology).await?;
374/// println!("Sector: {} ({} companies)", sector.name,
375///     sector.overview.as_ref().map(|o| o.companies_count.unwrap_or(0)).unwrap_or(0));
376///
377/// for company in sector.top_companies.iter().take(5) {
378///     println!("  {} - {:?}", company.symbol, company.name);
379/// }
380/// # Ok(())
381/// # }
382/// ```
383pub async fn sector(sector_type: Sector) -> Result<SectorData> {
384    let client = crate::adapters::yahoo::session::get_or_auth(&ClientConfig::default()).await?;
385    crate::adapters::yahoo::market::sectors::fetch(&client, sector_type).await
386}
387
388/// Fetch detailed industry data from Yahoo Finance
389///
390/// Returns comprehensive industry information including overview, performance,
391/// top companies, top performing companies, top growth companies, and research reports.
392///
393/// # Arguments
394///
395/// * `industry_key` - The industry key/slug (e.g., "semiconductors", "software-infrastructure")
396///
397/// # Examples
398///
399/// ```no_run
400/// use finance_query::finance;
401///
402/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
403/// let industry = finance::industry("semiconductors").await?;
404/// println!("Industry: {} ({} companies)", industry.name,
405///     industry.overview.as_ref().map(|o| o.companies_count.unwrap_or(0)).unwrap_or(0));
406///
407/// for company in industry.top_companies.iter().take(5) {
408///     println!("  {} - {:?}", company.symbol, company.name);
409/// }
410/// # Ok(())
411/// # }
412/// ```
413pub async fn industry(industry_key: impl AsRef<str>) -> Result<IndustryData> {
414    let client = crate::adapters::yahoo::session::get_or_auth(&ClientConfig::default()).await?;
415    crate::adapters::yahoo::market::industries::fetch(&client, industry_key.as_ref()).await
416}
417
418/// Get list of available currencies
419///
420/// Returns currency information from Yahoo Finance.
421///
422/// # Examples
423///
424/// ```no_run
425/// use finance_query::finance;
426///
427/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
428/// let currencies = finance::currencies().await?;
429/// # Ok(())
430/// # }
431/// ```
432pub async fn currencies() -> Result<Vec<crate::models::market::currencies::Currency>> {
433    let client = crate::adapters::yahoo::session::get_or_auth(&ClientConfig::default()).await?;
434    crate::adapters::yahoo::market::currencies::fetch(&client).await
435}
436
437/// Get list of supported exchanges
438///
439/// Scrapes the Yahoo Finance help page for a list of supported exchanges
440/// with their symbol suffixes and data delay information.
441///
442/// # Examples
443///
444/// ```no_run
445/// use finance_query::finance;
446///
447/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
448/// let exchanges = finance::exchanges().await?;
449/// for exchange in &exchanges {
450///     println!("{} - {} ({})", exchange.country, exchange.market, exchange.suffix);
451/// }
452/// # Ok(())
453/// # }
454/// ```
455pub async fn exchanges() -> Result<Vec<crate::models::market::exchanges::Exchange>> {
456    crate::scrapers::yahoo_exchanges::scrape_exchanges().await
457}
458
459/// Get market summary
460///
461/// Returns market summary with major indices, currencies, and commodities.
462///
463/// # Arguments
464///
465/// * `region` - Optional region for localization. If None, uses default (US).
466///
467/// # Examples
468///
469/// ```no_run
470/// use finance_query::{finance, Region};
471///
472/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
473/// // Use default (US)
474/// let summary = finance::market_summary(None).await?;
475/// // Or specify a region
476/// let summary = finance::market_summary(Some(Region::Canada)).await?;
477/// # Ok(())
478/// # }
479/// ```
480pub async fn market_summary(
481    region: Option<Region>,
482) -> Result<Vec<crate::models::market::market_summary::MarketSummaryQuote>> {
483    let client = crate::adapters::yahoo::session::get_or_auth(&ClientConfig::default()).await?;
484    crate::adapters::yahoo::market::market_summary::fetch(&client, region).await
485}
486
487/// Get trending tickers for a region
488///
489/// Returns trending stocks for a specific region.
490///
491/// # Arguments
492///
493/// * `region` - Optional region for localization. If None, uses default (US).
494///
495/// # Examples
496///
497/// ```no_run
498/// use finance_query::{finance, Region};
499///
500/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
501/// // Use default (US)
502/// let trending = finance::trending(None).await?;
503/// // Or specify a region
504/// let trending = finance::trending(Some(Region::Canada)).await?;
505/// # Ok(())
506/// # }
507/// ```
508pub async fn trending(
509    region: Option<Region>,
510) -> Result<Vec<crate::models::discovery::trending::TrendingQuote>> {
511    let client = crate::adapters::yahoo::session::get_or_auth(&ClientConfig::default()).await?;
512    crate::adapters::yahoo::market::trending::fetch(&client, region).await
513}
514
515/// Fetch the current CNN Fear & Greed Index from Alternative.me.
516///
517/// Returns a 0–100 sentiment score and its classification. No API key required.
518///
519/// # Examples
520///
521/// ```no_run
522/// use finance_query::finance;
523///
524/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
525/// let fg = finance::fear_and_greed().await?;
526/// println!("Fear & Greed: {} ({})", fg.value, fg.classification.as_str());
527/// # Ok(())
528/// # }
529/// ```
530pub async fn fear_and_greed() -> Result<crate::models::sentiment::FearAndGreed> {
531    crate::adapters::yahoo::market::fear_and_greed::fetch().await
532}
533
534/// Fetch the crypto Fear & Greed Index from Alternative.me — current value
535/// plus up to `limit - 1` historical readings (newest first).
536///
537/// Alternative.me's index specifically tracks crypto (Bitcoin) market
538/// sentiment from volatility, momentum, social media, dominance, and
539/// Google Trends signals. No API key required.
540///
541/// # Arguments
542///
543/// * `limit` - Number of readings to return, newest first (`1` for just the
544///   current value; e.g. `30` for the trailing month).
545///
546/// # Examples
547///
548/// ```no_run
549/// use finance_query::finance;
550///
551/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
552/// let history = finance::fear_and_greed_crypto(7).await?;
553/// let latest = &history[0];
554/// println!("Crypto Fear & Greed: {} ({})", latest.value, latest.classification.as_str());
555/// # Ok(())
556/// # }
557/// ```
558pub async fn fear_and_greed_crypto(
559    limit: u32,
560) -> Result<Vec<crate::models::sentiment::FearAndGreed>> {
561    crate::adapters::yahoo::market::fear_and_greed::fetch_history(limit).await
562}
563
564// ── Financial Modeling Prep (FMP) ───────────────────────────────────
565
566/// Time period for analyst estimates.
567#[cfg(feature = "fmp")]
568#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
569pub enum Period {
570    /// Annual (yearly) estimates.
571    Annual,
572    /// Quarterly estimates.
573    Quarter,
574}
575
576#[cfg(feature = "fmp")]
577impl From<Period> for crate::adapters::fmp::models::Period {
578    fn from(p: Period) -> Self {
579        match p {
580            Period::Annual => Self::Annual,
581            Period::Quarter => Self::Quarter,
582        }
583    }
584}
585
586/// An insider trading transaction record.
587#[cfg(feature = "fmp")]
588#[non_exhaustive]
589#[derive(Debug, Clone, Serialize, Deserialize)]
590pub struct InsiderTransaction {
591    /// Ticker symbol.
592    pub symbol: Option<String>,
593    /// Filing date (YYYY-MM-DD).
594    pub filing_date: Option<String>,
595    /// Transaction date (YYYY-MM-DD).
596    pub transaction_date: Option<String>,
597    /// Reporting person name.
598    pub reporting_name: Option<String>,
599    /// Transaction type (e.g., "P-Purchase", "S-Sale").
600    pub transaction_type: Option<String>,
601    /// Number of securities transacted.
602    pub securities_transacted: Option<f64>,
603    /// Price per share.
604    pub price: Option<f64>,
605    /// Securities owned after transaction.
606    pub securities_owned: Option<f64>,
607    /// Form type / owner type description.
608    pub type_of_owner: Option<String>,
609    /// Link to SEC filing.
610    pub link: Option<String>,
611}
612
613#[cfg(feature = "fmp")]
614impl From<crate::adapters::fmp::corporate::insider_trading::InsiderTradeDTO>
615    for InsiderTransaction
616{
617    fn from(d: crate::adapters::fmp::corporate::insider_trading::InsiderTradeDTO) -> Self {
618        use crate::adapters::fmp::corporate::insider_trading::InsiderTradeDTO;
619        let InsiderTradeDTO {
620            symbol,
621            filing_date,
622            transaction_date,
623            reporting_name,
624            transaction_type,
625            securities_transacted,
626            price,
627            securities_owned,
628            type_of_owner,
629            link,
630            ..
631        } = d;
632        Self {
633            symbol,
634            filing_date,
635            transaction_date,
636            reporting_name,
637            transaction_type,
638            securities_transacted,
639            price,
640            securities_owned,
641            type_of_owner,
642            link,
643        }
644    }
645}
646
647/// An analyst estimate entry (revenue, EBITDA, EPS forecasts).
648#[cfg(feature = "fmp")]
649#[non_exhaustive]
650#[derive(Debug, Clone, Serialize, Deserialize)]
651pub struct AnalystEstimate {
652    /// Ticker symbol.
653    pub symbol: Option<String>,
654    /// Estimate date.
655    pub date: Option<String>,
656    /// Estimated revenue low.
657    pub estimated_revenue_low: Option<f64>,
658    /// Estimated revenue high.
659    pub estimated_revenue_high: Option<f64>,
660    /// Estimated revenue avg.
661    pub estimated_revenue_avg: Option<f64>,
662    /// Estimated EBITDA low.
663    pub estimated_ebitda_low: Option<f64>,
664    /// Estimated EBITDA high.
665    pub estimated_ebitda_high: Option<f64>,
666    /// Estimated EBITDA avg.
667    pub estimated_ebitda_avg: Option<f64>,
668    /// Estimated EPS avg.
669    pub estimated_eps_avg: Option<f64>,
670    /// Estimated EPS high.
671    pub estimated_eps_high: Option<f64>,
672    /// Estimated EPS low.
673    pub estimated_eps_low: Option<f64>,
674    /// Number of analysts covering revenue.
675    pub number_analyst_estimated_revenue: Option<i32>,
676    /// Number of analysts covering EPS.
677    pub number_analysts_estimated_eps: Option<i32>,
678}
679
680#[cfg(feature = "fmp")]
681impl From<crate::adapters::fmp::fundamentals::estimates::AnalystEstimateDTO> for AnalystEstimate {
682    fn from(d: crate::adapters::fmp::fundamentals::estimates::AnalystEstimateDTO) -> Self {
683        use crate::adapters::fmp::fundamentals::estimates::AnalystEstimateDTO;
684        let AnalystEstimateDTO {
685            symbol,
686            date,
687            estimated_revenue_low,
688            estimated_revenue_high,
689            estimated_revenue_avg,
690            estimated_ebitda_low,
691            estimated_ebitda_high,
692            estimated_ebitda_avg,
693            estimated_eps_avg,
694            estimated_eps_high,
695            estimated_eps_low,
696            number_analyst_estimated_revenue,
697            number_analysts_estimated_eps,
698        } = d;
699        Self {
700            symbol,
701            date,
702            estimated_revenue_low,
703            estimated_revenue_high,
704            estimated_revenue_avg,
705            estimated_ebitda_low,
706            estimated_ebitda_high,
707            estimated_ebitda_avg,
708            estimated_eps_avg,
709            estimated_eps_high,
710            estimated_eps_low,
711            number_analyst_estimated_revenue,
712            number_analysts_estimated_eps,
713        }
714    }
715}
716
717/// An analyst stock recommendation (buy/hold/sell counts).
718#[cfg(feature = "fmp")]
719#[non_exhaustive]
720#[derive(Debug, Clone, Serialize, Deserialize)]
721pub struct AnalystRecommendation {
722    /// Ticker symbol.
723    pub symbol: Option<String>,
724    /// Recommendation date.
725    pub date: Option<String>,
726    /// Number of buy ratings.
727    pub analyst_ratings_buy: Option<i32>,
728    /// Number of hold ratings.
729    pub analyst_ratings_hold: Option<i32>,
730    /// Number of sell ratings.
731    pub analyst_ratings_sell: Option<i32>,
732    /// Number of strong buy ratings.
733    pub analyst_ratings_strong_buy: Option<i32>,
734    /// Number of strong sell ratings.
735    pub analyst_ratings_strong_sell: Option<i32>,
736}
737
738#[cfg(feature = "fmp")]
739impl From<crate::adapters::fmp::fundamentals::estimates::AnalystRecommendationDTO>
740    for AnalystRecommendation
741{
742    fn from(d: crate::adapters::fmp::fundamentals::estimates::AnalystRecommendationDTO) -> Self {
743        use crate::adapters::fmp::fundamentals::estimates::AnalystRecommendationDTO;
744        let AnalystRecommendationDTO {
745            symbol,
746            date,
747            analyst_ratings_buy,
748            analyst_ratings_hold,
749            analyst_ratings_sell,
750            analyst_ratings_strong_buy,
751            analyst_ratings_strong_sell,
752        } = d;
753        Self {
754            symbol,
755            date,
756            analyst_ratings_buy,
757            analyst_ratings_hold,
758            analyst_ratings_sell,
759            analyst_ratings_strong_buy,
760            analyst_ratings_strong_sell,
761        }
762    }
763}
764
765/// Fetch insider trading transactions for a symbol.
766#[cfg(feature = "fmp")]
767pub async fn insider_trading(symbol: &str, limit: u32) -> Result<Vec<InsiderTransaction>> {
768    crate::adapters::fmp::corporate::insider_trading::insider_trading(symbol, limit)
769        .await
770        .map(|v| v.into_iter().map(Into::into).collect())
771}
772
773/// Fetch analyst estimates for a symbol.
774#[cfg(feature = "fmp")]
775pub async fn analyst_estimates(symbol: &str, period: Period) -> Result<Vec<AnalystEstimate>> {
776    crate::adapters::fmp::fundamentals::estimates::analyst_estimates(symbol, period.into(), 4)
777        .await
778        .map(|v| v.into_iter().map(Into::into).collect())
779}
780
781/// Fetch analyst stock recommendations for a symbol.
782#[cfg(feature = "fmp")]
783pub async fn analyst_recommendations(symbol: &str) -> Result<Vec<AnalystRecommendation>> {
784    crate::adapters::fmp::fundamentals::estimates::analyst_recommendations(symbol)
785        .await
786        .map(|v| v.into_iter().map(Into::into).collect())
787}
788
789// ── Polygon.io ──────────────────────────────────────────────────────
790
791/// Fetch sentiment analysis for a symbol based on recent Polygon.io news.
792#[cfg(feature = "polygon")]
793pub async fn symbol_sentiment(symbol: &str) -> Result<crate::models::sentiment::SymbolSentiment> {
794    use crate::adapters::polygon;
795    let paginated = polygon::stock_news(&[("ticker", symbol), ("limit", "10")]).await?;
796    let articles = paginated.results.unwrap_or_default();
797
798    let mut positive = 0u32;
799    let mut negative = 0u32;
800    let total = articles.len().max(1) as f64;
801    for article in &articles {
802        if let Some(ref insights) = article.insights {
803            for insight in insights {
804                if insight.ticker.as_deref() == Some(symbol) {
805                    match insight.sentiment.as_deref() {
806                        Some("positive") => positive += 1,
807                        Some("negative") => negative += 1,
808                        _ => {}
809                    }
810                }
811            }
812        }
813    }
814
815    let (score, label): (Option<f64>, Option<String>) = if total > 0.0 {
816        let s = (positive as f64 - negative as f64) / total;
817        let l = if s > 0.2 {
818            "positive"
819        } else if s < -0.2 {
820            "negative"
821        } else {
822            "neutral"
823        };
824        (Some(s), Some(l.to_string()))
825    } else {
826        (None, None)
827    };
828
829    Ok(crate::models::sentiment::SymbolSentiment { score, label })
830}
831
832// ── Alpha Vantage ───────────────────────────────────────────────────
833
834/// An upcoming earnings calendar entry.
835#[cfg(feature = "alphavantage")]
836#[non_exhaustive]
837#[derive(Debug, Clone, Serialize, Deserialize)]
838pub struct EarningsCalendarEntry {
839    /// Ticker symbol.
840    pub symbol: String,
841    /// Company name.
842    pub name: Option<String>,
843    /// Report date.
844    pub report_date: Option<String>,
845    /// Fiscal date ending.
846    pub fiscal_date_ending: Option<String>,
847    /// Estimated EPS.
848    pub estimate: Option<f64>,
849    /// Currency.
850    pub currency: Option<String>,
851}
852
853#[cfg(feature = "alphavantage")]
854impl From<crate::adapters::alphavantage::models::EarningsCalendarEntryDTO>
855    for EarningsCalendarEntry
856{
857    fn from(d: crate::adapters::alphavantage::models::EarningsCalendarEntryDTO) -> Self {
858        Self {
859            symbol: d.symbol,
860            name: d.name,
861            report_date: d.report_date,
862            fiscal_date_ending: d.fiscal_date_ending,
863            estimate: d.estimate,
864            currency: d.currency,
865        }
866    }
867}
868
869/// An upcoming IPO calendar entry.
870#[cfg(feature = "alphavantage")]
871#[non_exhaustive]
872#[derive(Debug, Clone, Serialize, Deserialize)]
873pub struct IpoCalendarEntry {
874    /// Ticker symbol.
875    pub symbol: Option<String>,
876    /// Company name.
877    pub name: Option<String>,
878    /// IPO date.
879    pub ipo_date: Option<String>,
880    /// Price range (e.g., `"$15-$17"`).
881    pub price_range: Option<String>,
882    /// Exchange.
883    pub exchange: Option<String>,
884}
885
886#[cfg(feature = "alphavantage")]
887impl From<crate::adapters::alphavantage::models::IpoCalendarEntryDTO> for IpoCalendarEntry {
888    fn from(d: crate::adapters::alphavantage::models::IpoCalendarEntryDTO) -> Self {
889        Self {
890            symbol: d.symbol,
891            name: d.name,
892            ipo_date: d.ipo_date,
893            price_range: d.price_range,
894            exchange: d.exchange,
895        }
896    }
897}
898
899/// Fetch the upcoming earnings calendar (market-wide, not symbol-filtered).
900#[cfg(feature = "alphavantage")]
901pub async fn earnings_calendar() -> Result<Vec<EarningsCalendarEntry>> {
902    crate::adapters::alphavantage::fundamentals::earnings_calendar()
903        .await
904        .map(|v| v.into_iter().map(Into::into).collect())
905}
906
907/// Fetch the upcoming IPO calendar (market-wide, not symbol-filtered).
908#[cfg(feature = "alphavantage")]
909pub async fn ipo_calendar() -> Result<Vec<IpoCalendarEntry>> {
910    crate::adapters::alphavantage::fundamentals::ipo_calendar()
911        .await
912        .map(|v| v.into_iter().map(Into::into).collect())
913}
914
915#[cfg(test)]
916mod tests {
917    use super::*;
918
919    #[tokio::test]
920    #[ignore = "requires network access"]
921    async fn finance_calls_share_one_session() {
922        let config = ClientConfig::default();
923        let first = crate::adapters::yahoo::session::get_or_auth(&config)
924            .await
925            .unwrap();
926
927        let _ = search("apple", &SearchOptions::default()).await;
928        let _ = hours(None).await;
929
930        let after = crate::adapters::yahoo::session::get_or_auth(&config)
931            .await
932            .unwrap();
933        assert!(std::sync::Arc::ptr_eq(&first, &after));
934    }
935}