1use 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#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct RecommendationRow {
22 pub period: String,
24 pub strong_buy: Option<u32>,
26 pub buy: Option<u32>,
28 pub hold: Option<u32>,
30 pub sell: Option<u32>,
32 pub strong_sell: Option<u32>,
34}
35
36#[derive(Debug, Clone, PartialEq)]
39pub struct RecommendationSummary {
40 pub latest_period: Option<String>,
42 pub strong_buy: Option<u32>,
44 pub buy: Option<u32>,
46 pub hold: Option<u32>,
48 pub sell: Option<u32>,
50 pub strong_sell: Option<u32>,
52 pub mean: Option<f64>,
54 pub rating: Option<String>,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct UpgradeDowngradeRow {
61 pub timestamp: i64,
63 pub firm: Option<String>,
65 pub from_grade: Option<String>,
67 pub to_grade: Option<String>,
69 pub action: Option<String>,
71}
72
73#[derive(Debug, Clone, PartialEq)]
75pub struct PriceTarget {
76 pub mean: Option<f64>,
78 pub high: Option<f64>,
80 pub low: Option<f64>,
82 pub number_of_analysts: Option<u32>,
84}
85
86#[derive(Debug, Clone, PartialEq)]
89pub struct EarningsTrendRow {
90 pub period: String,
92 pub growth: Option<f64>,
94 pub earnings_estimate: EarningsEstimate,
96 pub revenue_estimate: RevenueEstimate,
98 pub eps_trend: EpsTrend,
100 pub eps_revisions: EpsRevisions,
102}
103
104#[derive(Debug, Clone, Default, PartialEq)]
106pub struct EarningsEstimate {
107 pub avg: Option<f64>,
109 pub low: Option<f64>,
111 pub high: Option<f64>,
113 pub year_ago_eps: Option<f64>,
115 pub num_analysts: Option<u32>,
117 pub growth: Option<f64>,
119}
120
121#[derive(Debug, Clone, Default, PartialEq)]
123pub struct RevenueEstimate {
124 pub avg: Option<i64>,
126 pub low: Option<i64>,
128 pub high: Option<i64>,
130 pub year_ago_revenue: Option<i64>,
132 pub num_analysts: Option<u32>,
134 pub growth: Option<f64>,
136}
137
138#[derive(Debug, Clone, Default, PartialEq)]
140pub struct EpsTrend {
141 pub current: Option<f64>,
143 pub seven_days_ago: Option<f64>,
145 pub thirty_days_ago: Option<f64>,
147 pub sixty_days_ago: Option<f64>,
149 pub ninety_days_ago: Option<f64>,
151}
152
153#[derive(Debug, Clone, Default, PartialEq, Eq)]
155pub struct EpsRevisions {
156 pub up_last_7_days: Option<u32>,
158 pub up_last_30_days: Option<u32>,
160 pub down_last_7_days: Option<u32>,
162 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
338pub(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
367pub(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
407pub(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
436pub(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
450pub(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}