Skip to main content

paft_fundamentals/
analysis.rs

1//! Analyst, recommendations, and earnings-related types under `paft_fundamentals::analysis`.
2
3use serde::{Deserialize, Serialize};
4use std::str::FromStr;
5
6use chrono::{DateTime, Utc};
7#[cfg(feature = "dataframe")]
8use df_derive_macros::ToDataFrame;
9use paft_decimal::Decimal;
10use paft_domain::{DomainError, Horizon, PeriodYear, ReportingPeriod};
11use paft_money::{Money, Price};
12
13use crate::FundamentalsError;
14
15paft_core::other_string_code_type!(
16    /// Provider-specific recommendation grade not modeled by [`RecommendationGrade`].
17    pub struct OtherRecommendationGrade for RecommendationGrade;
18    type Error = FundamentalsError;
19    parse(input) => RecommendationGrade::from_str(input);
20    invalid(input) => FundamentalsError::InvalidEnumValue {
21        enum_name: "RecommendationGrade",
22        value: input.to_string(),
23    };
24);
25
26/// Analyst recommendation grades with canonical variants and extensible fallback.
27///
28/// This enum provides type-safe handling of recommendation grades while gracefully
29/// handling unknown or provider-specific grades through the `Other` variant.
30///
31/// Canonical/serde rules:
32/// - Emission uses a single canonical form per variant (UPPERCASE ASCII, no spaces)
33/// - Parser accepts a superset of tokens (aliases, case-insensitive)
34/// - `Other(s)` serializes to its canonical `code()` string (no escape prefix)
35/// - `Display` output matches the canonical code for known variants and the raw `s` for `Other(s)`
36/// - Serde round-trips preserve identity for canonical variants; unknown tokens normalize to `Other(UPPERCASE)`
37#[derive(Debug, Clone, PartialEq, Eq, Hash)]
38#[non_exhaustive]
39pub enum RecommendationGrade {
40    /// Strong buy recommendation
41    StrongBuy,
42    /// Buy recommendation
43    Buy,
44    /// Hold recommendation
45    Hold,
46    /// Sell recommendation
47    Sell,
48    /// Strong sell recommendation
49    StrongSell,
50    /// Outperform recommendation
51    Outperform,
52    /// Underperform recommendation
53    Underperform,
54    /// Unknown or provider-specific grade
55    Other(OtherRecommendationGrade),
56}
57
58impl RecommendationGrade {
59    /// Attempts to parse a recommendation grade, uppercasing unknown inputs into `Other`.
60    ///
61    /// # Errors
62    /// Returns `FundamentalsError::InvalidEnumValue` when `input` is empty/whitespace.
63    #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", err))]
64    pub fn try_from_str(input: &str) -> Result<Self, FundamentalsError> {
65        Self::from_str(input)
66    }
67
68    /// Builds an unknown recommendation grade, rejecting modeled grades and aliases.
69    ///
70    /// # Errors
71    /// Returns an error if `input` is empty, cannot be canonicalized, or parses
72    /// to a modeled [`RecommendationGrade`] variant.
73    pub fn other(input: &str) -> Result<Self, FundamentalsError> {
74        OtherRecommendationGrade::new(input).map(Self::Other)
75    }
76}
77
78// serde via macro
79
80// Implement code() and string impls via macro
81paft_core::string_enum_with_code!(
82    RecommendationGrade, Other(OtherRecommendationGrade), "RecommendationGrade",
83    type Error = FundamentalsError;
84    invalid(input) => FundamentalsError::InvalidEnumValue {
85        enum_name: "RecommendationGrade",
86        value: input.to_string(),
87    };
88    {
89        "STRONG_BUY" => RecommendationGrade::StrongBuy,
90        "BUY" => RecommendationGrade::Buy,
91        "HOLD" => RecommendationGrade::Hold,
92        "SELL" => RecommendationGrade::Sell,
93        "STRONG_SELL" => RecommendationGrade::StrongSell,
94        "OUTPERFORM" => RecommendationGrade::Outperform,
95        "UNDERPERFORM" => RecommendationGrade::Underperform
96    },
97    {
98        // Aliases
99        "NEUTRAL" => RecommendationGrade::Hold,
100        "MARKET_PERFORM" => RecommendationGrade::Hold,
101        "OVERWEIGHT" => RecommendationGrade::Outperform,
102        "UNDERWEIGHT" => RecommendationGrade::Underperform
103    }
104);
105
106// Display should match code for these enums
107paft_core::impl_display_via_code!(RecommendationGrade);
108
109paft_core::other_string_code_type!(
110    /// Provider-specific recommendation action not modeled by [`RecommendationAction`].
111    pub struct OtherRecommendationAction for RecommendationAction;
112    type Error = FundamentalsError;
113    parse(input) => RecommendationAction::from_str(input);
114    invalid(input) => FundamentalsError::InvalidEnumValue {
115        enum_name: "RecommendationAction",
116        value: input.to_string(),
117    };
118);
119
120/// Analyst recommendation actions with canonical variants and extensible fallback.
121///
122/// This enum provides type-safe handling of recommendation actions while gracefully
123/// handling unknown or provider-specific actions through the `Other` variant.
124///
125/// Canonical/serde rules:
126/// - Emission uses a single canonical form per variant (UPPERCASE ASCII, no spaces)
127/// - Parser accepts a superset of tokens (aliases, case-insensitive)
128/// - `Other(s)` serializes to its canonical `code()` string (no escape prefix)
129/// - `Display` output matches the canonical code for known variants and the raw `s` for `Other(s)`
130/// - Serde round-trips preserve identity for canonical variants; unknown tokens normalize to `Other(UPPERCASE)`
131#[derive(Debug, Clone, PartialEq, Eq, Hash)]
132#[non_exhaustive]
133pub enum RecommendationAction {
134    /// Upgrade action
135    Upgrade,
136    /// Downgrade action
137    Downgrade,
138    /// Initiate coverage
139    Initiate,
140    /// Maintain or reiterate recommendation
141    Maintain,
142    /// Resume coverage
143    Resume,
144    /// Suspend coverage
145    Suspend,
146    /// Unknown or provider-specific action
147    Other(OtherRecommendationAction),
148}
149
150impl RecommendationAction {
151    /// Attempts to parse a recommendation action, uppercasing unknown inputs into `Other`.
152    ///
153    /// # Errors
154    /// Returns `FundamentalsError::InvalidEnumValue` when `input` is empty/whitespace.
155    #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", err))]
156    pub fn try_from_str(input: &str) -> Result<Self, FundamentalsError> {
157        Self::from_str(input)
158    }
159
160    /// Builds an unknown recommendation action, rejecting modeled actions and aliases.
161    ///
162    /// # Errors
163    /// Returns an error if `input` is empty, cannot be canonicalized, or parses
164    /// to a modeled [`RecommendationAction`] variant.
165    pub fn other(input: &str) -> Result<Self, FundamentalsError> {
166        OtherRecommendationAction::new(input).map(Self::Other)
167    }
168}
169
170// Implement code() and string impls via macro
171paft_core::string_enum_with_code!(
172    RecommendationAction, Other(OtherRecommendationAction), "RecommendationAction",
173    type Error = FundamentalsError;
174    invalid(input) => FundamentalsError::InvalidEnumValue {
175        enum_name: "RecommendationAction",
176        value: input.to_string(),
177    };
178    {
179        "UPGRADE" => RecommendationAction::Upgrade,
180        "DOWNGRADE" => RecommendationAction::Downgrade,
181        "INITIATE" => RecommendationAction::Initiate,
182        "MAINTAIN" => RecommendationAction::Maintain,
183        "RESUME" => RecommendationAction::Resume,
184        "SUSPEND" => RecommendationAction::Suspend
185    },
186    {
187        // Aliases
188        "UP" => RecommendationAction::Upgrade,
189        "DOWN" => RecommendationAction::Downgrade,
190        "INIT" => RecommendationAction::Initiate,
191        "INITIATED" => RecommendationAction::Initiate,
192        "REITERATE" => RecommendationAction::Maintain
193    }
194);
195
196paft_core::impl_display_via_code!(RecommendationAction);
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
199#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
200/// Earnings datasets: yearly summaries and quarterly breakdowns.
201pub struct Earnings {
202    /// Annual earnings summary rows.
203    pub yearly: Vec<EarningsYear>,
204    /// Quarterly earnings summary rows.
205    pub quarterly: Vec<EarningsQuarter>,
206    /// Quarterly EPS actual vs estimate rows.
207    pub quarterly_eps: Vec<EarningsQuarterEps>,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
212/// Yearly earnings summary.
213pub struct EarningsYear {
214    /// Fiscal year.
215    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
216    pub year: PeriodYear,
217    /// Revenue for the year.
218    pub revenue: Option<Money>,
219    /// Earnings for the year.
220    pub earnings: Option<Money>,
221}
222
223impl EarningsYear {
224    /// Builds a yearly earnings summary with validated fiscal year.
225    ///
226    /// # Errors
227    /// Returns [`DomainError::InvalidPeriodYear`] when `year` is outside
228    /// `0..=9999`.
229    pub fn new(year: i32) -> Result<Self, DomainError> {
230        Ok(Self {
231            year: PeriodYear::new(year)?,
232            revenue: None,
233            earnings: None,
234        })
235    }
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
240/// Quarterly earnings summary for a period key (e.g., 2023Q4 or 2023-10-01).
241pub struct EarningsQuarter {
242    /// `ReportingPeriod` with structured variants and extensible fallback.
243    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
244    pub period: ReportingPeriod,
245    /// Revenue for the period.
246    pub revenue: Option<Money>,
247    /// Earnings for the period.
248    pub earnings: Option<Money>,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
253/// Quarterly EPS actual vs estimate for a period key.
254pub struct EarningsQuarterEps {
255    /// `ReportingPeriod` with structured variants and extensible fallback.
256    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
257    pub period: ReportingPeriod,
258    /// Actual EPS.
259    pub actual: Option<Price>,
260    /// Estimated EPS.
261    pub estimate: Option<Price>,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
265#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
266/// Analyst price target summary.
267pub struct PriceTarget {
268    /// Mean price target.
269    pub mean: Option<Price>,
270    /// High price target.
271    pub high: Option<Price>,
272    /// Low price target.
273    pub low: Option<Price>,
274    /// Number of contributing analysts.
275    pub number_of_analysts: Option<u32>,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
280/// Distribution of analyst recommendations for a period.
281pub struct RecommendationRow {
282    /// `ReportingPeriod` with structured variants and extensible fallback.
283    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
284    pub period: ReportingPeriod,
285    /// Count of "strong buy" recommendations.
286    pub strong_buy: Option<u32>,
287    /// Count of "buy" recommendations.
288    pub buy: Option<u32>,
289    /// Count of "hold" recommendations.
290    pub hold: Option<u32>,
291    /// Count of "sell" recommendations.
292    pub sell: Option<u32>,
293    /// Count of "strong sell" recommendations.
294    pub strong_sell: Option<u32>,
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
298#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
299/// Summary of analyst recommendations and mean scoring.
300pub struct RecommendationSummary {
301    /// Most recent period of the summary.
302    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
303    pub latest_period: Option<ReportingPeriod>,
304    /// Count of "strong buy" recommendations.
305    pub strong_buy: Option<u32>,
306    /// Count of "buy" recommendations.
307    pub buy: Option<u32>,
308    /// Count of "hold" recommendations.
309    pub hold: Option<u32>,
310    /// Count of "sell" recommendations.
311    pub sell: Option<u32>,
312    /// Count of "strong sell" recommendations.
313    pub strong_sell: Option<u32>,
314    /// Mean recommendation score.
315    #[serde(default, with = "paft_decimal::serde::option_canonical_str")]
316    pub mean: Option<Decimal>,
317    /// Provider-specific text for the mean score (e.g., "Buy", "Overweight").
318    pub mean_rating_text: Option<String>,
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
323/// Broker action history for an instrument.
324pub struct UpgradeDowngradeRow {
325    /// Event timestamp.
326    #[serde(with = "chrono::serde::ts_milliseconds")]
327    pub ts: DateTime<Utc>,
328    /// Research firm name.
329    pub firm: Option<String>,
330    /// Previous rating with canonical variants and extensible fallback.
331    #[cfg_attr(feature = "dataframe", df_derive(as_str))]
332    pub from_grade: Option<RecommendationGrade>,
333    /// New rating with canonical variants and extensible fallback.
334    #[cfg_attr(feature = "dataframe", df_derive(as_str))]
335    pub to_grade: Option<RecommendationGrade>,
336    /// Action description with canonical variants and extensible fallback.
337    #[cfg_attr(feature = "dataframe", df_derive(as_str))]
338    pub action: Option<RecommendationAction>,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
342#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
343/// Summary of key analysis metrics extracted from detailed analysis data.
344pub struct AnalysisSummary {
345    /// Analyst target mean price.
346    pub target_mean_price: Option<Price>,
347    /// Analyst target high price.
348    pub target_high_price: Option<Price>,
349    /// Analyst target low price.
350    pub target_low_price: Option<Price>,
351    /// Number of analyst opinions contributing to the recommendation.
352    pub number_of_analyst_opinions: Option<u32>,
353    /// Numeric recommendation score (provider-defined scale).
354    #[serde(default, with = "paft_decimal::serde::option_canonical_str")]
355    pub recommendation_mean: Option<Decimal>,
356    /// Categorical recommendation text (e.g., "Buy", "Overweight").
357    pub recommendation_text: Option<String>,
358}
359
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
361#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
362/// Earnings estimate data with analyst consensus.
363pub struct EarningsEstimate {
364    /// Average earnings estimate.
365    pub avg: Option<Price>,
366    /// Low earnings estimate.
367    pub low: Option<Price>,
368    /// High earnings estimate.
369    pub high: Option<Price>,
370    /// Earnings per share from a year ago.
371    pub year_ago_eps: Option<Price>,
372    /// Number of analysts providing earnings estimates.
373    pub num_analysts: Option<u32>,
374    /// Estimated earnings growth.
375    #[serde(default, with = "paft_decimal::serde::option_canonical_str")]
376    pub growth: Option<Decimal>,
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
380#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
381/// Revenue estimate data with analyst consensus.
382pub struct RevenueEstimate {
383    /// Average revenue estimate.
384    pub avg: Option<Money>,
385    /// Low revenue estimate.
386    pub low: Option<Money>,
387    /// High revenue estimate.
388    pub high: Option<Money>,
389    /// Revenue from a year ago.
390    pub year_ago_revenue: Option<Money>,
391    /// Number of analysts providing revenue estimates.
392    pub num_analysts: Option<u32>,
393    /// Estimated revenue growth.
394    #[serde(default, with = "paft_decimal::serde::option_canonical_str")]
395    pub growth: Option<Decimal>,
396}
397
398/// A flexible data point for time-series trend data.
399///
400/// This struct allows any provider to represent trend data for any lookback
401/// horizon, making the system provider-agnostic instead of tied to specific
402/// hardcoded buckets.
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
405pub struct TrendPoint {
406    /// The lookback horizon this data point represents (e.g., "7d", "1mo", "3mo").
407    /// This allows providers to use their own horizon conventions.
408    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
409    pub horizon: Horizon,
410    /// The value for this horizon.
411    pub value: Price,
412}
413
414impl TrendPoint {
415    /// Creates a new trend point with the specified horizon and value.
416    #[must_use]
417    pub const fn new(horizon: Horizon, value: Price) -> Self {
418        Self { horizon, value }
419    }
420
421    /// Creates a new trend point from a horizon string.
422    ///
423    /// # Errors
424    /// Returns an error if the horizon string cannot be parsed.
425    #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", err))]
426    pub fn try_new_str(horizon: &str, value: Price) -> Result<Self, DomainError> {
427        Ok(Self {
428            horizon: horizon.parse()?,
429            value,
430        })
431    }
432}
433
434/// EPS trend changes over different lookback horizons.
435///
436/// This struct now uses a flexible collection of trend points instead of
437/// hardcoded time buckets, making it provider-agnostic.
438#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
439#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
440pub struct EpsTrend {
441    /// Current EPS trend.
442    pub current: Option<Price>,
443    /// Historical EPS trend data points with flexible lookback horizons.
444    /// Each provider can populate this with their available horizons
445    /// (e.g., a generic provider might use "7d", "30d", "60d", "90d" while another
446    /// provider might use "1mo", "3mo", "6mo").
447    pub historical: Vec<TrendPoint>,
448}
449
450impl EpsTrend {
451    /// Creates a new EPS trend with the specified current value and historical data.
452    #[must_use]
453    pub const fn new(current: Option<Price>, historical: Vec<TrendPoint>) -> Self {
454        Self {
455            current,
456            historical,
457        }
458    }
459
460    /// Finds a trend point by horizon.
461    #[must_use]
462    pub fn find_by_horizon(&self, horizon: &Horizon) -> Option<&TrendPoint> {
463        self.historical
464            .iter()
465            .find(|point| &point.horizon == horizon)
466    }
467
468    /// Finds a trend point by horizon string.
469    ///
470    /// Parses `horizon` using [`Horizon`]'s string parser and performs the lookup.
471    ///
472    /// # Errors
473    /// Returns `DomainError` if the provided `horizon` string cannot be parsed.
474    pub fn find_by_horizon_str(&self, horizon: &str) -> Result<Option<&TrendPoint>, DomainError> {
475        let parsed: Horizon = horizon.parse()?;
476        Ok(self.find_by_horizon(&parsed))
477    }
478
479    /// Returns all available horizons in the historical data.
480    #[must_use]
481    pub fn available_horizons(&self) -> Vec<Horizon> {
482        self.historical
483            .iter()
484            .map(|point| point.horizon.clone())
485            .collect()
486    }
487}
488
489/// A flexible data point for revision counts over different lookback horizons.
490///
491/// This struct allows any provider to represent revision data for any lookback
492/// horizon, making the system provider-agnostic instead of tied to specific
493/// hardcoded buckets.
494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
495#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
496pub struct RevisionPoint {
497    /// The lookback horizon this data point represents (e.g., "7d", "1mo", "3mo").
498    /// This allows providers to use their own horizon conventions.
499    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
500    pub horizon: Horizon,
501    /// Number of upward revisions in this horizon.
502    pub up_count: u32,
503    /// Number of downward revisions in this horizon.
504    pub down_count: u32,
505}
506
507impl RevisionPoint {
508    /// Creates a new revision point with the specified horizon and counts.
509    #[must_use]
510    pub const fn new(horizon: Horizon, up_count: u32, down_count: u32) -> Self {
511        Self {
512            horizon,
513            up_count,
514            down_count,
515        }
516    }
517
518    /// Creates a new revision point from a horizon string.
519    ///
520    /// # Errors
521    /// Returns an error if the horizon string cannot be parsed.
522    #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", err))]
523    pub fn try_new_str(horizon: &str, up: u32, down: u32) -> Result<Self, DomainError> {
524        Ok(Self {
525            horizon: horizon.parse()?,
526            up_count: up,
527            down_count: down,
528        })
529    }
530
531    /// Returns the total number of revisions (up + down) in this horizon.
532    #[must_use]
533    pub fn total_revisions(&self) -> u64 {
534        u64::from(self.up_count) + u64::from(self.down_count)
535    }
536
537    /// Returns the net revision count (up - down) in this horizon.
538    /// Positive values indicate more upward revisions, negative values indicate more downward revisions.
539    #[must_use]
540    pub fn net_revisions(&self) -> i64 {
541        i64::from(self.up_count) - i64::from(self.down_count)
542    }
543}
544
545/// EPS revisions tracking upward and downward changes.
546///
547/// This struct now uses a flexible collection of revision points instead of
548/// hardcoded time buckets, making it provider-agnostic.
549#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
550#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
551pub struct EpsRevisions {
552    /// Historical EPS revision data points with flexible lookback horizons.
553    /// Each provider can populate this with their available horizons
554    /// (e.g., a generic provider might use "7d", "30d" while another provider might
555    /// use "1mo", "3mo", "6mo").
556    pub historical: Vec<RevisionPoint>,
557}
558
559impl EpsRevisions {
560    /// Creates a new EPS revisions struct with the specified historical data.
561    #[must_use]
562    pub const fn new(historical: Vec<RevisionPoint>) -> Self {
563        Self { historical }
564    }
565
566    /// Finds a revision point by horizon.
567    #[must_use]
568    pub fn find_by_horizon(&self, horizon: &Horizon) -> Option<&RevisionPoint> {
569        self.historical
570            .iter()
571            .find(|point| &point.horizon == horizon)
572    }
573
574    /// Finds a revision point by horizon string.
575    ///
576    /// Parses `horizon` using [`Horizon`]'s string parser and performs the lookup.
577    ///
578    /// # Errors
579    /// Returns `DomainError` if the provided `horizon` string cannot be parsed.
580    pub fn find_by_horizon_str(
581        &self,
582        horizon: &str,
583    ) -> Result<Option<&RevisionPoint>, DomainError> {
584        let parsed: Horizon = horizon.parse()?;
585        Ok(self.find_by_horizon(&parsed))
586    }
587
588    /// Returns all available horizons in the historical data.
589    #[must_use]
590    pub fn available_horizons(&self) -> Vec<Horizon> {
591        self.historical
592            .iter()
593            .map(|point| point.horizon.clone())
594            .collect()
595    }
596}
597
598#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
599#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
600/// Represents a single row of earnings trend data for a specific period.
601pub struct EarningsTrendRow {
602    /// The period the trend data applies to with structured variants and extensible fallback.
603    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
604    pub period: ReportingPeriod,
605    /// The growth rate.
606    #[serde(default, with = "paft_decimal::serde::option_canonical_str")]
607    pub growth: Option<Decimal>,
608    /// Earnings estimate data with analyst consensus.
609    pub earnings_estimate: EarningsEstimate,
610    /// Revenue estimate data with analyst consensus.
611    pub revenue_estimate: RevenueEstimate,
612    /// EPS trend changes over different lookback horizons.
613    pub eps_trend: EpsTrend,
614    /// EPS revisions tracking upward and downward changes by lookback horizon.
615    pub eps_revisions: EpsRevisions,
616}