paft_fundamentals/
analysis.rs

1//! Analyst, recommendations, and earnings-related types under `paft_fundamentals::fundamentals::analysis`.
2
3use serde::{Deserialize, Serialize};
4use std::str::FromStr;
5use strum::{AsRefStr, Display, EnumString};
6
7use chrono::{DateTime, Utc};
8#[cfg(feature = "dataframe")]
9use df_derive::ToDataFrame;
10#[cfg(feature = "dataframe")]
11use paft_core::dataframe::ToDataFrame;
12use paft_core::domain::{Money, Period};
13
14/// Analyst recommendation grades with canonical variants and extensible fallback.
15///
16/// This enum provides type-safe handling of recommendation grades while gracefully
17/// handling unknown or provider-specific grades through the `Other` variant.
18#[derive(
19    Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display, AsRefStr, EnumString,
20)]
21#[strum(ascii_case_insensitive)]
22#[serde(from = "String", into = "String")]
23pub enum RecommendationGrade {
24    /// Strong buy recommendation
25    #[strum(to_string = "STRONG_BUY", serialize = "STRONG BUY", serialize = "BUY+")]
26    StrongBuy,
27    /// Buy recommendation
28    #[strum(to_string = "BUY")]
29    Buy,
30    /// Hold recommendation
31    #[strum(to_string = "HOLD", serialize = "NEUTRAL")]
32    Hold,
33    /// Sell recommendation
34    #[strum(to_string = "SELL")]
35    Sell,
36    /// Strong sell recommendation
37    #[strum(
38        to_string = "STRONG_SELL",
39        serialize = "STRONG SELL",
40        serialize = "SELL-"
41    )]
42    StrongSell,
43    /// Outperform recommendation
44    #[strum(to_string = "OUTPERFORM", serialize = "OVERWEIGHT")]
45    Outperform,
46    /// Underperform recommendation
47    #[strum(to_string = "UNDERPERFORM", serialize = "UNDERWEIGHT")]
48    Underperform,
49    /// Unknown or provider-specific grade
50    Other(String),
51}
52
53impl From<String> for RecommendationGrade {
54    fn from(s: String) -> Self {
55        // Try to parse as a known variant first
56        Self::from_str(&s).unwrap_or_else(|_| Self::Other(s.to_uppercase()))
57    }
58}
59
60impl From<RecommendationGrade> for String {
61    fn from(grade: RecommendationGrade) -> Self {
62        match grade {
63            RecommendationGrade::Other(s) => s,
64            _ => grade.to_string(),
65        }
66    }
67}
68
69/// Analyst recommendation actions with canonical variants and extensible fallback.
70///
71/// This enum provides type-safe handling of recommendation actions while gracefully
72/// handling unknown or provider-specific actions through the `Other` variant.
73#[derive(
74    Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display, AsRefStr, EnumString,
75)]
76#[strum(ascii_case_insensitive)]
77#[serde(from = "String", into = "String")]
78pub enum RecommendationAction {
79    /// Upgrade action
80    #[strum(to_string = "UPGRADE", serialize = "UP")]
81    Upgrade,
82    /// Downgrade action
83    #[strum(to_string = "DOWNGRADE", serialize = "DOWN")]
84    Downgrade,
85    /// Initiate coverage
86    #[strum(to_string = "INIT", serialize = "INITIATED", serialize = "INITIATE")]
87    Initiate,
88    /// Maintain or reiterate recommendation
89    #[strum(to_string = "MAINTAIN", serialize = "REITERATE")]
90    Maintain,
91    /// Resume coverage
92    #[strum(to_string = "RESUME")]
93    Resume,
94    /// Suspend coverage
95    #[strum(to_string = "SUSPEND")]
96    Suspend,
97    /// Unknown or provider-specific action
98    Other(String),
99}
100
101impl From<String> for RecommendationAction {
102    fn from(s: String) -> Self {
103        // Try to parse as a known variant first
104        Self::from_str(&s).unwrap_or_else(|_| Self::Other(s.to_uppercase()))
105    }
106}
107
108impl From<RecommendationAction> for String {
109    fn from(action: RecommendationAction) -> Self {
110        match action {
111            RecommendationAction::Other(s) => s,
112            _ => action.to_string(),
113        }
114    }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
118#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
119/// Earnings datasets: yearly summaries and quarterly breakdowns.
120pub struct Earnings {
121    /// Annual earnings summary rows.
122    pub yearly: Vec<EarningsYear>,
123    /// Quarterly earnings summary rows.
124    pub quarterly: Vec<EarningsQuarter>,
125    /// Quarterly EPS actual vs estimate rows.
126    pub quarterly_eps: Vec<EarningsQuarterEps>,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
130#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
131/// Yearly earnings summary.
132pub struct EarningsYear {
133    /// Fiscal year.
134    pub year: i32,
135    /// Revenue for the year.
136    pub revenue: Option<Money>,
137    /// Earnings for the year.
138    pub earnings: Option<Money>,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
143/// Quarterly earnings summary for a period key (e.g., 2023Q4 or 2023-10-01).
144pub struct EarningsQuarter {
145    /// Period with structured variants and extensible fallback.
146    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
147    pub period: Period,
148    /// Revenue for the period.
149    pub revenue: Option<Money>,
150    /// Earnings for the period.
151    pub earnings: Option<Money>,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
156/// Quarterly EPS actual vs estimate for a period key.
157pub struct EarningsQuarterEps {
158    /// Period with structured variants and extensible fallback.
159    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
160    pub period: Period,
161    /// Actual EPS.
162    pub actual: Option<Money>,
163    /// Estimated EPS.
164    pub estimate: Option<Money>,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
168#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
169/// Analyst price target summary.
170pub struct PriceTarget {
171    /// Mean price target.
172    pub mean: Option<Money>,
173    /// High price target.
174    pub high: Option<Money>,
175    /// Low price target.
176    pub low: Option<Money>,
177    /// Number of contributing analysts.
178    pub number_of_analysts: Option<u32>,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
183/// Distribution of analyst recommendations for a period.
184pub struct RecommendationRow {
185    /// Period with structured variants and extensible fallback.
186    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
187    pub period: Period,
188    /// Count of "strong buy" recommendations.
189    pub strong_buy: Option<u32>,
190    /// Count of "buy" recommendations.
191    pub buy: Option<u32>,
192    /// Count of "hold" recommendations.
193    pub hold: Option<u32>,
194    /// Count of "sell" recommendations.
195    pub sell: Option<u32>,
196    /// Count of "strong sell" recommendations.
197    pub strong_sell: Option<u32>,
198}
199
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
201#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
202/// Summary of analyst recommendations and mean scoring.
203pub struct RecommendationSummary {
204    /// Most recent period of the summary.
205    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
206    pub latest_period: Option<Period>,
207    /// Count of "strong buy" recommendations.
208    pub strong_buy: Option<u32>,
209    /// Count of "buy" recommendations.
210    pub buy: Option<u32>,
211    /// Count of "hold" recommendations.
212    pub hold: Option<u32>,
213    /// Count of "sell" recommendations.
214    pub sell: Option<u32>,
215    /// Count of "strong sell" recommendations.
216    pub strong_sell: Option<u32>,
217    /// Mean recommendation score.
218    pub mean: Option<f64>,
219    /// Provider-specific text for the mean score (e.g., "Buy", "Overweight").
220    pub mean_rating_text: Option<String>,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
225/// Broker action history for an instrument.
226pub struct UpgradeDowngradeRow {
227    /// Event timestamp.
228    #[serde(with = "chrono::serde::ts_seconds")]
229    pub ts: DateTime<Utc>,
230    /// Research firm name.
231    pub firm: Option<String>,
232    /// Previous rating with canonical variants and extensible fallback.
233    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
234    pub from_grade: Option<RecommendationGrade>,
235    /// New rating with canonical variants and extensible fallback.
236    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
237    pub to_grade: Option<RecommendationGrade>,
238    /// Action description with canonical variants and extensible fallback.
239    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
240    pub action: Option<RecommendationAction>,
241}
242
243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
244#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
245/// Summary of key analysis metrics extracted from detailed analysis data.
246pub struct AnalysisSummary {
247    /// Analyst target mean price.
248    pub target_mean_price: Option<Money>,
249    /// Analyst target high price.
250    pub target_high_price: Option<Money>,
251    /// Analyst target low price.
252    pub target_low_price: Option<Money>,
253    /// Number of analyst opinions contributing to the recommendation.
254    pub number_of_analyst_opinions: Option<u32>,
255    /// Numeric recommendation score (provider-defined scale).
256    pub recommendation_mean: Option<f64>,
257    /// Categorical recommendation text (e.g., "Buy", "Overweight").
258    pub recommendation_text: Option<String>,
259}
260
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
262#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
263/// Earnings estimate data with analyst consensus.
264pub struct EarningsEstimate {
265    /// Average earnings estimate.
266    pub avg: Option<Money>,
267    /// Low earnings estimate.
268    pub low: Option<Money>,
269    /// High earnings estimate.
270    pub high: Option<Money>,
271    /// Earnings per share from a year ago.
272    pub year_ago_eps: Option<Money>,
273    /// Number of analysts providing earnings estimates.
274    pub num_analysts: Option<u32>,
275    /// Estimated earnings growth.
276    pub growth: Option<f64>,
277}
278
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
280#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
281/// Revenue estimate data with analyst consensus.
282pub struct RevenueEstimate {
283    /// Average revenue estimate.
284    pub avg: Option<Money>,
285    /// Low revenue estimate.
286    pub low: Option<Money>,
287    /// High revenue estimate.
288    pub high: Option<Money>,
289    /// Revenue from a year ago.
290    pub year_ago_revenue: Option<Money>,
291    /// Number of analysts providing revenue estimates.
292    pub num_analysts: Option<u32>,
293    /// Estimated revenue growth.
294    pub growth: Option<f64>,
295}
296
297/// A flexible data point for time-series trend data.
298///
299/// This struct allows any provider to represent trend data for any time period,
300/// making the system provider-agnostic instead of tied to specific hardcoded buckets.
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
302#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
303pub struct TrendPoint {
304    /// The period this data point represents (e.g., "7d", "1mo", "3mo").
305    /// This allows providers to use their own time period conventions.
306    pub period: String,
307    /// The value for this time period.
308    pub value: Money,
309}
310
311impl TrendPoint {
312    /// Creates a new trend point with the specified period and value.
313    pub fn new(period: impl Into<String>, value: Money) -> Self {
314        Self {
315            period: period.into(),
316            value,
317        }
318    }
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
322#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
323/// EPS trend changes over different time periods.
324///
325/// This struct now uses a flexible collection of trend points instead of
326/// hardcoded time buckets, making it provider-agnostic.
327pub struct EpsTrend {
328    /// Current EPS trend.
329    pub current: Option<Money>,
330    /// Historical EPS trend data points with flexible time periods.
331    /// Each provider can populate this with their available time periods
332    /// (e.g., a generic provider might use "7d", "30d", "60d", "90d" while another
333    /// provider might use "1mo", "3mo", "6mo").
334    pub historical: Vec<TrendPoint>,
335}
336
337impl EpsTrend {
338    /// Creates a new EPS trend with the specified current value and historical data.
339    #[must_use]
340    pub const fn new(current: Option<Money>, historical: Vec<TrendPoint>) -> Self {
341        Self {
342            current,
343            historical,
344        }
345    }
346
347    /// Finds a trend point by period string.
348    #[must_use]
349    pub fn find_by_period(&self, period: &str) -> Option<&TrendPoint> {
350        self.historical.iter().find(|point| point.period == period)
351    }
352
353    /// Returns all available periods in the historical data.
354    #[must_use]
355    pub fn available_periods(&self) -> Vec<&str> {
356        self.historical
357            .iter()
358            .map(|point| point.period.as_str())
359            .collect()
360    }
361}
362
363/// A flexible data point for revision counts over different time periods.
364///
365/// This struct allows any provider to represent revision data for any time period,
366/// making the system provider-agnostic instead of tied to specific hardcoded buckets.
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
369pub struct RevisionPoint {
370    /// The period this data point represents (e.g., "7d", "1mo", "3mo").
371    /// This allows providers to use their own time period conventions.
372    pub period: String,
373    /// Number of upward revisions in this period.
374    pub up_count: u32,
375    /// Number of downward revisions in this period.
376    pub down_count: u32,
377}
378
379impl RevisionPoint {
380    /// Creates a new revision point with the specified period and counts.
381    pub fn new(period: impl Into<String>, up_count: u32, down_count: u32) -> Self {
382        Self {
383            period: period.into(),
384            up_count,
385            down_count,
386        }
387    }
388
389    /// Returns the total number of revisions (up + down) in this period.
390    #[must_use]
391    pub const fn total_revisions(&self) -> u32 {
392        self.up_count + self.down_count
393    }
394
395    /// Returns the net revision count (up - down) in this period.
396    /// Positive values indicate more upward revisions, negative values indicate more downward revisions.
397    #[must_use]
398    #[allow(clippy::cast_possible_wrap)]
399    pub const fn net_revisions(&self) -> i32 {
400        self.up_count as i32 - self.down_count as i32
401    }
402}
403
404#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
405#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
406/// EPS revisions tracking upward and downward changes.
407///
408/// This struct now uses a flexible collection of revision points instead of
409/// hardcoded time buckets, making it provider-agnostic.
410pub struct EpsRevisions {
411    /// Historical EPS revision data points with flexible time periods.
412    /// Each provider can populate this with their available time periods
413    /// (e.g., a generic provider might use "7d", "30d" while another provider might
414    /// use "1mo", "3mo", "6mo").
415    pub historical: Vec<RevisionPoint>,
416}
417
418impl EpsRevisions {
419    /// Creates a new EPS revisions struct with the specified historical data.
420    #[must_use]
421    pub const fn new(historical: Vec<RevisionPoint>) -> Self {
422        Self { historical }
423    }
424
425    /// Finds a revision point by period string.
426    #[must_use]
427    pub fn find_by_period(&self, period: &str) -> Option<&RevisionPoint> {
428        self.historical.iter().find(|point| point.period == period)
429    }
430
431    /// Returns all available periods in the historical data.
432    #[must_use]
433    pub fn available_periods(&self) -> Vec<&str> {
434        self.historical
435            .iter()
436            .map(|point| point.period.as_str())
437            .collect()
438    }
439
440    /// Returns the total number of upward revisions across all periods.
441    #[must_use]
442    pub fn total_up_revisions(&self) -> u32 {
443        self.historical.iter().map(|point| point.up_count).sum()
444    }
445
446    /// Returns the total number of downward revisions across all periods.
447    #[must_use]
448    pub fn total_down_revisions(&self) -> u32 {
449        self.historical.iter().map(|point| point.down_count).sum()
450    }
451
452    /// Returns the net revision count across all periods (total up - total down).
453    #[must_use]
454    #[allow(clippy::cast_possible_wrap)]
455    pub fn net_revisions(&self) -> i32 {
456        self.total_up_revisions() as i32 - self.total_down_revisions() as i32
457    }
458}
459
460#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
461#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
462/// Represents a single row of earnings trend data for a specific period.
463pub struct EarningsTrendRow {
464    /// The period the trend data applies to with structured variants and extensible fallback.
465    #[cfg_attr(feature = "dataframe", df_derive(as_string))]
466    pub period: Period,
467    /// The growth rate.
468    pub growth: Option<f64>,
469    /// Earnings estimate data with analyst consensus.
470    #[serde(flatten)]
471    pub earnings_estimate: EarningsEstimate,
472    /// Revenue estimate data with analyst consensus.
473    #[serde(flatten)]
474    pub revenue_estimate: RevenueEstimate,
475    /// EPS trend changes over different time periods.
476    #[serde(flatten)]
477    pub eps_trend: EpsTrend,
478    /// EPS revisions tracking upward and downward changes.
479    #[serde(flatten)]
480    pub eps_revisions: EpsRevisions,
481}