Skip to main content

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