Packages

Elixir client library for cryptocurrency exchanges — generated from CCXT specs via compile-time macros.

Current section

Files

Jump to
ccxt_client lib ccxt response_coercer.ex
Raw

lib/ccxt/response_coercer.ex

defmodule CCXT.ResponseCoercer do
@moduledoc """
Coerces raw API response maps to typed structs.
Part of the Type-Safe API Bundle (Task 149). After the HTTP client returns
raw response data, this module converts it to typed structs for better
developer experience and compile-time safety.
## Usage
By default, API responses are coerced to typed structs:
{:ok, %CCXT.Types.Ticker{}} = Exchange.fetch_ticker("BTC/USDT")
To get raw maps instead, disable normalization:
{:ok, %{}} = Exchange.fetch_ticker("BTC/USDT", normalize: false)
## Response Types
The coercer maps endpoint names to type modules:
| Endpoint | Response Type |
|----------|---------------|
| `fetch_ticker` | `CCXT.Types.Ticker` |
| `fetch_tickers` | `[CCXT.Types.Ticker]` |
| `fetch_markets` | `[CCXT.Types.MarketInterface]` |
| `fetch_order` | `CCXT.Types.Order` |
| `fetch_orders` | `[CCXT.Types.Order]` |
| `fetch_balance` | `CCXT.Types.Balance` |
| `fetch_order_book` | `CCXT.Types.OrderBook` |
| `fetch_trades` | `[CCXT.Types.Trade]` |
| `fetch_positions` | `[CCXT.Types.Position]` |
| `fetch_funding_rate` | `CCXT.Types.FundingRate` |
| `fetch_funding_rates` | `%{symbol => CCXT.Types.FundingRate}` |
| `fetch_funding_rate_history` | `[CCXT.Types.FundingRateHistory]` |
| `create_order` | `CCXT.Types.Order` |
| `cancel_order` | `CCXT.Types.Order` |
| `fetch_deposits` | `[CCXT.Types.Transaction]` |
| `fetch_withdrawals` | `[CCXT.Types.Transaction]` |
| `fetch_deposit_address` | `CCXT.Types.DepositAddress` |
| `transfer` | `CCXT.Types.TransferEntry` |
| `fetch_transfers` | `[CCXT.Types.TransferEntry]` |
| `fetch_ledger` | `[CCXT.Types.LedgerEntry]` |
| `fetch_leverage` | `CCXT.Types.Leverage` |
| `fetch_trading_fee` | `CCXT.Types.TradingFeeInterface` |
| `fetch_trading_fees` | `%{symbol => CCXT.Types.TradingFeeInterface}` |
| `fetch_deposit_withdraw_fees` | `%{code => CCXT.Types.DepositWithdrawFee}` |
| `add_margin` | `CCXT.Types.MarginModification` |
| `reduce_margin` | `CCXT.Types.MarginModification` |
| `fetch_open_interest` | `CCXT.Types.OpenInterest` |
| `fetch_open_interest_history` | `[CCXT.Types.OpenInterest]` |
| `set_margin_mode` | `CCXT.Types.MarginMode` |
| `fetch_margin_mode` | `CCXT.Types.MarginMode` |
| `fetch_liquidations` | `[CCXT.Types.Liquidation]` |
| `fetch_borrow_interest` | `[CCXT.Types.BorrowInterest]` |
| `fetch_cross_borrow_rate` | `CCXT.Types.CrossBorrowRate` |
| `fetch_convert_trade` | `CCXT.Types.Conversion` |
| `fetch_convert_trade_history` | `[CCXT.Types.Conversion]` |
| `fetch_greeks` | `CCXT.Types.Greeks` |
| `fetch_accounts` | `[CCXT.Types.Account]` |
| `fetch_option` | `CCXT.Types.Option` |
| `fetch_option_chain` | `%{symbol => CCXT.Types.Option}` |
| `fetch_funding_history` | `[CCXT.Types.FundingHistory]` |
| `fetch_isolated_borrow_rate` | `CCXT.Types.IsolatedBorrowRate` |
| `fetch_isolated_borrow_rates` | `%{symbol => CCXT.Types.IsolatedBorrowRate}` |
| `fetch_last_prices` | `%{symbol => CCXT.Types.LastPrice}` |
| `fetch_long_short_ratio_history` | `[CCXT.Types.LongShortRatio]` |
| `fetch_leverage_tiers` | `%{symbol => [CCXT.Types.LeverageTier]}` |
| `fetch_market_leverage_tiers` | `[CCXT.Types.LeverageTier]` |
"""
require Logger
# Maps response type atoms to their corresponding modules
@type_modules %{
ticker: CCXT.Types.Ticker,
order: CCXT.Types.Order,
position: CCXT.Types.Position,
balance: CCXT.Types.Balance,
order_book: CCXT.Types.OrderBook,
trade: CCXT.Types.Trade,
funding_rate: CCXT.Types.FundingRate,
funding_rate_history: CCXT.Types.FundingRateHistory,
transaction: CCXT.Types.Transaction,
transfer: CCXT.Types.TransferEntry,
deposit_address: CCXT.Types.DepositAddress,
ledger_entry: CCXT.Types.LedgerEntry,
leverage: CCXT.Types.Leverage,
trading_fee: CCXT.Types.TradingFeeInterface,
deposit_withdraw_fee: CCXT.Types.DepositWithdrawFee,
margin_modification: CCXT.Types.MarginModification,
open_interest: CCXT.Types.OpenInterest,
margin_mode: CCXT.Types.MarginMode,
liquidation: CCXT.Types.Liquidation,
borrow_interest: CCXT.Types.BorrowInterest,
borrow_rate: CCXT.Types.CrossBorrowRate,
conversion: CCXT.Types.Conversion,
greeks: CCXT.Types.Greeks,
account: CCXT.Types.Account,
option: CCXT.Types.Option,
funding_history: CCXT.Types.FundingHistory,
isolated_borrow_rate: CCXT.Types.IsolatedBorrowRate,
last_price: CCXT.Types.LastPrice,
long_short_ratio: CCXT.Types.LongShortRatio,
leverage_tier: CCXT.Types.LeverageTier,
market_interface: CCXT.Types.MarketInterface
}
# List types return arrays of the singular type.
# Note: :funding_rates appears in both @list_types and @dict_types because CCXT returns
# either a list or a dict depending on the exchange. The is_list guard matches first for
# lists; the is_map + @dict_types clause handles dicts.
@list_types [
:tickers,
:orders,
:positions,
:trades,
:funding_rates,
:funding_rate_history,
:transactions,
:transfers,
:ledger_entries,
:open_interests,
:liquidations,
:borrow_interests,
:conversions,
:trading_fees,
:accounts,
:funding_histories,
:long_short_ratios,
:market_leverage_tiers,
:market_interfaces
]
# Dict types return %{key => struct} maps (e.g. fetchFundingRates returns %{symbol => FundingRate})
@dict_types [
:funding_rates,
:trading_fees,
:deposit_withdraw_fees,
:options,
:isolated_borrow_rates,
:last_prices
]
# Dict-of-list types return %{key => [struct]} maps (e.g. fetchLeverageTiers returns %{symbol => [LeverageTier]})
@dict_of_list_types [:leverage_tiers]
@type response_type ::
:ticker
| :tickers
| :order
| :orders
| :position
| :positions
| :balance
| :order_book
| :trade
| :trades
| :funding_rate
| :funding_rates
| :funding_rate_history
| :transaction
| :transactions
| :transfer
| :transfers
| :deposit_address
| :ledger_entry
| :ledger_entries
| :leverage
| :trading_fee
| :trading_fees
| :deposit_withdraw_fee
| :deposit_withdraw_fees
| :margin_modification
| :open_interest
| :open_interests
| :margin_mode
| :liquidation
| :liquidations
| :borrow_interest
| :borrow_interests
| :borrow_rate
| :conversion
| :conversions
| :greeks
| :account
| :accounts
| :option
| :options
| :funding_history
| :funding_histories
| :isolated_borrow_rate
| :isolated_borrow_rates
| :last_price
| :last_prices
| :long_short_ratio
| :long_short_ratios
| :leverage_tier
| :leverage_tiers
| :market_leverage_tiers
| :market_interface
| :market_interfaces
| :ohlcv
| nil
@doc """
Coerces response data to the appropriate typed struct.
## Parameters
- `data` - The raw response data (map or list of maps)
- `type` - The response type atom (e.g., `:ticker`, `:orders`)
- `opts` - Options keyword list. If `normalize: false`, returns data unchanged.
## Examples
iex> data = %{"symbol" => "BTC/USDT", "last" => 50000.0}
iex> CCXT.ResponseCoercer.coerce(data, :ticker, [])
%CCXT.Types.Ticker{symbol: "BTC/USDT", last: 50000.0, ...}
iex> CCXT.ResponseCoercer.coerce(data, :ticker, normalize: false)
%{"symbol" => "BTC/USDT", "last" => 50000.0}
iex> CCXT.ResponseCoercer.coerce(data, nil, [])
%{"symbol" => "BTC/USDT", "last" => 50000.0}
"""
@spec coerce(term(), response_type(), keyword(), [CCXT.ResponseParser.instruction()] | nil) :: term()
def coerce(data, type, opts, parser_mapping \\ nil)
def coerce(data, nil, _opts, _parser_mapping), do: data
def coerce(data, type, opts, parser_mapping) do
if Keyword.get(opts, :normalize, true) do
coerce_typed(data, type, parser_mapping)
else
data
end
end
# Internal typed coercion after raw check
@doc false
@spec coerce_typed(term(), response_type(), [CCXT.ResponseParser.instruction()] | nil) :: term()
defp coerce_typed(data, type, parser_mapping) when type in @list_types and is_list(data) do
singular = singularize(type)
Enum.map(data, &coerce_single(&1, singular, parser_mapping))
end
# Guard: list-only types that arrive as maps (unexpected shape).
# Excludes dual-shape types (in both @list_types and @dict_types/@dict_of_list_types).
# Log a warning so the mismatch is visible, then coerce map values individually.
@list_only_types @list_types -- (@dict_types ++ @dict_of_list_types)
defp coerce_typed(data, type, parser_mapping) when type in @list_only_types and is_map(data) do
Logger.warning(
"ResponseCoercer: expected list for #{inspect(type)}, got map with #{map_size(data)} entries — coercing values"
)
singular = singularize(type)
data |> Map.values() |> Enum.map(&coerce_single(&1, singular, parser_mapping))
end
defp coerce_typed(data, type, parser_mapping) when is_map(data) and type in @dict_of_list_types do
singular = singularize(type)
Map.new(data, fn
{k, v} when is_list(v) ->
{k, Enum.map(v, &coerce_single(&1, singular, parser_mapping))}
{k, v} when is_map(v) ->
{k, coerce_single(v, singular, parser_mapping)}
{k, v} ->
Logger.warning("ResponseCoercer: unexpected value for #{inspect(type)} key #{inspect(k)}: #{inspect(v)}")
{k, v}
end)
end
defp coerce_typed(data, type, parser_mapping) when is_map(data) and type in @dict_types do
singular = singularize(type)
Map.new(data, fn
{k, v} when is_map(v) ->
{k, coerce_single(v, singular, parser_mapping)}
{k, v} ->
Logger.warning("ResponseCoercer: unexpected non-map value for #{inspect(type)} key #{inspect(k)}: #{inspect(v)}")
{k, v}
end)
end
# OHLCV: special path — returns {:ok, _} | {:error, _} tuple (unlike other types).
# Must precede the generic is_map guard to avoid shadowing on map payloads.
# Handled by execute_request/8 in Generator.Helpers.
@doc false
defp coerce_typed(data, :ohlcv, _parser_mapping) do
CCXT.OHLCV.normalize(data)
end
defp coerce_typed(data, type, parser_mapping) when is_map(data) do
coerce_single(data, type, parser_mapping)
end
# Handle unexpected data types gracefully - return unchanged
defp coerce_typed(data, _type, _parser_mapping), do: data
@doc false
# Coerces a single map to its corresponding struct.
# First injects "info" key (raw data) so from_map/1 can populate the raw field,
# then applies ResponseParser to convert exchange-specific keys to unified keys,
# then calls from_map/1 on the type module.
@spec coerce_single(map(), atom(), [CCXT.ResponseParser.instruction()] | nil) :: struct() | map()
defp coerce_single(data, type, parser_mapping) when is_map(data) do
case Map.get(@type_modules, type) do
nil ->
data
module when not is_nil(module) ->
enriched = maybe_inject_info(data)
parsed = CCXT.ResponseParser.parse_single(enriched, parser_mapping)
module.from_map(parsed)
end
end
defp coerce_single(data, _type, _parser_mapping), do: data
@doc false
# Injects "info" => data so from_map's get_camel_value(map, :raw, :info) finds original data.
# Skips if "info" or :info already exists to avoid overwriting explicit info fields.
defp maybe_inject_info(data) do
if Map.has_key?(data, "info") or Map.has_key?(data, :info) do
data
else
Map.put(data, "info", data)
end
end
# Maps plural response types to their singular form for struct/parser lookup.
@singularizations %{
tickers: :ticker,
orders: :order,
positions: :position,
trades: :trade,
funding_rates: :funding_rate,
funding_rate_history: :funding_rate_history,
transactions: :transaction,
transfers: :transfer,
ledger_entries: :ledger_entry,
open_interests: :open_interest,
liquidations: :liquidation,
borrow_interests: :borrow_interest,
conversions: :conversion,
trading_fees: :trading_fee,
deposit_withdraw_fees: :deposit_withdraw_fee,
accounts: :account,
options: :option,
funding_histories: :funding_history,
isolated_borrow_rates: :isolated_borrow_rate,
last_prices: :last_price,
long_short_ratios: :long_short_ratio,
leverage_tiers: :leverage_tier,
market_leverage_tiers: :leverage_tier,
market_interfaces: :market_interface
}
@doc false
@spec singularize(atom()) :: atom()
for {plural, singular} <- @singularizations do
def singularize(unquote(plural)), do: unquote(singular)
end
def singularize(type), do: type
@doc """
Infers the response type from an endpoint name.
Used during code generation to automatically set response types
for known endpoint patterns.
## Examples
iex> CCXT.ResponseCoercer.infer_response_type(:fetch_ticker)
:ticker
iex> CCXT.ResponseCoercer.infer_response_type(:fetch_orders)
:orders
iex> CCXT.ResponseCoercer.infer_response_type(:some_custom_endpoint)
nil
"""
# Maps endpoint names to their response types. Generated into function clauses at compile time.
@endpoint_type_map %{
# Market data
fetch_ticker: :ticker,
fetch_tickers: :tickers,
fetch_markets: :market_interfaces,
fetch_order_book: :order_book,
fetch_trades: :trades,
fetch_my_trades: :trades,
# Orders
fetch_order: :order,
fetch_orders: :orders,
fetch_open_orders: :orders,
fetch_closed_orders: :orders,
create_order: :order,
cancel_order: :order,
edit_order: :order,
# Positions & funding
fetch_position: :position,
fetch_positions: :positions,
fetch_balance: :balance,
fetch_funding_rate: :funding_rate,
fetch_funding_rates: :funding_rates,
fetch_funding_rate_history: :funding_rate_history,
# Deposits, withdrawals, transfers
fetch_deposits: :transactions,
fetch_withdrawals: :transactions,
fetch_deposit: :transaction,
fetch_withdrawal: :transaction,
fetch_deposit_address: :deposit_address,
transfer: :transfer,
fetch_transfers: :transfers,
fetch_ledger: :ledger_entries,
# Leverage & margin
fetch_leverage: :leverage,
set_leverage: :leverage,
add_margin: :margin_modification,
reduce_margin: :margin_modification,
set_margin_mode: :margin_mode,
fetch_margin_mode: :margin_mode,
# Fees
fetch_trading_fee: :trading_fee,
fetch_trading_fees: :trading_fees,
fetch_deposit_withdraw_fees: :deposit_withdraw_fees,
# Market info
fetch_open_interest: :open_interest,
fetch_open_interest_history: :open_interests,
fetch_liquidations: :liquidations,
fetch_my_liquidations: :liquidations,
fetch_borrow_interest: :borrow_interests,
fetch_cross_borrow_rate: :borrow_rate,
# Conversions & options
fetch_convert_trade: :conversion,
fetch_convert_trade_history: :conversions,
fetch_greeks: :greeks,
# Accounts
fetch_accounts: :accounts,
# Options
fetch_option: :option,
fetch_option_chain: :options,
# Funding history
fetch_funding_history: :funding_histories,
# Borrow rates
fetch_isolated_borrow_rate: :isolated_borrow_rate,
fetch_isolated_borrow_rates: :isolated_borrow_rates,
# Last prices
fetch_last_prices: :last_prices,
# Long/short ratio
fetch_long_short_ratio_history: :long_short_ratios,
# Leverage tiers
fetch_leverage_tiers: :leverage_tiers,
fetch_market_leverage_tiers: :market_leverage_tiers,
# OHLCV
fetch_ohlcv: :ohlcv
}
@spec infer_response_type(atom()) :: response_type()
for {endpoint, type} <- @endpoint_type_map do
def infer_response_type(unquote(endpoint)), do: unquote(type)
end
def infer_response_type(_), do: nil
@doc """
Returns all known type modules.
Useful for introspection and testing.
"""
@spec type_modules() :: %{atom() => module()}
def type_modules, do: @type_modules
@doc """
Returns all list types.
Useful for introspection and testing.
"""
@spec list_types() :: [atom()]
def list_types, do: @list_types
@doc """
Returns all dict types (endpoints returning `%{key => struct}` maps).
Useful for introspection and testing.
"""
@spec dict_types() :: [atom()]
def dict_types, do: @dict_types
@doc """
Returns all dict-of-list types (endpoints returning `%{key => [struct]}` maps).
Useful for introspection and testing.
"""
@spec dict_of_list_types() :: [atom()]
def dict_of_list_types, do: @dict_of_list_types
end