libfmp 0.4.0

Async, typed Rust client for the Financial Modeling Prep API
Documentation
use std::str::FromStr;

use libfmp::{
    responses::analyst::{
        FinancialEstimate, HistoricalRating, HistoricalStockGrade, PriceTargetConsensus,
        PriceTargetSummary, RatingSnapshot, StockGrade, StockGradesSummary,
    },
    types::{Date, Ticker},
};
use serde::{Serialize, de::DeserializeOwned};

const ESTIMATES: &[u8] = include_bytes!("fixtures/financial_estimates.json");
const SNAPSHOT: &[u8] = include_bytes!("fixtures/ratings_snapshot.json");
const HISTORICAL_RATINGS: &[u8] = include_bytes!("fixtures/historical_ratings.json");
const TARGET_SUMMARY: &[u8] = include_bytes!("fixtures/price_target_summary.json");
const TARGET_CONSENSUS: &[u8] = include_bytes!("fixtures/price_target_consensus.json");
const GRADES: &[u8] = include_bytes!("fixtures/stock_grades.json");
const HISTORICAL_GRADES: &[u8] = include_bytes!("fixtures/historical_stock_grades.json");
const GRADES_SUMMARY: &[u8] = include_bytes!("fixtures/stock_grades_summary.json");

const PUBLISHERS: &str = "[\"StreetInsider\",\"TheFly\",\"Benzinga\",\"Pulse 2.0\",\"TipRanks Contributor\",\"MarketWatch\",\"Investing\",\"Barrons\",\"Investor's Business Daily\"]";

#[test]
fn exact_financial_estimate_decodes_all_22_fields() {
    assert_field_count(ESTIMATES, 22);
    let source: serde_json::Value = serde_json::from_slice(ESTIMATES).unwrap();
    let rows: Vec<FinancialEstimate> = serde_json::from_value(source.clone()).unwrap();
    assert_eq!(
        rows,
        [FinancialEstimate {
            symbol: Ticker::new("AAPL").unwrap(),
            date: Date::from_str("2030-09-27").unwrap(),
            revenue_low: 648_228_509_004,
            revenue_high: 735_022_980_353,
            revenue_avg: 679_000_000_000,
            ebitda_low: 233_968_328_102,
            ebitda_high: 265_295_486_763,
            ebitda_avg: 245_074_834_838,
            ebit_low: 217_109_092_822,
            ebit_high: 246_178_886_382,
            ebit_avg: 227_415_289_483,
            net_income_low: 191_547_261_069,
            net_income_high: 225_370_398_908,
            net_income_avg: 203_538_714_818,
            sga_expense_low: 41_721_580_524,
            sga_expense_high: 47_307_886_087,
            sga_expense_avg: 43_702_109_337,
            eps_avg: 13.565,
            eps_high: 15.01999,
            eps_low: 12.76582,
            num_analysts_revenue: 16,
            num_analysts_eps: 7,
        }]
    );
    assert_eq!(serde_json::to_value(rows).unwrap(), source);
}

#[test]
fn exact_rating_fixtures_keep_snapshot_and_historical_rows_distinct() {
    assert_field_count(SNAPSHOT, 9);
    assert_field_count(HISTORICAL_RATINGS, 10);
    let snapshot_source: serde_json::Value = serde_json::from_slice(SNAPSHOT).unwrap();
    let snapshots: Vec<RatingSnapshot> = serde_json::from_value(snapshot_source.clone()).unwrap();
    assert_eq!(snapshots[0].rating, "B");
    assert_eq!(snapshots[0].overall_score, 3);
    assert_eq!(snapshots[0].return_on_equity_score, 5);
    assert_eq!(serde_json::to_value(snapshots).unwrap(), snapshot_source);

    let historical_source: serde_json::Value = serde_json::from_slice(HISTORICAL_RATINGS).unwrap();
    let historical: Vec<HistoricalRating> =
        serde_json::from_value(historical_source.clone()).unwrap();
    assert_eq!(historical[0].date, Date::from_str("2026-07-30").unwrap());
    assert_eq!(historical[0].price_to_book_score, 1);
    assert_eq!(serde_json::to_value(historical).unwrap(), historical_source);
}

#[test]
fn exact_price_target_fixtures_preserve_raw_publishers_and_numeric_prices() {
    assert_field_count(TARGET_SUMMARY, 10);
    let summary_source: serde_json::Value = serde_json::from_slice(TARGET_SUMMARY).unwrap();
    let summaries: Vec<PriceTargetSummary> =
        serde_json::from_value(summary_source.clone()).unwrap();
    assert_eq!(summaries[0].publishers, PUBLISHERS);
    assert_eq!(summaries[0].last_month_avg_price_target, 333.75);
    assert_eq!(summaries[0].all_time_count, 254);
    assert_eq!(serde_json::to_value(summaries).unwrap(), summary_source);

    assert_field_count(TARGET_CONSENSUS, 5);
    let consensus_source: serde_json::Value = serde_json::from_slice(TARGET_CONSENSUS).unwrap();
    let consensus: Vec<PriceTargetConsensus> =
        serde_json::from_value(consensus_source.clone()).unwrap();
    assert_eq!(consensus[0].target_high, 400.0);
    assert_eq!(consensus[0].target_consensus, 337.67);
    assert_eq!(consensus[0].target_median, 340.0);
    let encoded = serde_json::to_value(consensus).unwrap();
    assert_eq!(encoded[0]["targetHigh"].as_f64(), Some(400.0));
    assert_eq!(encoded[0]["targetConsensus"].as_f64(), Some(337.67));
}

#[test]
fn exact_grade_fixtures_preserve_both_incompatible_bucket_key_families() {
    assert_field_count(GRADES, 6);
    let grade_source: serde_json::Value = serde_json::from_slice(GRADES).unwrap();
    let grades: Vec<StockGrade> = serde_json::from_value(grade_source.clone()).unwrap();
    assert_eq!(grades[0].grading_company, "Morgan Stanley");
    assert_eq!(grades[0].previous_grade, "Overweight");
    assert_eq!(grades[0].action, "maintain");
    assert_eq!(serde_json::to_value(grades).unwrap(), grade_source);

    assert_field_count(HISTORICAL_GRADES, 7);
    let historical_source: serde_json::Value = serde_json::from_slice(HISTORICAL_GRADES).unwrap();
    let historical: Vec<HistoricalStockGrade> =
        serde_json::from_value(historical_source.clone()).unwrap();
    assert_eq!(historical[0].analyst_ratings_strong_buy, 6);
    assert_eq!(historical[0].analyst_ratings_strong_sell, 2);
    assert_eq!(serde_json::to_value(historical).unwrap(), historical_source);

    assert_field_count(GRADES_SUMMARY, 7);
    let summary_source: serde_json::Value = serde_json::from_slice(GRADES_SUMMARY).unwrap();
    let summary: Vec<StockGradesSummary> = serde_json::from_value(summary_source.clone()).unwrap();
    assert_eq!(summary[0].strong_buy, 1);
    assert_eq!(summary[0].strong_sell, 0);
    assert_eq!(summary[0].consensus, "Buy");
    assert_eq!(serde_json::to_value(summary).unwrap(), summary_source);
}

#[test]
fn estimate_amounts_are_signed_and_counts_preserve_the_full_u64_domain() {
    let amount_fields = [
        "revenueLow",
        "revenueHigh",
        "revenueAvg",
        "ebitdaLow",
        "ebitdaHigh",
        "ebitdaAvg",
        "ebitLow",
        "ebitHigh",
        "ebitAvg",
        "netIncomeLow",
        "netIncomeHigh",
        "netIncomeAvg",
        "sgaExpenseLow",
        "sgaExpenseHigh",
        "sgaExpenseAvg",
    ];
    for (index, field) in amount_fields.into_iter().enumerate() {
        let mut source: serde_json::Value = serde_json::from_slice(ESTIMATES).unwrap();
        source[0][field] = if index % 2 == 0 {
            serde_json::json!(i64::MIN)
        } else {
            serde_json::json!(i64::MAX)
        };
        let rows: Vec<FinancialEstimate> = serde_json::from_value(source.clone()).unwrap();
        assert_eq!(
            serde_json::to_value(rows).unwrap()[0][field],
            source[0][field]
        );
    }

    for field in ["numAnalystsRevenue", "numAnalystsEps"] {
        let mut source: serde_json::Value = serde_json::from_slice(ESTIMATES).unwrap();
        source[0][field] = serde_json::json!(u64::MAX);
        let rows: Vec<FinancialEstimate> = serde_json::from_value(source).unwrap();
        assert_eq!(serde_json::to_value(rows).unwrap()[0][field], u64::MAX);
    }

    let mut snapshot: serde_json::Value = serde_json::from_slice(SNAPSHOT).unwrap();
    snapshot[0]["overallScore"] = serde_json::json!(u64::MAX);
    let rows: Vec<RatingSnapshot> = serde_json::from_value(snapshot).unwrap();
    assert_eq!(rows[0].overall_score, u64::MAX);

    let mut historical: serde_json::Value = serde_json::from_slice(HISTORICAL_GRADES).unwrap();
    historical[0]["analystRatingsStrongBuy"] = serde_json::json!(u64::MAX);
    let rows: Vec<HistoricalStockGrade> = serde_json::from_value(historical).unwrap();
    assert_eq!(rows[0].analyst_ratings_strong_buy, u64::MAX);

    let mut summary: serde_json::Value = serde_json::from_slice(GRADES_SUMMARY).unwrap();
    summary[0]["strongBuy"] = serde_json::json!(u64::MAX);
    let rows: Vec<StockGradesSummary> = serde_json::from_value(summary).unwrap();
    assert_eq!(rows[0].strong_buy, u64::MAX);
}

#[test]
fn all_fields_are_required_non_null_and_unknowns_are_accepted() {
    assert_contract::<FinancialEstimate>(ESTIMATES);
    assert_contract::<RatingSnapshot>(SNAPSHOT);
    assert_contract::<HistoricalRating>(HISTORICAL_RATINGS);
    assert_contract::<PriceTargetSummary>(TARGET_SUMMARY);
    assert_contract::<PriceTargetConsensus>(TARGET_CONSENSUS);
    assert_contract::<StockGrade>(GRADES);
    assert_contract::<HistoricalStockGrade>(HISTORICAL_GRADES);
    assert_contract::<StockGradesSummary>(GRADES_SUMMARY);
}

#[test]
fn all_eight_contracts_are_bare_arrays_preserving_empty_and_multiple_rows() {
    assert_bare_array::<FinancialEstimate>(ESTIMATES);
    assert_bare_array::<RatingSnapshot>(SNAPSHOT);
    assert_bare_array::<HistoricalRating>(HISTORICAL_RATINGS);
    assert_bare_array::<PriceTargetSummary>(TARGET_SUMMARY);
    assert_bare_array::<PriceTargetConsensus>(TARGET_CONSENSUS);
    assert_bare_array::<StockGrade>(GRADES);
    assert_bare_array::<HistoricalStockGrade>(HISTORICAL_GRADES);
    assert_bare_array::<StockGradesSummary>(GRADES_SUMMARY);
    assert!(
        serde_json::from_value::<Vec<FinancialEstimate>>(serde_json::json!({ "estimates": [] }))
            .is_err()
    );
}

fn assert_contract<T: DeserializeOwned>(fixture: &[u8]) {
    let source: serde_json::Value = serde_json::from_slice(fixture).unwrap();
    let keys = source[0]
        .as_object()
        .unwrap()
        .keys()
        .cloned()
        .collect::<Vec<_>>();
    for key in keys {
        let mut missing = source.clone();
        missing[0].as_object_mut().unwrap().remove(&key);
        assert!(
            serde_json::from_value::<Vec<T>>(missing).is_err(),
            "accepted missing {key}"
        );
        let mut null = source.clone();
        null[0][&key] = serde_json::Value::Null;
        assert!(
            serde_json::from_value::<Vec<T>>(null).is_err(),
            "accepted null {key}"
        );
    }
    let mut forward = source;
    forward[0]["futureProviderField"] = serde_json::json!({ "nested": [1, true, null] });
    assert_eq!(serde_json::from_value::<Vec<T>>(forward).unwrap().len(), 1);
}

fn assert_bare_array<T: DeserializeOwned + Serialize>(fixture: &[u8]) {
    assert!(serde_json::from_slice::<Vec<T>>(b"[]").unwrap().is_empty());
    let mut source: serde_json::Value = serde_json::from_slice(fixture).unwrap();
    let duplicate = source[0].clone();
    source.as_array_mut().unwrap().push(duplicate);
    assert_eq!(serde_json::from_value::<Vec<T>>(source).unwrap().len(), 2);
}

fn assert_field_count(fixture: &[u8], expected: usize) {
    let value: serde_json::Value = serde_json::from_slice(fixture).unwrap();
    assert_eq!(value[0].as_object().unwrap().len(), expected);
}