alpaca-mock 0.30.0

Executable mock server for alpaca-rust trading and market-data flows
Documentation
use alpaca_trade::options_contracts::{
    ContractStatus, ContractStyle, ContractType, ListResponse, OptionContract,
};
use axum::{
    Json,
    extract::{Path, Query, State},
};
use rust_decimal::Decimal;
use serde::Deserialize;

use crate::auth::MockHttpError;
use crate::state::{ListOptionContractsFilter, MockServerState};

pub(crate) async fn options_contracts_get(
    State(state): State<MockServerState>,
    Path(symbol_or_id): Path<String>,
) -> Result<Json<OptionContract>, MockHttpError> {
    state
        .get_option_contract(&symbol_or_id)
        .map(Json)
        .ok_or_else(|| MockHttpError::not_found("option contract was not found"))
}

#[derive(Debug, Default, Deserialize)]
pub(crate) struct ListOptionContractsQuery {
    underlying_symbols: Option<String>,
    show_deliverables: Option<bool>,
    status: Option<ContractStatus>,
    expiration_date: Option<String>,
    expiration_date_gte: Option<String>,
    expiration_date_lte: Option<String>,
    root_symbol: Option<String>,
    #[serde(rename = "type")]
    contract_type: Option<ContractType>,
    style: Option<ContractStyle>,
    strike_price_gte: Option<Decimal>,
    strike_price_lte: Option<Decimal>,
    page_token: Option<String>,
    limit: Option<u32>,
    ppind: Option<bool>,
}

pub(crate) async fn options_contracts_list(
    State(state): State<MockServerState>,
    Query(query): Query<ListOptionContractsQuery>,
) -> Result<Json<ListResponse>, MockHttpError> {
    if !matches!(query.limit, None | Some(1..=10_000)) {
        return Err(MockHttpError::bad_request(
            "limit must be between 1 and 10000",
        ));
    }
    if query
        .strike_price_gte
        .zip(query.strike_price_lte)
        .is_some_and(|(gte, lte)| gte > lte)
    {
        return Err(MockHttpError::bad_request(
            "strike_price_gte must not exceed strike_price_lte",
        ));
    }

    Ok(Json(state.list_option_contracts(
        ListOptionContractsFilter {
            underlying_symbols: parse_csv(query.underlying_symbols)?,
            show_deliverables: query.show_deliverables,
            status: query.status,
            expiration_date: query.expiration_date,
            expiration_date_gte: query.expiration_date_gte,
            expiration_date_lte: query.expiration_date_lte,
            root_symbol: query.root_symbol,
            contract_type: query.contract_type,
            style: query.style,
            strike_price_gte: query.strike_price_gte,
            strike_price_lte: query.strike_price_lte,
            page_token: query.page_token,
            limit: query.limit,
            ppind: query.ppind,
        },
    )))
}

fn parse_csv(value: Option<String>) -> Result<Option<Vec<String>>, MockHttpError> {
    value
        .map(|value| {
            let values = value
                .split(',')
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(str::to_owned)
                .collect::<Vec<_>>();
            if values.is_empty() {
                Err(MockHttpError::bad_request(
                    "underlying_symbols must not be empty",
                ))
            } else {
                Ok(values)
            }
        })
        .transpose()
}