libfmp 0.4.0

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

use std::{str::FromStr, sync::Arc};

use libfmp::{
    Client,
    config::Authentication,
    endpoints::{
        EndpointSpec,
        metadata::{AccessRequirement, EndpointBounds, GeographicAvailability},
        technical_indicators::{
            TechnicalIndicatorQuery, exponential_moving_average, simple_moving_average,
            weighted_moving_average,
        },
    },
    error::ErrorCategory,
    query::{ChartTimeframe, PeriodLength},
    responses::technical_indicators::{
        ExponentialMovingAverageBar, SimpleMovingAverageBar, WeightedMovingAverageBar,
    },
    transport::HttpMethod,
    types::{Date, Ticker},
};

use support::{FixtureExecutor, json_fixture};

const SMA: &[u8] = include_bytes!("fixtures/technical_indicator_sma.json");
const EMA: &[u8] = include_bytes!("fixtures/technical_indicator_ema.json");
const WMA: &[u8] = include_bytes!("fixtures/technical_indicator_wma.json");

fn query(symbol: &str, timeframe: ChartTimeframe) -> TechnicalIndicatorQuery {
    TechnicalIndicatorQuery::new(
        Ticker::new(symbol).unwrap(),
        PeriodLength::new(10).unwrap(),
        timeframe,
    )
}

#[test]
fn descriptors_use_exact_paths_bare_types_and_only_documented_metadata() {
    let sma = simple_moving_average(query("AAPL", ChartTimeframe::OneDay));
    let ema = exponential_moving_average(query("AAPL", ChartTimeframe::OneDay));
    let wma = weighted_moving_average(query("AAPL", ChartTimeframe::OneDay));
    assert_facts(&sma, "technical-indicators/sma");
    assert_facts(&ema, "technical-indicators/ema");
    assert_facts(&wma, "technical-indicators/wma");
    assert_response_types(&sma, &ema, &wma);
}

fn assert_facts<Q, R>(endpoint: &EndpointSpec<Q, Vec<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);
}

fn assert_response_types(
    _: &EndpointSpec<TechnicalIndicatorQuery, Vec<SimpleMovingAverageBar>>,
    _: &EndpointSpec<TechnicalIndicatorQuery, Vec<ExponentialMovingAverageBar>>,
    _: &EndpointSpec<TechnicalIndicatorQuery, Vec<WeightedMovingAverageBar>>,
) {
}

#[tokio::test]
async fn custom_proxy_keeps_query_order_headers_fixtures_and_independent_dates() {
    let executor = Arc::new(FixtureExecutor::new([
        json_fixture(SMA),
        json_fixture(EMA),
        json_fixture(WMA),
    ]));
    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", "technical indicators")
        .executor(executor.clone())
        .build()
        .unwrap();
    let from = Date::from_str("2026-03-01").unwrap();
    let to = Date::from_str("2026-06-01").unwrap();

    let sma = client
        .simple_moving_average(
            query("BRK.B / Class A", ChartTimeframe::OneDay)
                .with_from(from)
                .with_to(to),
        )
        .await
        .unwrap();
    let ema = client
        .exponential_moving_average(query("AAPL", ChartTimeframe::OneHour).with_from(from))
        .await
        .unwrap();
    let wma = client
        .weighted_moving_average(query("AAPL", ChartTimeframe::FourHours).with_to(to))
        .await
        .unwrap();
    assert_eq!((sma[0].sma, sma[0].volume), (331.621, 29_207_295));
    assert_eq!(ema[0].ema, 331.1209325826155);
    assert_eq!(wma[0].wma, 333.21345454545457);

    let requests = executor.requests();
    assert!(requests.iter().all(|request| {
        request.method() == HttpMethod::Get
            && request.expose_headers()["x-router-token"] == "Token proxy-secret"
            && request.expose_headers()["x-data-scope"] == "technical indicators"
    }));
    assert_eq!(
        requests
            .iter()
            .map(|request| request.expose_url().as_str())
            .collect::<Vec<_>>(),
        [
            "https://proxy.example/router/gateway/stable/technical-indicators/sma?symbol=BRK.B+%2F+Class+A&periodLength=10&timeframe=1day&from=2026-03-01&to=2026-06-01",
            "https://proxy.example/router/gateway/stable/technical-indicators/ema?symbol=AAPL&periodLength=10&timeframe=1hour&from=2026-03-01",
            "https://proxy.example/router/gateway/stable/technical-indicators/wma?symbol=AAPL&periodLength=10&timeframe=4hour&to=2026-06-01",
        ]
    );
}

#[tokio::test]
async fn all_seven_timeframes_have_exact_wire_urls_and_omit_dates() {
    let timeframes = [
        (ChartTimeframe::OneMinute, "1min"),
        (ChartTimeframe::FiveMinutes, "5min"),
        (ChartTimeframe::FifteenMinutes, "15min"),
        (ChartTimeframe::ThirtyMinutes, "30min"),
        (ChartTimeframe::OneHour, "1hour"),
        (ChartTimeframe::FourHours, "4hour"),
        (ChartTimeframe::OneDay, "1day"),
    ];
    let executor = Arc::new(FixtureExecutor::new(
        std::iter::repeat_with(|| json_fixture(SMA)).take(timeframes.len()),
    ));
    let client = Client::builder()
        .authentication(Authentication::fmp_header("secret"))
        .executor(executor.clone())
        .build()
        .unwrap();
    for (timeframe, _) in timeframes {
        client
            .simple_moving_average(query("AAPL", timeframe))
            .await
            .unwrap();
    }
    for (request, (_, wire)) in executor.requests().iter().zip(timeframes) {
        assert_eq!(
            request.expose_url().as_str(),
            format!(
                "https://financialmodelingprep.com/stable/technical-indicators/sma?symbol=AAPL&periodLength=10&timeframe={wire}"
            )
        );
    }
}

#[tokio::test]
async fn direct_header_and_query_auth_preserve_all_three_urls() {
    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(SMA),
            json_fixture(EMA),
            json_fixture(WMA),
        ]));
        let client = Client::builder()
            .authentication(authentication)
            .executor(executor.clone())
            .build()
            .unwrap();
        client
            .simple_moving_average(query("AAPL", ChartTimeframe::OneDay))
            .await
            .unwrap();
        client
            .exponential_moving_average(query("AAPL", ChartTimeframe::OneDay))
            .await
            .unwrap();
        client
            .weighted_moving_average(query("AAPL", ChartTimeframe::OneDay))
            .await
            .unwrap();
        let requests = executor.requests();
        let expected = ["sma", "ema", "wma"];
        for (request, indicator) in requests.iter().zip(expected) {
            assert_eq!(
                request.expose_url().as_str(),
                format!(
                    "https://financialmodelingprep.com/stable/technical-indicators/{indicator}?symbol=AAPL&periodLength=10&timeframe=1day{suffix}"
                )
            );
            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_arrays_keep_each_endpoint_identity() {
    let executor = Arc::new(FixtureExecutor::new(
        std::iter::repeat_with(|| json_fixture(b"{}")).take(3),
    ));
    let client = Client::builder()
        .authentication(Authentication::fmp_header("secret"))
        .executor(executor)
        .build()
        .unwrap();
    let errors = [
        client
            .simple_moving_average(query("AAPL", ChartTimeframe::OneDay))
            .await
            .unwrap_err(),
        client
            .exponential_moving_average(query("AAPL", ChartTimeframe::OneDay))
            .await
            .unwrap_err(),
        client
            .weighted_moving_average(query("AAPL", ChartTimeframe::OneDay))
            .await
            .unwrap_err(),
    ];
    for (error, endpoint) in errors.iter().zip([
        "technical-indicators/sma",
        "technical-indicators/ema",
        "technical-indicators/wma",
    ]) {
        assert_eq!(error.category(), ErrorCategory::Decode);
        assert_eq!(error.endpoint(), Some(endpoint));
        assert_eq!(error.status_code(), Some(200));
    }
}