use serde::Serialize;
use tracing::instrument;
use marketsurge_client::fundamentals::FundamentalsItem;
use crate::cli::SymbolsArgs;
use crate::common::command::{api_call, run_command, zip_symbols};
#[derive(Debug, Clone, Serialize)]
pub struct FundamentalsRecord {
pub symbol: String,
pub company_name: Option<String>,
pub metric: String,
pub period_offset: Option<String>,
pub period: Option<String>,
pub value: Option<String>,
pub pct_change_yoy: Option<String>,
pub revision_direction: Option<String>,
}
fn flatten_fundamentals(
symbols: &[&str],
market_data: &[FundamentalsItem],
) -> Vec<FundamentalsRecord> {
let mut records = Vec::new();
for (symbol, item) in zip_symbols(symbols, market_data) {
let company_name = item
.symbology
.as_ref()
.and_then(|s| s.company.as_ref())
.and_then(|c| c.company_name.clone());
let financials = match &item.financials {
Some(f) => f,
None => continue,
};
if let Some(eps) = financials
.consensus_financials
.as_ref()
.and_then(|c| c.eps.as_ref())
{
for period in &eps.reported_earnings {
records.push(FundamentalsRecord {
symbol: symbol.to_string(),
company_name: company_name.clone(),
metric: "reported_eps".to_string(),
period_offset: period.period_offset.clone(),
period: period
.period_end_date
.as_ref()
.and_then(|d| d.value.clone()),
value: period
.value
.as_ref()
.and_then(|v| v.formatted_value.clone()),
pct_change_yoy: period
.percent_change_yoy
.as_ref()
.and_then(|v| v.formatted_value.clone()),
revision_direction: None,
});
}
}
if let Some(sales) = financials
.consensus_financials
.as_ref()
.and_then(|c| c.sales.as_ref())
{
for period in &sales.reported_sales {
records.push(FundamentalsRecord {
symbol: symbol.to_string(),
company_name: company_name.clone(),
metric: "reported_sales".to_string(),
period_offset: period.period_offset.clone(),
period: period
.period_end_date
.as_ref()
.and_then(|d| d.value.clone()),
value: period
.value
.as_ref()
.and_then(|v| v.formatted_value.clone()),
pct_change_yoy: period
.percent_change_yoy
.as_ref()
.and_then(|v| v.formatted_value.clone()),
revision_direction: None,
});
}
}
if let Some(estimates) = &financials.estimates {
for est in &estimates.eps_estimates {
records.push(FundamentalsRecord {
symbol: symbol.to_string(),
company_name: company_name.clone(),
metric: "eps_estimate".to_string(),
period_offset: est.period_offset.clone(),
period: est.period.clone(),
value: est.value.as_ref().and_then(|v| v.formatted_value.clone()),
pct_change_yoy: est
.percent_change_yoy
.as_ref()
.and_then(|v| v.formatted_value.clone()),
revision_direction: est.revision_direction.clone(),
});
}
for est in &estimates.sales_estimates {
records.push(FundamentalsRecord {
symbol: symbol.to_string(),
company_name: company_name.clone(),
metric: "sales_estimate".to_string(),
period_offset: est.period_offset.clone(),
period: est.period.clone(),
value: est.value.as_ref().and_then(|v| v.formatted_value.clone()),
pct_change_yoy: est
.percent_change_yoy
.as_ref()
.and_then(|v| v.formatted_value.clone()),
revision_direction: None,
});
}
}
}
records
}
#[instrument(skip_all)]
#[cfg(not(coverage))]
pub async fn handle(args: &SymbolsArgs, fields: &[String]) -> i32 {
run_command(&args.symbols, fields, |client, symbol_refs| async move {
let response = api_call(client.fundamentals(
&symbol_refs,
"CHARTING",
"P7Y_AGO",
"P2Y_FUTURE",
"P7Y_AGO",
"P2Y_FUTURE",
))
.await?;
Ok(flatten_fundamentals(&symbol_refs, &response.market_data))
})
.await
}
#[cfg(test)]
mod tests {
use marketsurge_client::fundamentals::{
FundamentalsCompany, FundamentalsConsensus, FundamentalsConsensusEps,
FundamentalsConsensusSales, FundamentalsEstimate, FundamentalsEstimates,
FundamentalsFinancials, FundamentalsItem, FundamentalsReportedPeriod,
FundamentalsSymbology,
};
use marketsurge_client::types::{DateValue, FormattedFloat};
use super::*;
fn make_formatted(val: f64, display: &str) -> Option<FormattedFloat> {
Some(FormattedFloat {
value: Some(val),
formatted_value: Some(display.to_string()),
})
}
fn make_item_with_financials(financials: FundamentalsFinancials) -> FundamentalsItem {
FundamentalsItem {
id: Some("AAPL".to_string()),
financials: Some(financials),
symbology: Some(FundamentalsSymbology {
company: Some(FundamentalsCompany {
company_name: Some("Apple Inc.".to_string()),
}),
instrument: None,
}),
}
}
#[test]
fn reported_eps_branch() {
let item = make_item_with_financials(FundamentalsFinancials {
consensus_financials: Some(FundamentalsConsensus {
eps: Some(FundamentalsConsensusEps {
reported_earnings: vec![FundamentalsReportedPeriod {
value: make_formatted(1.65, "$1.65"),
percent_change_yoy: make_formatted(10.0, "10%"),
period_offset: Some("CURRENT".to_string()),
period_end_date: Some(DateValue {
value: Some("2026-03-31".to_string()),
}),
}],
}),
sales: None,
}),
estimates: None,
});
let records = flatten_fundamentals(&["AAPL"], &[item]);
assert_eq!(records.len(), 1);
assert_eq!(records[0].metric, "reported_eps");
assert_eq!(records[0].symbol, "AAPL");
assert_eq!(records[0].company_name.as_deref(), Some("Apple Inc."));
assert_eq!(records[0].value.as_deref(), Some("$1.65"));
assert_eq!(records[0].pct_change_yoy.as_deref(), Some("10%"));
assert_eq!(records[0].period_offset.as_deref(), Some("CURRENT"));
assert_eq!(records[0].period.as_deref(), Some("2026-03-31"));
assert!(records[0].revision_direction.is_none());
}
#[test]
fn reported_sales_branch() {
let item = make_item_with_financials(FundamentalsFinancials {
consensus_financials: Some(FundamentalsConsensus {
eps: None,
sales: Some(FundamentalsConsensusSales {
reported_sales: vec![FundamentalsReportedPeriod {
value: make_formatted(95.2, "$95.2B"),
percent_change_yoy: make_formatted(5.0, "5%"),
period_offset: Some("P1Q_AGO".to_string()),
period_end_date: Some(DateValue {
value: Some("2025-12-31".to_string()),
}),
}],
}),
}),
estimates: None,
});
let records = flatten_fundamentals(&["AAPL"], &[item]);
assert_eq!(records.len(), 1);
assert_eq!(records[0].metric, "reported_sales");
assert_eq!(records[0].value.as_deref(), Some("$95.2B"));
assert_eq!(records[0].period.as_deref(), Some("2025-12-31"));
assert!(records[0].revision_direction.is_none());
}
#[test]
fn eps_estimate_branch() {
let item = make_item_with_financials(FundamentalsFinancials {
consensus_financials: None,
estimates: Some(FundamentalsEstimates {
eps_estimates: vec![FundamentalsEstimate {
value: make_formatted(1.72, "$1.72"),
percent_change_yoy: make_formatted(4.2, "4.2%"),
period_offset: Some("P1Q_FUTURE".to_string()),
period: Some("P1Q".to_string()),
revision_direction: Some("UP".to_string()),
}],
sales_estimates: vec![],
}),
});
let records = flatten_fundamentals(&["AAPL"], &[item]);
assert_eq!(records.len(), 1);
assert_eq!(records[0].metric, "eps_estimate");
assert_eq!(records[0].value.as_deref(), Some("$1.72"));
assert_eq!(records[0].period.as_deref(), Some("P1Q"));
assert_eq!(records[0].revision_direction.as_deref(), Some("UP"));
}
#[test]
fn sales_estimate_branch() {
let item = make_item_with_financials(FundamentalsFinancials {
consensus_financials: None,
estimates: Some(FundamentalsEstimates {
eps_estimates: vec![],
sales_estimates: vec![FundamentalsEstimate {
value: make_formatted(100.5, "$100.5B"),
percent_change_yoy: make_formatted(8.0, "8%"),
period_offset: Some("P1Q_FUTURE".to_string()),
period: Some("P1Q".to_string()),
revision_direction: Some("DOWN".to_string()),
}],
}),
});
let records = flatten_fundamentals(&["AAPL"], &[item]);
assert_eq!(records.len(), 1);
assert_eq!(records[0].metric, "sales_estimate");
assert_eq!(records[0].value.as_deref(), Some("$100.5B"));
assert_eq!(records[0].period.as_deref(), Some("P1Q"));
assert!(records[0].revision_direction.is_none());
}
#[test]
fn none_financials_produces_no_records() {
let item = FundamentalsItem {
id: Some("AAPL".to_string()),
financials: None,
symbology: None,
};
let records = flatten_fundamentals(&["AAPL"], &[item]);
assert!(records.is_empty());
}
}