use serde::{Deserialize, Serialize};
use crate::error::Result;
use crate::models::fundamentals::{EarningsSurprise, GradingAction};
use crate::adapters::fmp::build_client;
use crate::adapters::fmp::models::Period;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AnalystEstimateDTO {
pub symbol: Option<String>,
pub date: Option<String>,
#[serde(rename = "revenueLow")]
pub estimated_revenue_low: Option<f64>,
#[serde(rename = "revenueHigh")]
pub estimated_revenue_high: Option<f64>,
#[serde(rename = "revenueAvg")]
pub estimated_revenue_avg: Option<f64>,
#[serde(rename = "ebitdaLow")]
pub estimated_ebitda_low: Option<f64>,
#[serde(rename = "ebitdaHigh")]
pub estimated_ebitda_high: Option<f64>,
#[serde(rename = "ebitdaAvg")]
pub estimated_ebitda_avg: Option<f64>,
#[serde(rename = "epsAvg")]
pub estimated_eps_avg: Option<f64>,
#[serde(rename = "epsHigh")]
pub estimated_eps_high: Option<f64>,
#[serde(rename = "epsLow")]
pub estimated_eps_low: Option<f64>,
#[serde(rename = "numAnalystsRevenue")]
pub number_analyst_estimated_revenue: Option<i32>,
#[serde(rename = "numAnalystsEps")]
pub number_analysts_estimated_eps: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AnalystRecommendationDTO {
pub symbol: Option<String>,
pub date: Option<String>,
#[serde(rename = "analystRatingsBuy")]
pub analyst_ratings_buy: Option<i32>,
#[serde(rename = "analystRatingsHold")]
pub analyst_ratings_hold: Option<i32>,
#[serde(rename = "analystRatingsSell")]
pub analyst_ratings_sell: Option<i32>,
#[serde(rename = "analystRatingsStrongBuy")]
pub analyst_ratings_strong_buy: Option<i32>,
#[serde(rename = "analystRatingsStrongSell")]
pub analyst_ratings_strong_sell: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct EarningsSurpriseDTO {
pub date: Option<String>,
pub symbol: Option<String>,
#[serde(rename = "epsActual", alias = "actualEarningResult")]
pub actual_earning_result: Option<f64>,
#[serde(rename = "epsEstimated", alias = "estimatedEarning")]
pub estimated_earning: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StockGradeDTO {
pub symbol: Option<String>,
pub date: Option<String>,
#[serde(rename = "gradingCompany")]
pub grading_company: Option<String>,
#[serde(rename = "previousGrade")]
pub previous_grade: Option<String>,
#[serde(rename = "newGrade")]
pub new_grade: Option<String>,
}
pub async fn analyst_estimates(
symbol: &str,
period: Period,
limit: u32,
) -> Result<Vec<AnalystEstimateDTO>> {
let client = build_client()?;
let limit_str = limit.to_string();
client
.get(
"/stable/analyst-estimates",
&[
("symbol", symbol),
("period", period.as_str()),
("limit", &limit_str),
("page", "0"),
],
)
.await
}
pub async fn analyst_recommendations(symbol: &str) -> Result<Vec<AnalystRecommendationDTO>> {
let client = build_client()?;
client
.get("/stable/grades-historical", &[("symbol", symbol)])
.await
}
pub async fn earnings_surprises(symbol: &str) -> Result<Vec<EarningsSurpriseDTO>> {
let client = build_client()?;
client.get("/stable/earnings", &[("symbol", symbol)]).await
}
pub async fn stock_grade(symbol: &str, limit: u32) -> Result<Vec<StockGradeDTO>> {
let client = build_client()?;
let limit_str = limit.to_string();
client
.get(
"/stable/grades",
&[("symbol", symbol), ("limit", &limit_str)],
)
.await
}
fn to_earnings_surprise(dto: EarningsSurpriseDTO) -> EarningsSurprise {
let surprise = match (dto.actual_earning_result, dto.estimated_earning) {
(Some(actual), Some(estimated)) => Some(actual - estimated),
_ => None,
};
let surprise_percent = match (surprise, dto.estimated_earning) {
(Some(s), Some(estimated)) if estimated != 0.0 => Some(s / estimated.abs() * 100.0),
_ => None,
};
EarningsSurprise {
symbol: dto.symbol,
date: dto.date,
actual_eps: dto.actual_earning_result,
estimated_eps: dto.estimated_earning,
surprise,
surprise_percent,
}
}
pub async fn fetch_earnings_surprises_response(symbol: &str) -> Result<Vec<EarningsSurprise>> {
Ok(earnings_surprises(symbol)
.await?
.into_iter()
.map(to_earnings_surprise)
.collect())
}
fn to_grading_action(dto: StockGradeDTO) -> GradingAction {
GradingAction {
symbol: dto.symbol,
date: dto.date,
grading_company: dto.grading_company,
previous_grade: dto.previous_grade,
new_grade: dto.new_grade,
}
}
pub async fn fetch_grading_history_response(
symbol: &str,
limit: u32,
) -> Result<Vec<GradingAction>> {
Ok(stock_grade(symbol, limit)
.await?
.into_iter()
.map(to_grading_action)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_analyst_estimates_mock() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/stable/analyst-estimates")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("apikey".into(), "test-key".into()),
mockito::Matcher::UrlEncoded("symbol".into(), "AAPL".into()),
mockito::Matcher::UrlEncoded("period".into(), "quarter".into()),
mockito::Matcher::UrlEncoded("limit".into(), "4".into()),
]))
.with_status(200)
.with_body(
serde_json::json!([
{
"symbol": "AAPL",
"date": "2024-03-31",
"revenueAvg": 90000000000.0,
"epsAvg": 1.50,
"numAnalystsRevenue": 30,
"numAnalystsEps": 28
}
])
.to_string(),
)
.create_async()
.await;
let client = crate::adapters::fmp::build_test_client(&server.url()).unwrap();
let resp: Vec<AnalystEstimateDTO> = client
.get(
"/stable/analyst-estimates",
&[("symbol", "AAPL"), ("period", "quarter"), ("limit", "4")],
)
.await
.unwrap();
assert_eq!(resp.len(), 1);
assert_eq!(resp[0].symbol.as_deref(), Some("AAPL"));
assert!((resp[0].estimated_eps_avg.unwrap() - 1.50).abs() < 0.01);
}
#[tokio::test]
async fn test_earnings_surprises_mock() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/stable/earnings")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("apikey".into(), "test-key".into()),
mockito::Matcher::UrlEncoded("symbol".into(), "AAPL".into()),
]))
.with_status(200)
.with_body(
serde_json::json!([
{
"date": "2024-01-25",
"symbol": "AAPL",
"epsActual": 2.18,
"epsEstimated": 2.10
}
])
.to_string(),
)
.create_async()
.await;
let client = crate::adapters::fmp::build_test_client(&server.url()).unwrap();
let resp: Vec<EarningsSurpriseDTO> = client
.get("/stable/earnings", &[("symbol", "AAPL")])
.await
.unwrap();
assert_eq!(resp.len(), 1);
assert!((resp[0].actual_earning_result.unwrap() - 2.18).abs() < 0.01);
}
#[test]
fn maps_earnings_surprise_and_derives_surprise_fields() {
let dto: EarningsSurpriseDTO = serde_json::from_value(serde_json::json!({
"date": "2024-01-25",
"symbol": "AAPL",
"actualEarningResult": 2.18,
"estimatedEarning": 2.10
}))
.unwrap();
let out = to_earnings_surprise(dto);
assert_eq!(out.actual_eps, Some(2.18));
assert_eq!(out.estimated_eps, Some(2.10));
assert!((out.surprise.unwrap() - 0.08).abs() < 1e-9);
assert!((out.surprise_percent.unwrap() - 3.8095238095).abs() < 1e-6);
}
#[test]
fn earnings_surprise_missing_estimate_yields_no_derived_fields() {
let dto: EarningsSurpriseDTO = serde_json::from_value(serde_json::json!({
"date": "2024-01-25",
"symbol": "AAPL",
"actualEarningResult": 2.18
}))
.unwrap();
let out = to_earnings_surprise(dto);
assert_eq!(out.surprise, None);
assert_eq!(out.surprise_percent, None);
}
#[test]
fn maps_stock_grade_to_grading_action() {
let dto: StockGradeDTO = serde_json::from_value(serde_json::json!({
"symbol": "AAPL",
"date": "2024-01-15",
"gradingCompany": "Morgan Stanley",
"previousGrade": "Equal-Weight",
"newGrade": "Overweight"
}))
.unwrap();
let out = to_grading_action(dto);
assert_eq!(out.grading_company.as_deref(), Some("Morgan Stanley"));
assert_eq!(out.previous_grade.as_deref(), Some("Equal-Weight"));
assert_eq!(out.new_grade.as_deref(), Some("Overweight"));
}
}