use serde::{Deserialize, Serialize};
use crate::error::Result;
use crate::models::fundamentals::FinancialStatement;
use crate::providers::build_financial_statement;
use crate::{Frequency, Provider, StatementType};
use super::build_client;
use super::models::PaginatedResponseDTO;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StatementPeriodDTO {
pub cik: Option<String>,
pub tickers: Option<Vec<String>>,
pub period_end: Option<String>,
pub filing_date: Option<String>,
pub fiscal_year: Option<f64>,
pub fiscal_quarter: Option<f64>,
pub timeframe: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct IncomeStatementDTO {
#[serde(flatten)]
pub period: StatementPeriodDTO,
pub revenue: Option<f64>,
pub cost_of_revenue: Option<f64>,
pub gross_profit: Option<f64>,
pub research_development: Option<f64>,
pub selling_general_administrative: Option<f64>,
pub depreciation_depletion_amortization: Option<f64>,
pub other_operating_expenses: Option<f64>,
pub total_operating_expenses: Option<f64>,
pub operating_income: Option<f64>,
pub interest_expense: Option<f64>,
pub interest_income: Option<f64>,
pub other_income_expense: Option<f64>,
pub total_other_income_expense: Option<f64>,
pub income_before_income_taxes: Option<f64>,
pub income_taxes: Option<f64>,
pub equity_in_affiliates: Option<f64>,
pub discontinued_operations: Option<f64>,
pub extraordinary_items: Option<f64>,
pub noncontrolling_interest: Option<f64>,
pub consolidated_net_income_loss: Option<f64>,
pub net_income_loss_attributable_common_shareholders: Option<f64>,
pub preferred_stock_dividends_declared: Option<f64>,
pub basic_earnings_per_share: Option<f64>,
pub diluted_earnings_per_share: Option<f64>,
pub basic_shares_outstanding: Option<f64>,
pub diluted_shares_outstanding: Option<f64>,
pub ebitda: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct BalanceSheetDTO {
#[serde(flatten)]
pub period: StatementPeriodDTO,
pub cash_and_equivalents: Option<f64>,
pub short_term_investments: Option<f64>,
pub receivables: Option<f64>,
pub inventories: Option<f64>,
pub other_current_assets: Option<f64>,
pub total_current_assets: Option<f64>,
pub property_plant_equipment_net: Option<f64>,
pub goodwill: Option<f64>,
pub intangible_assets_net: Option<f64>,
pub other_assets: Option<f64>,
pub total_assets: Option<f64>,
pub accounts_payable: Option<f64>,
pub accrued_and_other_current_liabilities: Option<f64>,
pub debt_current: Option<f64>,
pub deferred_revenue_current: Option<f64>,
pub total_current_liabilities: Option<f64>,
pub long_term_debt_and_capital_lease_obligations: Option<f64>,
pub other_noncurrent_liabilities: Option<f64>,
pub total_liabilities: Option<f64>,
pub commitments_and_contingencies: Option<f64>,
pub preferred_stock: Option<f64>,
pub common_stock: Option<f64>,
pub additional_paid_in_capital: Option<f64>,
pub retained_earnings_deficit: Option<f64>,
pub treasury_stock: Option<f64>,
pub accumulated_other_comprehensive_income: Option<f64>,
pub other_equity: Option<f64>,
pub total_equity_attributable_to_parent: Option<f64>,
pub noncontrolling_interest: Option<f64>,
pub total_equity: Option<f64>,
pub total_liabilities_and_equity: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CashFlowStatementDTO {
#[serde(flatten)]
pub period: StatementPeriodDTO,
pub net_income: Option<f64>,
pub depreciation_depletion_and_amortization: Option<f64>,
pub change_in_other_operating_assets_and_liabilities_net: Option<f64>,
pub other_operating_activities: Option<f64>,
pub cash_from_operating_activities_continuing_operations: Option<f64>,
pub net_cash_from_operating_activities_discontinued_operations: Option<f64>,
pub net_cash_from_operating_activities: Option<f64>,
pub purchase_of_property_plant_and_equipment: Option<f64>,
pub sale_of_property_plant_and_equipment: Option<f64>,
pub other_investing_activities: Option<f64>,
pub net_cash_from_investing_activities_continuing_operations: Option<f64>,
pub net_cash_from_investing_activities_discontinued_operations: Option<f64>,
pub net_cash_from_investing_activities: Option<f64>,
pub dividends: Option<f64>,
pub short_term_debt_issuances_repayments: Option<f64>,
pub long_term_debt_issuances_repayments: Option<f64>,
pub other_financing_activities: Option<f64>,
pub net_cash_from_financing_activities_continuing_operations: Option<f64>,
pub net_cash_from_financing_activities_discontinued_operations: Option<f64>,
pub net_cash_from_financing_activities: Option<f64>,
pub effect_of_currency_exchange_rate: Option<f64>,
pub change_in_cash_and_equivalents: Option<f64>,
pub income_loss_from_discontinued_operations: Option<f64>,
pub noncontrolling_interests: Option<f64>,
pub other_cash_adjustments: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ShortInterestDTO {
pub ticker: Option<String>,
pub settlement_date: Option<String>,
pub short_interest: Option<f64>,
pub avg_daily_volume: Option<f64>,
pub days_to_cover: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ShortVolumeDTO {
pub ticker: Option<String>,
pub date: Option<String>,
pub short_volume: Option<f64>,
pub exempt_volume: Option<f64>,
pub non_exempt_volume: Option<f64>,
pub total_volume: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FloatDataDTO {
pub ticker: Option<String>,
pub free_float: Option<f64>,
pub free_float_percent: Option<f64>,
pub effective_date: Option<String>,
}
pub async fn income_statements(
ticker: &str,
params: &[(&str, &str)],
) -> Result<PaginatedResponseDTO<IncomeStatementDTO>> {
statements("income-statements", ticker, params).await
}
pub async fn balance_sheets(
ticker: &str,
params: &[(&str, &str)],
) -> Result<PaginatedResponseDTO<BalanceSheetDTO>> {
statements("balance-sheets", ticker, params).await
}
pub async fn cash_flow_statements(
ticker: &str,
params: &[(&str, &str)],
) -> Result<PaginatedResponseDTO<CashFlowStatementDTO>> {
statements("cash-flow-statements", ticker, params).await
}
async fn statements<T: serde::de::DeserializeOwned>(
endpoint: &str,
ticker: &str,
params: &[(&str, &str)],
) -> Result<PaginatedResponseDTO<T>> {
let client = build_client()?;
let path = format!("/stocks/financials/v1/{endpoint}");
let mut query: Vec<(&str, &str)> = vec![("tickers", ticker)];
query.extend_from_slice(params);
client.get(&path, &query).await
}
const STATEMENT_METADATA: [&str; 7] = [
"cik",
"tickers",
"period_end",
"filing_date",
"fiscal_year",
"fiscal_quarter",
"timeframe",
];
fn pivot_statements<T: serde::Serialize>(
rows: Vec<T>,
) -> std::collections::HashMap<String, std::collections::HashMap<String, serde_json::Value>> {
let mut data: std::collections::HashMap<
String,
std::collections::HashMap<String, serde_json::Value>,
> = std::collections::HashMap::new();
for row in rows {
let Ok(serde_json::Value::Object(obj)) = serde_json::to_value(&row) else {
continue;
};
let field = |key: &str| obj.get(key).and_then(serde_json::Value::as_str);
let period = field("period_end")
.or_else(|| field("filing_date"))
.unwrap_or("unknown")
.to_string();
for (metric, value) in &obj {
if STATEMENT_METADATA.contains(&metric.as_str())
|| !matches!(value, serde_json::Value::Number(_))
{
continue;
}
data.entry(metric.clone())
.or_default()
.insert(period.clone(), value.clone());
}
}
data
}
pub async fn fetch_financials_response(
symbol: &str,
stmt_type: StatementType,
frequency: Frequency,
) -> Result<FinancialStatement> {
let timeframe = match frequency {
Frequency::Annual => "annual",
Frequency::Quarterly => "quarterly",
};
let params = [("timeframe", timeframe), ("limit", "100")];
let data = match stmt_type {
StatementType::Income => pivot_statements(
income_statements(symbol, ¶ms)
.await?
.results
.unwrap_or_default(),
),
StatementType::Balance => pivot_statements(
balance_sheets(symbol, ¶ms)
.await?
.results
.unwrap_or_default(),
),
StatementType::CashFlow => pivot_statements(
cash_flow_statements(symbol, ¶ms)
.await?
.results
.unwrap_or_default(),
),
};
Ok(build_financial_statement(
symbol.to_string(),
stmt_type.as_str().to_string(),
frequency.as_str().to_string(),
Provider::Polygon,
data,
))
}
pub async fn stock_short_interest(
ticker: &str,
params: &[(&str, &str)],
) -> Result<PaginatedResponseDTO<ShortInterestDTO>> {
let client = build_client()?;
let mut query = vec![("ticker", ticker)];
query.extend_from_slice(params);
client.get("/stocks/v1/short-interest", &query).await
}
pub async fn stock_short_volume(
ticker: &str,
params: &[(&str, &str)],
) -> Result<PaginatedResponseDTO<ShortVolumeDTO>> {
let client = build_client()?;
let mut query = vec![("ticker", ticker)];
query.extend_from_slice(params);
client.get("/stocks/v1/short-volume", &query).await
}
pub async fn stock_float(ticker: &str) -> Result<PaginatedResponseDTO<FloatDataDTO>> {
let client = build_client()?;
client.get("/stocks/vX/float", &[("ticker", ticker)]).await
}
pub async fn fetch_short_interest_response(
symbol: &str,
) -> Result<Vec<crate::models::fundamentals::ShortInterest>> {
let paginated = stock_short_interest(symbol, &[("limit", "50")]).await?;
Ok(paginated
.results
.unwrap_or_default()
.into_iter()
.map(|d| crate::models::fundamentals::ShortInterest {
settlement_date: d.settlement_date,
short_interest: d.short_interest,
avg_daily_volume: d.avg_daily_volume,
days_to_cover: d.days_to_cover,
})
.collect())
}
pub async fn fetch_short_volume_response(
symbol: &str,
) -> Result<Vec<crate::models::fundamentals::ShortVolume>> {
let paginated = stock_short_volume(symbol, &[("limit", "50")]).await?;
Ok(paginated
.results
.unwrap_or_default()
.into_iter()
.map(|d| crate::models::fundamentals::ShortVolume {
date: d.date,
short_volume: d.short_volume,
short_exempt_volume: d.exempt_volume,
total_volume: d.total_volume,
})
.collect())
}
pub async fn fetch_share_float_response(
symbol: &str,
) -> Result<crate::models::fundamentals::ShareFloat> {
let paginated = stock_float(symbol).await?;
let d = paginated
.results
.unwrap_or_default()
.into_iter()
.next()
.ok_or_else(|| crate::error::FinanceError::ResponseStructureError {
field: "results".into(),
context: format!("no float data returned for {symbol}"),
})?;
Ok(crate::models::fundamentals::ShareFloat {
symbol: d.ticker.or_else(|| Some(symbol.to_string())),
float_shares: d.free_float,
outstanding_shares: None,
float_percent: d.free_float_percent,
date: d.effective_date,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn income_statements_read_the_flat_v1_shape() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/stocks/financials/v1/income-statements")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("apiKey".into(), "test-key".into()),
mockito::Matcher::UrlEncoded("tickers".into(), "AAPL".into()),
mockito::Matcher::UrlEncoded("timeframe".into(), "annual".into()),
]))
.with_status(200)
.with_body(
r#"{
"status": "OK",
"request_id": "abc",
"results": [{
"cik": "0000320193",
"tickers": ["AAPL"],
"period_end": "2024-09-28",
"filing_date": "2024-11-01",
"fiscal_year": 2024,
"fiscal_quarter": 4,
"timeframe": "annual",
"revenue": 391035000000,
"cost_of_revenue": 210352000000,
"gross_profit": 180683000000,
"operating_income": 123216000000,
"diluted_earnings_per_share": 6.08,
"ebitda": 134661000000
}]
}"#,
)
.create_async()
.await;
let client = super::super::build_test_client(&server.url()).unwrap();
let resp: PaginatedResponseDTO<IncomeStatementDTO> = client
.get(
"/stocks/financials/v1/income-statements",
&[("tickers", "AAPL"), ("timeframe", "annual")],
)
.await
.unwrap();
let row = &resp.results.unwrap()[0];
assert_eq!(row.period.period_end.as_deref(), Some("2024-09-28"));
assert_eq!(row.period.fiscal_year, Some(2024.0));
assert_eq!(row.revenue, Some(391_035_000_000.0));
assert_eq!(row.gross_profit, Some(180_683_000_000.0));
assert_eq!(row.diluted_earnings_per_share, Some(6.08));
}
#[test]
fn pivot_keys_metrics_by_period_and_drops_metadata() {
let rows: Vec<BalanceSheetDTO> = serde_json::from_str(
r#"[
{"period_end":"2024-09-28","fiscal_year":2024,"tickers":["AAPL"],
"total_assets":364980000000,"total_liabilities":308030000000},
{"period_end":"2023-09-30","fiscal_year":2023,"tickers":["AAPL"],
"total_assets":352583000000}
]"#,
)
.unwrap();
let data = pivot_statements(rows);
assert_eq!(
data["total_assets"]["2024-09-28"].as_f64(),
Some(364_980_000_000.0)
);
assert_eq!(data["total_assets"].len(), 2);
assert_eq!(data["total_liabilities"].len(), 1);
assert!(!data.contains_key("fiscal_year"));
assert!(!data.contains_key("period_end"));
}
#[test]
fn pivot_falls_back_to_the_filing_date() {
let rows: Vec<CashFlowStatementDTO> =
serde_json::from_str(r#"[{"filing_date":"2024-11-01","net_income":93736000000}]"#)
.unwrap();
assert!(pivot_statements(rows)["net_income"].contains_key("2024-11-01"));
}
#[tokio::test]
async fn share_float_reports_the_percent_and_never_derives_outstanding() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/stocks/vX/float")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_body(
r#"{"status":"OK","results":[{"ticker":"AAPL","free_float":15000000000,
"free_float_percent":98.5,"effective_date":"2025-11-01"}]}"#,
)
.create_async()
.await;
let client = super::super::build_test_client(&server.url()).unwrap();
let resp: PaginatedResponseDTO<FloatDataDTO> =
client.get("/stocks/vX/float", &[]).await.unwrap();
let d = resp.results.unwrap().into_iter().next().unwrap();
assert_eq!(d.free_float, Some(15_000_000_000.0));
assert_eq!(d.free_float_percent, Some(98.5));
let share_float = crate::models::fundamentals::ShareFloat {
symbol: d.ticker,
float_shares: d.free_float,
outstanding_shares: None,
float_percent: d.free_float_percent,
date: d.effective_date,
};
assert_eq!(share_float.float_percent, Some(98.5));
assert!(share_float.outstanding_shares.is_none());
}
}