libfmp 0.4.0

Async, typed Rust client for the Financial Modeling Prep API
Documentation
mod support;

use std::sync::Arc;

use libfmp::{
    Client,
    config::Authentication,
    endpoints::{
        EndpointSpec,
        dcf::{
            CustomDcfQuery, DcfAssumptions, custom_discounted_cash_flow,
            custom_levered_discounted_cash_flow,
        },
        metadata::{AccessRequirement, EndpointBounds, GeographicAvailability},
    },
    error::ErrorCategory,
    responses::dcf::{CustomDcfValuation, CustomLeveredDcfValuation},
    transport::HttpMethod,
    types::{FiniteDecimal, Ticker},
};

use support::{FixtureExecutor, json_fixture};

const CUSTOM: &[u8] = include_bytes!("fixtures/custom_discounted_cash_flow.json");
const LEVERED: &[u8] = include_bytes!("fixtures/custom_levered_discounted_cash_flow.json");

fn decimal(value: f64) -> FiniteDecimal {
    FiniteDecimal::new(value).unwrap()
}

#[test]
fn descriptors_use_exact_get_paths_distinct_rows_and_worldwide_unbounded_metadata() {
    let query = CustomDcfQuery::new(
        Ticker::new("BRK.B / Class A").unwrap(),
        DcfAssumptions::new(),
    );
    assert_eq!(query.symbol().as_str(), "BRK.B / Class A");
    assert_eq!(query.assumptions(), &DcfAssumptions::default());

    let custom = custom_discounted_cash_flow(query.clone());
    let levered = custom_levered_discounted_cash_flow(query);
    assert_custom_facts(&custom, "custom-discounted-cash-flow");
    assert_levered_facts(&levered, "custom-levered-discounted-cash-flow");
}

fn assert_custom_facts(
    endpoint: &EndpointSpec<CustomDcfQuery, Vec<CustomDcfValuation>>,
    path: &'static str,
) {
    assert_facts(endpoint, path);
}

fn assert_levered_facts(
    endpoint: &EndpointSpec<CustomDcfQuery, Vec<CustomLeveredDcfValuation>>,
    path: &'static str,
) {
    assert_facts(endpoint, path);
}

fn assert_facts<Q, R>(endpoint: &EndpointSpec<Q, R>, path: &'static str) {
    assert_eq!(endpoint.method(), HttpMethod::Get);
    assert_eq!(endpoint.id(), path);
    assert_eq!(endpoint.relative_path(), path);
    assert_eq!(
        endpoint.metadata().geography(),
        GeographicAvailability::Worldwide
    );
    assert_eq!(endpoint.metadata().access(), AccessRequirement::Unspecified);
    assert_eq!(endpoint.metadata().bounds(), EndpointBounds::new());
    assert_eq!(endpoint.metadata().conditional_plan(), None);
    assert_eq!(endpoint.metadata().realtime(), None);
}

#[test]
fn all_eighteen_assumptions_are_independent_and_exposed_without_rescaling() {
    let assumptions = documented_assumptions();

    assert_eq!(
        assumptions.revenue_growth_pct(),
        Some(decimal(0.1094119804597946))
    );
    assert_eq!(assumptions.ebitda_pct(), Some(decimal(0.31273548388)));
    assert_eq!(
        assumptions.depreciation_and_amortization_pct(),
        Some(decimal(0.0345531631720999))
    );
    assert_eq!(
        assumptions.cash_and_short_term_investments_pct(),
        Some(decimal(0.2344222126801843))
    );
    assert_eq!(
        assumptions.receivables_pct(),
        Some(decimal(0.1533770531229388))
    );
    assert_eq!(
        assumptions.inventories_pct(),
        Some(decimal(0.0155245674227653))
    );
    assert_eq!(assumptions.payable_pct(), Some(decimal(0.1614868903169657)));
    assert_eq!(assumptions.ebit_pct(), Some(decimal(0.2781823207138459)));
    assert_eq!(
        assumptions.capital_expenditure_pct(),
        Some(decimal(0.0306025847141713))
    );
    assert_eq!(
        assumptions.operating_cash_flow_pct(),
        Some(decimal(0.2886333485760204))
    );
    assert_eq!(
        assumptions.selling_general_and_administrative_expenses_pct(),
        Some(decimal(0.0662854095187211))
    );
    assert_eq!(assumptions.tax_rate(), Some(decimal(0.14919579658453103)));
    assert_eq!(assumptions.long_term_growth_rate(), Some(decimal(4.0)));
    assert_eq!(assumptions.cost_of_debt(), Some(decimal(3.64)));
    assert_eq!(assumptions.cost_of_equity(), Some(decimal(9.51168)));
    assert_eq!(assumptions.market_risk_premium(), Some(decimal(4.72)));
    assert_eq!(assumptions.beta(), Some(decimal(1.244)));
    assert_eq!(assumptions.risk_free_rate(), Some(decimal(3.64)));
}

#[test]
fn finite_decimal_rejects_every_nonfinite_input_before_an_assumption_can_be_built() {
    assert!(FiniteDecimal::new(f64::NAN).is_err());
    assert!(FiniteDecimal::new(f64::INFINITY).is_err());
    assert!(FiniteDecimal::new(f64::NEG_INFINITY).is_err());
}

#[tokio::test]
async fn proxy_encodes_symbol_then_all_present_assumptions_in_documented_order() {
    let executor = Arc::new(FixtureExecutor::new([
        json_fixture(CUSTOM),
        json_fixture(LEVERED),
    ]));
    let client = Client::builder()
        .base_url("https://proxy.example/router")
        .path_prefix("gateway/stable")
        .authentication(Authentication::custom_header(
            "x-router-token",
            Some("Token ".to_owned()),
            "proxy-secret",
        ))
        .default_header("x-data-scope", "custom dcf")
        .executor(executor.clone())
        .build()
        .unwrap();

    let custom = client
        .custom_discounted_cash_flow(CustomDcfQuery::new(
            Ticker::new("BRK.B / Class A").unwrap(),
            documented_assumptions(),
        ))
        .await
        .unwrap();
    let levered = client
        .custom_levered_discounted_cash_flow(CustomDcfQuery::new(
            Ticker::new("BRK.B / Class A").unwrap(),
            documented_assumptions(),
        ))
        .await
        .unwrap();

    assert_eq!(custom[0].equity_value_per_share, 147.18);
    assert_eq!(levered[0].equity_value_per_share, 140.71);

    let suffix = "?symbol=BRK.B+%2F+Class+A&revenueGrowthPct=0.1094119804597946&ebitdaPct=0.31273548388&depreciationAndAmortizationPct=0.0345531631720999&cashAndShortTermInvestmentsPct=0.2344222126801843&receivablesPct=0.1533770531229388&inventoriesPct=0.0155245674227653&payablePct=0.1614868903169657&ebitPct=0.2781823207138459&capitalExpenditurePct=0.0306025847141713&operatingCashFlowPct=0.2886333485760204&sellingGeneralAndAdministrativeExpensesPct=0.0662854095187211&taxRate=0.14919579658453103&longTermGrowthRate=4&costOfDebt=3.64&costOfEquity=9.51168&marketRiskPremium=4.72&beta=1.244&riskFreeRate=3.64";
    let requests = executor.requests();
    assert_eq!(requests.len(), 2);
    assert_eq!(
        requests[0].expose_url().as_str(),
        format!("https://proxy.example/router/gateway/stable/custom-discounted-cash-flow{suffix}")
    );
    assert_eq!(
        requests[1].expose_url().as_str(),
        format!(
            "https://proxy.example/router/gateway/stable/custom-levered-discounted-cash-flow{suffix}"
        )
    );
    for request in requests.iter() {
        assert_eq!(request.method(), HttpMethod::Get);
        assert_eq!(
            request.expose_headers()["x-router-token"],
            "Token proxy-secret"
        );
        assert_eq!(request.expose_headers()["x-data-scope"], "custom dcf");
    }
}

#[tokio::test]
async fn omitted_assumptions_stay_omitted_while_zero_and_negative_are_encoded_exactly() {
    let executor = Arc::new(FixtureExecutor::new([
        json_fixture(CUSTOM),
        json_fixture(LEVERED),
    ]));
    let client = Client::builder()
        .base_url("https://proxy.example")
        .authentication(Authentication::None)
        .executor(executor.clone())
        .build()
        .unwrap();

    client
        .custom_discounted_cash_flow(CustomDcfQuery::new(
            Ticker::new("AAPL").unwrap(),
            DcfAssumptions::new(),
        ))
        .await
        .unwrap();
    client
        .custom_levered_discounted_cash_flow(CustomDcfQuery::new(
            Ticker::new("AAPL").unwrap(),
            DcfAssumptions::new()
                .with_revenue_growth_pct(decimal(0.0))
                .with_tax_rate(decimal(-1.25))
                .with_long_term_growth_rate(decimal(4.0)),
        ))
        .await
        .unwrap();

    let requests = executor.requests();
    assert_eq!(
        requests[0].expose_url().as_str(),
        "https://proxy.example/stable/custom-discounted-cash-flow?symbol=AAPL"
    );
    assert_eq!(
        requests[1].expose_url().as_str(),
        "https://proxy.example/stable/custom-levered-discounted-cash-flow?symbol=AAPL&revenueGrowthPct=0&taxRate=-1.25&longTermGrowthRate=4"
    );
}

#[tokio::test]
async fn direct_fmp_header_and_query_auth_preserve_both_custom_paths() {
    for (authentication, suffix, expected_header) in [
        (
            Authentication::fmp_header("header-secret"),
            "",
            Some("header-secret"),
        ),
        (
            Authentication::fmp_query("query-secret"),
            "&apikey=query-secret",
            None,
        ),
    ] {
        let executor = Arc::new(FixtureExecutor::new([
            json_fixture(CUSTOM),
            json_fixture(LEVERED),
        ]));
        let client = Client::builder()
            .authentication(authentication)
            .executor(executor.clone())
            .build()
            .unwrap();
        let query = || CustomDcfQuery::new(Ticker::new("AAPL").unwrap(), DcfAssumptions::new());

        client.custom_discounted_cash_flow(query()).await.unwrap();
        client
            .custom_levered_discounted_cash_flow(query())
            .await
            .unwrap();

        let requests = executor.requests();
        assert_eq!(
            request_urls(&requests),
            [
                format!(
                    "https://financialmodelingprep.com/stable/custom-discounted-cash-flow?symbol=AAPL{suffix}"
                ),
                format!(
                    "https://financialmodelingprep.com/stable/custom-levered-discounted-cash-flow?symbol=AAPL{suffix}"
                ),
            ]
        );
        for request in requests.iter() {
            match expected_header {
                Some(value) => assert_eq!(request.expose_headers()["apikey"], value),
                None => assert!(!request.expose_headers().contains_key("apikey")),
            }
        }
    }
}

#[tokio::test]
async fn malformed_non_array_responses_keep_distinct_custom_endpoint_identities() {
    let executor = Arc::new(FixtureExecutor::new([
        json_fixture(b"{}"),
        json_fixture(b"{}"),
    ]));
    let client = Client::builder()
        .authentication(Authentication::fmp_header("secret"))
        .executor(executor)
        .build()
        .unwrap();
    let query = || CustomDcfQuery::new(Ticker::new("AAPL").unwrap(), DcfAssumptions::new());

    let errors = [
        client
            .custom_discounted_cash_flow(query())
            .await
            .unwrap_err(),
        client
            .custom_levered_discounted_cash_flow(query())
            .await
            .unwrap_err(),
    ];
    for (error, id) in errors.iter().zip([
        "custom-discounted-cash-flow",
        "custom-levered-discounted-cash-flow",
    ]) {
        assert_eq!(error.category(), ErrorCategory::Decode);
        assert_eq!(error.endpoint(), Some(id));
        assert_eq!(error.status_code(), Some(200));
    }
}

fn documented_assumptions() -> DcfAssumptions {
    DcfAssumptions::new()
        .with_revenue_growth_pct(decimal(0.1094119804597946))
        .with_ebitda_pct(decimal(0.31273548388))
        .with_depreciation_and_amortization_pct(decimal(0.0345531631720999))
        .with_cash_and_short_term_investments_pct(decimal(0.2344222126801843))
        .with_receivables_pct(decimal(0.1533770531229388))
        .with_inventories_pct(decimal(0.0155245674227653))
        .with_payable_pct(decimal(0.1614868903169657))
        .with_ebit_pct(decimal(0.2781823207138459))
        .with_capital_expenditure_pct(decimal(0.0306025847141713))
        .with_operating_cash_flow_pct(decimal(0.2886333485760204))
        .with_selling_general_and_administrative_expenses_pct(decimal(0.0662854095187211))
        .with_tax_rate(decimal(0.14919579658453103))
        .with_long_term_growth_rate(decimal(4.0))
        .with_cost_of_debt(decimal(3.64))
        .with_cost_of_equity(decimal(9.51168))
        .with_market_risk_premium(decimal(4.72))
        .with_beta(decimal(1.244))
        .with_risk_free_rate(decimal(3.64))
}

fn request_urls(requests: &[libfmp::transport::PreparedRequest]) -> Vec<String> {
    requests
        .iter()
        .map(|request| request.expose_url().as_str().to_owned())
        .collect()
}