finance_query_core/models/
analysts.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum AnalysisType {
8    #[serde(rename = "recommendations")]
9    Recommendations,
10    #[serde(rename = "upgrades_downgrades")]
11    UpgradesDowngrades,
12    #[serde(rename = "price_targets")]
13    PriceTargets,
14    #[serde(rename = "earnings_estimate")]
15    EarningsEstimate,
16    #[serde(rename = "revenue_estimate")]
17    RevenueEstimate,
18    #[serde(rename = "earnings_history")]
19    EarningsHistory,
20}
21
22impl AnalysisType {
23    pub fn as_str(&self) -> &'static str {
24        match self {
25            AnalysisType::Recommendations => "recommendations",
26            AnalysisType::UpgradesDowngrades => "upgrades_downgrades",
27            AnalysisType::PriceTargets => "price_targets",
28            AnalysisType::EarningsEstimate => "earnings_estimate",
29            AnalysisType::RevenueEstimate => "revenue_estimate",
30            AnalysisType::EarningsHistory => "earnings_history",
31        }
32    }
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct RecommendationData {
37    pub period: String,
38    #[serde(skip_serializing_if = "Option::is_none", rename = "strongBuy")]
39    pub strong_buy: Option<i32>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub buy: Option<i32>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub hold: Option<i32>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub sell: Option<i32>,
46    #[serde(skip_serializing_if = "Option::is_none", rename = "strongSell")]
47    pub strong_sell: Option<i32>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct UpgradeDowngrade {
52    pub firm: String,
53    #[serde(skip_serializing_if = "Option::is_none", rename = "toGrade")]
54    pub to_grade: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none", rename = "fromGrade")]
56    pub from_grade: Option<String>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub action: Option<String>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub date: Option<DateTime<Utc>>,
61}
62
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct PriceTarget {
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub current: Option<f64>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub mean: Option<f64>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub median: Option<f64>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub low: Option<f64>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub high: Option<f64>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct EarningsEstimate {
80    pub estimates: HashMap<String, serde_json::Value>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct RevenueEstimate {
85    pub estimates: HashMap<String, serde_json::Value>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct EarningsHistoryItem {
90    pub date: DateTime<Utc>,
91    #[serde(skip_serializing_if = "Option::is_none", rename = "epsActual")]
92    pub eps_actual: Option<f64>,
93    #[serde(skip_serializing_if = "Option::is_none", rename = "epsEstimate")]
94    pub eps_estimate: Option<f64>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub surprise: Option<f64>,
97    #[serde(skip_serializing_if = "Option::is_none", rename = "surprisePercent")]
98    pub surprise_percent: Option<f64>,
99}
100
101/// EPS trend data showing how estimates have changed over time
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct EpsTrend {
104    pub period: String,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub current: Option<f64>,
107    #[serde(skip_serializing_if = "Option::is_none", rename = "7daysAgo")]
108    pub days_7_ago: Option<f64>,
109    #[serde(skip_serializing_if = "Option::is_none", rename = "30daysAgo")]
110    pub days_30_ago: Option<f64>,
111    #[serde(skip_serializing_if = "Option::is_none", rename = "60daysAgo")]
112    pub days_60_ago: Option<f64>,
113    #[serde(skip_serializing_if = "Option::is_none", rename = "90daysAgo")]
114    pub days_90_ago: Option<f64>,
115}
116
117/// EPS revisions showing analyst estimate changes
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct EpsRevisions {
120    pub period: String,
121    #[serde(skip_serializing_if = "Option::is_none", rename = "upLast7days")]
122    pub up_last_7_days: Option<i32>,
123    #[serde(skip_serializing_if = "Option::is_none", rename = "upLast30days")]
124    pub up_last_30_days: Option<i32>,
125    #[serde(skip_serializing_if = "Option::is_none", rename = "downLast7days")]
126    pub down_last_7_days: Option<i32>,
127    #[serde(skip_serializing_if = "Option::is_none", rename = "downLast30days")]
128    pub down_last_30_days: Option<i32>,
129}
130
131/// Growth estimates comparing stock to industry/sector/index
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct GrowthEstimate {
134    pub period: String,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub stock: Option<f64>,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub industry: Option<f64>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub sector: Option<f64>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub index: Option<f64>,
143}
144
145// Response types
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct RecommendationsResponse {
148    pub symbol: String,
149    pub recommendations: Vec<RecommendationData>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct UpgradesDowngradesResponse {
154    pub symbol: String,
155    #[serde(rename = "upgradesDowngrades")]
156    pub upgrades_downgrades: Vec<UpgradeDowngrade>,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct PriceTargetsResponse {
161    pub symbol: String,
162    #[serde(rename = "priceTargets")]
163    pub price_targets: PriceTarget,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct EarningsEstimateResponse {
168    pub symbol: String,
169    #[serde(rename = "earningsEstimate")]
170    pub earnings_estimate: EarningsEstimate,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct RevenueEstimateResponse {
175    pub symbol: String,
176    #[serde(rename = "revenueEstimate")]
177    pub revenue_estimate: RevenueEstimate,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct EarningsHistoryResponse {
182    pub symbol: String,
183    #[serde(rename = "earningsHistory")]
184    pub earnings_history: Vec<EarningsHistoryItem>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct EpsTrendResponse {
189    pub symbol: String,
190    #[serde(rename = "epsTrend")]
191    pub eps_trend: Vec<EpsTrend>,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct EpsRevisionsResponse {
196    pub symbol: String,
197    #[serde(rename = "epsRevisions")]
198    pub eps_revisions: Vec<EpsRevisions>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct GrowthEstimatesResponse {
203    pub symbol: String,
204    #[serde(rename = "growthEstimates")]
205    pub growth_estimates: Vec<GrowthEstimate>,
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use proptest::prelude::*;
212
213    fn optional_i32() -> impl Strategy<Value = Option<i32>> {
214        proptest::option::of(0i32..1000i32)
215    }
216
217    // **Feature: crate-extraction, Property 1: Model Serialization Round-Trip**
218    // **Validates: Requirements 2.2**
219    proptest! {
220        #![proptest_config(ProptestConfig::with_cases(100))]
221
222        #[test]
223        fn recommendation_data_roundtrip(
224            period in "[0-9]{4}-[0-9]{2}",
225            strong_buy in optional_i32(),
226            buy in optional_i32(),
227            hold in optional_i32(),
228            sell in optional_i32(),
229            strong_sell in optional_i32(),
230        ) {
231            let rec = RecommendationData {
232                period: period.clone(),
233                strong_buy,
234                buy,
235                hold,
236                sell,
237                strong_sell,
238            };
239
240            let json = serde_json::to_string(&rec).unwrap();
241            let parsed: RecommendationData = serde_json::from_str(&json).unwrap();
242
243            prop_assert_eq!(rec.period, parsed.period);
244            prop_assert_eq!(rec.strong_buy, parsed.strong_buy);
245            prop_assert_eq!(rec.buy, parsed.buy);
246            prop_assert_eq!(rec.hold, parsed.hold);
247            prop_assert_eq!(rec.sell, parsed.sell);
248            prop_assert_eq!(rec.strong_sell, parsed.strong_sell);
249        }
250
251        #[test]
252        fn analysis_type_roundtrip(at in prop_oneof![
253            Just(AnalysisType::Recommendations),
254            Just(AnalysisType::UpgradesDowngrades),
255            Just(AnalysisType::PriceTargets),
256            Just(AnalysisType::EarningsEstimate),
257            Just(AnalysisType::RevenueEstimate),
258            Just(AnalysisType::EarningsHistory),
259        ]) {
260            let json = serde_json::to_string(&at).unwrap();
261            let parsed: AnalysisType = serde_json::from_str(&json).unwrap();
262
263            prop_assert_eq!(at.as_str(), parsed.as_str());
264        }
265    }
266}