Skip to main content

yfinance/
analysis.rs

1//! Analyst data: recommendations, price targets, upgrades/downgrades, earnings trend.
2//!
3//! Backed by `quoteSummary` modules `recommendationTrend`, `financialData`,
4//! `upgradeDowngradeHistory`, and `earningsTrend`. Each public method on
5//! [`Ticker`] requests just the modules it needs.
6//!
7//! [`Ticker`]: crate::ticker::Ticker
8
9use serde::Deserialize;
10use serde_json::Value;
11
12use crate::client::YfClient;
13use crate::error::{Error, Result};
14use crate::wire::{
15    from_raw_f64 as raw_f64, from_raw_i64 as raw_i64, from_raw_u32 as raw_u32, RawNum,
16};
17
18/// One row from `recommendationTrend.trend`. Periods are Yahoo's relative
19/// labels (`"0m"`, `"-1m"`, `"-2m"`, `"-3m"`).
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct RecommendationRow {
22    /// Period label as returned by Yahoo (e.g. `"0m"`).
23    pub period: String,
24    /// Strong-buy ratings count.
25    pub strong_buy: Option<u32>,
26    /// Buy ratings count.
27    pub buy: Option<u32>,
28    /// Hold ratings count.
29    pub hold: Option<u32>,
30    /// Sell ratings count.
31    pub sell: Option<u32>,
32    /// Strong-sell ratings count.
33    pub strong_sell: Option<u32>,
34}
35
36/// Compact summary combining the latest recommendation period and Yahoo's
37/// numeric `recommendationMean` / `recommendationKey`.
38#[derive(Debug, Clone, PartialEq)]
39pub struct RecommendationSummary {
40    /// Latest period covered (typically `"0m"`).
41    pub latest_period: Option<String>,
42    /// Strong-buy count for the latest period.
43    pub strong_buy: Option<u32>,
44    /// Buy count.
45    pub buy: Option<u32>,
46    /// Hold count.
47    pub hold: Option<u32>,
48    /// Sell count.
49    pub sell: Option<u32>,
50    /// Strong-sell count.
51    pub strong_sell: Option<u32>,
52    /// Numeric recommendation mean (1.0 = strong buy, 5.0 = strong sell).
53    pub mean: Option<f64>,
54    /// Yahoo's text rating (`"buy"`, `"hold"`, …).
55    pub rating: Option<String>,
56}
57
58/// One row from `upgradeDowngradeHistory.history`.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct UpgradeDowngradeRow {
61    /// Event timestamp (UNIX seconds).
62    pub timestamp: i64,
63    /// Issuing firm.
64    pub firm: Option<String>,
65    /// Previous grade.
66    pub from_grade: Option<String>,
67    /// New grade.
68    pub to_grade: Option<String>,
69    /// Action (`"up"`, `"down"`, `"main"`, `"reit"`, …).
70    pub action: Option<String>,
71}
72
73/// Aggregated analyst price targets from `financialData`.
74#[derive(Debug, Clone, PartialEq)]
75pub struct PriceTarget {
76    /// Mean target price.
77    pub mean: Option<f64>,
78    /// Highest target price.
79    pub high: Option<f64>,
80    /// Lowest target price.
81    pub low: Option<f64>,
82    /// Number of analyst opinions.
83    pub number_of_analysts: Option<u32>,
84}
85
86/// One row of `earningsTrend.trend`. The same shape covers the four periods
87/// Yahoo reports (`"0q"`, `"+1q"`, `"0y"`, `"+1y"`).
88#[derive(Debug, Clone, PartialEq)]
89pub struct EarningsTrendRow {
90    /// Period label.
91    pub period: String,
92    /// Year-over-year growth estimate.
93    pub growth: Option<f64>,
94    /// Earnings-per-share estimates.
95    pub earnings_estimate: EarningsEstimate,
96    /// Revenue estimates.
97    pub revenue_estimate: RevenueEstimate,
98    /// EPS trend across the last 90 days.
99    pub eps_trend: EpsTrend,
100    /// Up/down revisions over 7- and 30-day windows.
101    pub eps_revisions: EpsRevisions,
102}
103
104/// Earnings-per-share estimate for one period.
105#[derive(Debug, Clone, Default, PartialEq)]
106pub struct EarningsEstimate {
107    /// Mean estimate.
108    pub avg: Option<f64>,
109    /// Lowest estimate.
110    pub low: Option<f64>,
111    /// Highest estimate.
112    pub high: Option<f64>,
113    /// EPS the same period a year ago (for growth calculation).
114    pub year_ago_eps: Option<f64>,
115    /// Number of analysts contributing.
116    pub num_analysts: Option<u32>,
117    /// Year-over-year growth fraction.
118    pub growth: Option<f64>,
119}
120
121/// Revenue estimate for one period (raw values are dollars).
122#[derive(Debug, Clone, Default, PartialEq)]
123pub struct RevenueEstimate {
124    /// Mean estimate.
125    pub avg: Option<i64>,
126    /// Lowest estimate.
127    pub low: Option<i64>,
128    /// Highest estimate.
129    pub high: Option<i64>,
130    /// Revenue the same period a year ago.
131    pub year_ago_revenue: Option<i64>,
132    /// Number of analysts contributing.
133    pub num_analysts: Option<u32>,
134    /// Year-over-year growth fraction (decimal, e.g. `0.157` for +15.7%).
135    pub growth: Option<f64>,
136}
137
138/// EPS trend snapshot — current consensus and 7/30/60/90-day-old values.
139#[derive(Debug, Clone, Default, PartialEq)]
140pub struct EpsTrend {
141    /// Current consensus.
142    pub current: Option<f64>,
143    /// Consensus 7 days ago.
144    pub seven_days_ago: Option<f64>,
145    /// Consensus 30 days ago.
146    pub thirty_days_ago: Option<f64>,
147    /// Consensus 60 days ago.
148    pub sixty_days_ago: Option<f64>,
149    /// Consensus 90 days ago.
150    pub ninety_days_ago: Option<f64>,
151}
152
153/// Up- and down-revision counts over 7 and 30 days.
154#[derive(Debug, Clone, Default, PartialEq, Eq)]
155pub struct EpsRevisions {
156    /// Up-revisions in the last 7 days.
157    pub up_last_7_days: Option<u32>,
158    /// Up-revisions in the last 30 days.
159    pub up_last_30_days: Option<u32>,
160    /// Down-revisions in the last 7 days.
161    pub down_last_7_days: Option<u32>,
162    /// Down-revisions in the last 30 days.
163    pub down_last_30_days: Option<u32>,
164}
165
166#[derive(Deserialize)]
167struct V10Result {
168    #[serde(default, rename = "recommendationTrend")]
169    recommendation_trend: Option<RecommendationTrendNode>,
170    #[serde(default, rename = "upgradeDowngradeHistory")]
171    upgrade_downgrade_history: Option<UpgradeDowngradeHistoryNode>,
172    #[serde(default, rename = "financialData")]
173    financial_data: Option<FinancialDataNode>,
174    #[serde(default, rename = "earningsTrend")]
175    earnings_trend: Option<EarningsTrendNode>,
176}
177
178#[derive(Deserialize)]
179struct RecommendationTrendNode {
180    #[serde(default)]
181    trend: Option<Vec<RecommendationNode>>,
182}
183
184#[derive(Deserialize)]
185struct RecommendationNode {
186    #[serde(default)]
187    period: Option<String>,
188    #[serde(default, rename = "strongBuy")]
189    strong_buy: Option<i64>,
190    #[serde(default)]
191    buy: Option<i64>,
192    #[serde(default)]
193    hold: Option<i64>,
194    #[serde(default)]
195    sell: Option<i64>,
196    #[serde(default, rename = "strongSell")]
197    strong_sell: Option<i64>,
198}
199
200#[derive(Deserialize)]
201struct UpgradeDowngradeHistoryNode {
202    #[serde(default)]
203    history: Option<Vec<UpgradeNode>>,
204}
205
206#[derive(Deserialize)]
207struct UpgradeNode {
208    #[serde(default, rename = "epochGradeDate")]
209    epoch_grade_date: Option<i64>,
210    #[serde(default)]
211    firm: Option<String>,
212    #[serde(default, rename = "toGrade")]
213    to_grade: Option<String>,
214    #[serde(default, rename = "fromGrade")]
215    from_grade: Option<String>,
216    #[serde(default)]
217    action: Option<String>,
218    #[serde(default, rename = "gradeChange")]
219    grade_change: Option<String>,
220}
221
222#[derive(Deserialize)]
223struct FinancialDataNode {
224    #[serde(default, rename = "targetMeanPrice")]
225    target_mean_price: Option<RawNum<f64>>,
226    #[serde(default, rename = "targetHighPrice")]
227    target_high_price: Option<RawNum<f64>>,
228    #[serde(default, rename = "targetLowPrice")]
229    target_low_price: Option<RawNum<f64>>,
230    #[serde(default, rename = "numberOfAnalystOpinions")]
231    number_of_analyst_opinions: Option<RawNum<f64>>,
232    #[serde(default, rename = "recommendationMean")]
233    recommendation_mean: Option<RawNum<f64>>,
234    #[serde(default, rename = "recommendationKey")]
235    recommendation_key: Option<String>,
236}
237
238#[derive(Deserialize)]
239struct EarningsTrendNode {
240    #[serde(default)]
241    trend: Option<Vec<EarningsTrendItemNode>>,
242}
243
244#[derive(Deserialize)]
245struct EarningsTrendItemNode {
246    #[serde(default)]
247    period: Option<String>,
248    #[serde(default)]
249    growth: Option<RawNum<f64>>,
250    #[serde(default, rename = "earningsEstimate")]
251    earnings_estimate: Option<EarningsEstimateNode>,
252    #[serde(default, rename = "revenueEstimate")]
253    revenue_estimate: Option<RevenueEstimateNode>,
254    #[serde(default, rename = "epsTrend")]
255    eps_trend: Option<EpsTrendNode>,
256    #[serde(default, rename = "epsRevisions")]
257    eps_revisions: Option<EpsRevisionsNode>,
258}
259
260#[derive(Deserialize, Default)]
261struct EarningsEstimateNode {
262    #[serde(default)]
263    avg: Option<RawNum<f64>>,
264    #[serde(default)]
265    low: Option<RawNum<f64>>,
266    #[serde(default)]
267    high: Option<RawNum<f64>>,
268    #[serde(default, rename = "yearAgoEps")]
269    year_ago_eps: Option<RawNum<f64>>,
270    #[serde(default, rename = "numberOfAnalysts")]
271    num_analysts: Option<RawNum<f64>>,
272    #[serde(default)]
273    growth: Option<RawNum<f64>>,
274}
275
276#[derive(Deserialize, Default)]
277struct RevenueEstimateNode {
278    #[serde(default)]
279    avg: Option<RawNum<i64>>,
280    #[serde(default)]
281    low: Option<RawNum<i64>>,
282    #[serde(default)]
283    high: Option<RawNum<i64>>,
284    #[serde(default, rename = "yearAgoRevenue")]
285    year_ago_revenue: Option<RawNum<i64>>,
286    #[serde(default, rename = "numberOfAnalysts")]
287    num_analysts: Option<RawNum<f64>>,
288    #[serde(default)]
289    growth: Option<RawNum<f64>>,
290}
291
292#[derive(Deserialize, Default)]
293struct EpsTrendNode {
294    #[serde(default)]
295    current: Option<RawNum<f64>>,
296    #[serde(default, rename = "7daysAgo")]
297    seven_days_ago: Option<RawNum<f64>>,
298    #[serde(default, rename = "30daysAgo")]
299    thirty_days_ago: Option<RawNum<f64>>,
300    #[serde(default, rename = "60daysAgo")]
301    sixty_days_ago: Option<RawNum<f64>>,
302    #[serde(default, rename = "90daysAgo")]
303    ninety_days_ago: Option<RawNum<f64>>,
304}
305
306#[derive(Deserialize, Default)]
307struct EpsRevisionsNode {
308    #[serde(default, rename = "upLast7days")]
309    up_last_7_days: Option<RawNum<f64>>,
310    #[serde(default, rename = "upLast30days")]
311    up_last_30_days: Option<RawNum<f64>>,
312    #[serde(default, rename = "downLast7days")]
313    down_last_7_days: Option<RawNum<f64>>,
314    #[serde(default, rename = "downLast30days")]
315    down_last_30_days: Option<RawNum<f64>>,
316}
317
318async fn fetch_modules(
319    client: &YfClient,
320    symbol: &str,
321    modules: &str,
322    fixture_label: &str,
323) -> Result<V10Result> {
324    let Some(map) = client
325        .fetch_quote_summary(symbol, modules, fixture_label)
326        .await?
327    else {
328        return Ok(V10Result {
329            recommendation_trend: None,
330            upgrade_downgrade_history: None,
331            financial_data: None,
332            earnings_trend: None,
333        });
334    };
335    serde_json::from_value(Value::Object(map)).map_err(Error::from)
336}
337
338/// Per-period analyst counts (`recommendationTrend`).
339pub(crate) async fn recommendations(
340    client: &YfClient,
341    symbol: &str,
342) -> Result<Vec<RecommendationRow>> {
343    let r = fetch_modules(
344        client,
345        symbol,
346        "recommendationTrend",
347        "analysis_recommendationTrend",
348    )
349    .await?;
350    let trend = r
351        .recommendation_trend
352        .and_then(|x| x.trend)
353        .unwrap_or_default();
354    Ok(trend
355        .into_iter()
356        .map(|n| RecommendationRow {
357            period: n.period.unwrap_or_default(),
358            strong_buy: n.strong_buy.and_then(|v| u32::try_from(v).ok()),
359            buy: n.buy.and_then(|v| u32::try_from(v).ok()),
360            hold: n.hold.and_then(|v| u32::try_from(v).ok()),
361            sell: n.sell.and_then(|v| u32::try_from(v).ok()),
362            strong_sell: n.strong_sell.and_then(|v| u32::try_from(v).ok()),
363        })
364        .collect())
365}
366
367/// Latest period plus `recommendationMean` / `recommendationKey`.
368pub(crate) async fn recommendations_summary(
369    client: &YfClient,
370    symbol: &str,
371) -> Result<RecommendationSummary> {
372    let r = fetch_modules(
373        client,
374        symbol,
375        "recommendationTrend,financialData",
376        "analysis_recommendationTrend-financialData",
377    )
378    .await?;
379    let latest = r
380        .recommendation_trend
381        .and_then(|x| x.trend.and_then(|t| t.into_iter().next()));
382    let (period, sb, b, h, s, ss) = latest.map_or((None, None, None, None, None, None), |t| {
383        (
384            t.period,
385            t.strong_buy.and_then(|v| u32::try_from(v).ok()),
386            t.buy.and_then(|v| u32::try_from(v).ok()),
387            t.hold.and_then(|v| u32::try_from(v).ok()),
388            t.sell.and_then(|v| u32::try_from(v).ok()),
389            t.strong_sell.and_then(|v| u32::try_from(v).ok()),
390        )
391    });
392    let (mean, rating) = r.financial_data.map_or((None, None), |fd| {
393        (raw_f64(fd.recommendation_mean), fd.recommendation_key)
394    });
395    Ok(RecommendationSummary {
396        latest_period: period,
397        strong_buy: sb,
398        buy: b,
399        hold: h,
400        sell: s,
401        strong_sell: ss,
402        mean,
403        rating,
404    })
405}
406
407/// History of analyst upgrades and downgrades, oldest first.
408pub(crate) async fn upgrades_downgrades(
409    client: &YfClient,
410    symbol: &str,
411) -> Result<Vec<UpgradeDowngradeRow>> {
412    let r = fetch_modules(
413        client,
414        symbol,
415        "upgradeDowngradeHistory",
416        "analysis_upgradeDowngradeHistory",
417    )
418    .await?;
419    let mut rows: Vec<_> = r
420        .upgrade_downgrade_history
421        .and_then(|x| x.history)
422        .unwrap_or_default()
423        .into_iter()
424        .map(|h| UpgradeDowngradeRow {
425            timestamp: h.epoch_grade_date.unwrap_or_default(),
426            firm: h.firm,
427            from_grade: h.from_grade,
428            to_grade: h.to_grade,
429            action: h.action.or(h.grade_change),
430        })
431        .collect();
432    rows.sort_by_key(|r| r.timestamp);
433    Ok(rows)
434}
435
436/// Mean / high / low analyst price target plus contributor count.
437pub(crate) async fn price_target(client: &YfClient, symbol: &str) -> Result<PriceTarget> {
438    let r = fetch_modules(client, symbol, "financialData", "analysis_financialData").await?;
439    let fd = r
440        .financial_data
441        .ok_or_else(|| Error::invalid("analysis: financialData module missing"))?;
442    Ok(PriceTarget {
443        mean: raw_f64(fd.target_mean_price),
444        high: raw_f64(fd.target_high_price),
445        low: raw_f64(fd.target_low_price),
446        number_of_analysts: raw_u32(fd.number_of_analyst_opinions),
447    })
448}
449
450/// Earnings & revenue estimates, EPS trend, EPS revisions for the four
451/// reporting periods Yahoo exposes (`0q`, `+1q`, `0y`, `+1y`).
452pub(crate) async fn earnings_trend(
453    client: &YfClient,
454    symbol: &str,
455) -> Result<Vec<EarningsTrendRow>> {
456    let r = fetch_modules(client, symbol, "earningsTrend", "analysis_earningsTrend").await?;
457    let trend = r.earnings_trend.and_then(|x| x.trend).unwrap_or_default();
458    Ok(trend
459        .into_iter()
460        .map(|n| EarningsTrendRow {
461            period: n.period.unwrap_or_default(),
462            growth: raw_f64(n.growth),
463            earnings_estimate: n
464                .earnings_estimate
465                .map(|e| EarningsEstimate {
466                    avg: raw_f64(e.avg),
467                    low: raw_f64(e.low),
468                    high: raw_f64(e.high),
469                    year_ago_eps: raw_f64(e.year_ago_eps),
470                    num_analysts: raw_u32(e.num_analysts),
471                    growth: raw_f64(e.growth),
472                })
473                .unwrap_or_default(),
474            revenue_estimate: n
475                .revenue_estimate
476                .map(|e| RevenueEstimate {
477                    avg: raw_i64(e.avg),
478                    low: raw_i64(e.low),
479                    high: raw_i64(e.high),
480                    year_ago_revenue: raw_i64(e.year_ago_revenue),
481                    num_analysts: raw_u32(e.num_analysts),
482                    growth: raw_f64(e.growth),
483                })
484                .unwrap_or_default(),
485            eps_trend: n
486                .eps_trend
487                .map(|e| EpsTrend {
488                    current: raw_f64(e.current),
489                    seven_days_ago: raw_f64(e.seven_days_ago),
490                    thirty_days_ago: raw_f64(e.thirty_days_ago),
491                    sixty_days_ago: raw_f64(e.sixty_days_ago),
492                    ninety_days_ago: raw_f64(e.ninety_days_ago),
493                })
494                .unwrap_or_default(),
495            eps_revisions: n
496                .eps_revisions
497                .map(|e| EpsRevisions {
498                    up_last_7_days: raw_u32(e.up_last_7_days),
499                    up_last_30_days: raw_u32(e.up_last_30_days),
500                    down_last_7_days: raw_u32(e.down_last_7_days),
501                    down_last_30_days: raw_u32(e.down_last_30_days),
502                })
503                .unwrap_or_default(),
504        })
505        .collect())
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn parses_recommendation_trend() {
514        let v: V10Result = serde_json::from_value(serde_json::json!({
515            "recommendationTrend": {
516                "trend": [
517                    {"period": "0m", "strongBuy": 5, "buy": 10, "hold": 3, "sell": 1, "strongSell": 0}
518                ]
519            }
520        }))
521        .unwrap();
522        let trend = v.recommendation_trend.unwrap().trend.unwrap();
523        assert_eq!(trend.len(), 1);
524        assert_eq!(trend[0].buy, Some(10));
525    }
526
527    #[test]
528    fn raw_u32_rounds_and_filters() {
529        assert_eq!(raw_u32(Some(RawNum { raw: Some(3.6) })), Some(4));
530        assert_eq!(raw_u32(Some(RawNum { raw: Some(-1.0) })), None);
531        assert_eq!(raw_u32(None::<RawNum<f64>>), None);
532    }
533}