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_parser.ex
Raw

lib/ccxt/response_parser.ex

defmodule CCXT.ResponseParser do
@moduledoc """
Parses raw exchange responses using compiled mapping tables.
Converts exchange-specific field names (e.g., `"askPrice"`) to unified field names
(e.g., `"ask"`) that `from_map/1` expects, applying type coercion via `CCXT.Safe`.
## How It Works
Each mapping is a list of instruction tuples:
[{:ask, :number, ["askPrice"]}, {:bid, :number, ["bidPrice"]}, ...]
For each instruction, `parse_single/2`:
1. Looks up the source key(s) in the raw exchange data
2. Applies the coercion function (via `CCXT.Safe`)
3. Writes the result using the unified key name that `from_map/1` expects
The parsed fields are merged into the original data, so unmapped fields
pass through unchanged.
## Integration
Called by `CCXT.ResponseCoercer.coerce/4` before `from_map/1`:
Raw exchange data → parse_single → Unified map → from_map/1 → Struct
"""
alias CCXT.Safe
@type coercion ::
atom()
| {:bool_enum, String.t(), String.t()}
| {:literal, any()}
| {:enum_map, atom(), %{String.t() => String.t()}}
| {:array_index, non_neg_integer(), atom() | tuple()}
| {:param, atom()}
@type instruction :: {atom(), coercion(), [String.t()]}
# Schema modules whose fields need snake_case -> camelCase key mapping
@parser_schemas [
CCXT.Types.Schema.Ticker,
CCXT.Types.Schema.Trade,
CCXT.Types.Schema.Order,
CCXT.Types.Schema.Position,
CCXT.Types.Schema.FundingRate,
CCXT.Types.Schema.Transaction,
CCXT.Types.Schema.TransferEntry,
CCXT.Types.Schema.DepositAddress,
CCXT.Types.Schema.LedgerEntry,
CCXT.Types.Schema.Leverage,
CCXT.Types.Schema.TradingFeeInterface,
CCXT.Types.Schema.DepositWithdrawFee,
CCXT.Types.Schema.MarginModification,
CCXT.Types.Schema.OpenInterest,
CCXT.Types.Schema.MarginMode,
CCXT.Types.Schema.Liquidation,
CCXT.Types.Schema.FundingRateHistory,
CCXT.Types.Schema.BorrowInterest,
CCXT.Types.Schema.CrossBorrowRate,
CCXT.Types.Schema.Conversion,
CCXT.Types.Schema.Greeks,
CCXT.Types.Schema.Account,
CCXT.Types.Schema.Option,
CCXT.Types.Schema.FundingHistory,
CCXT.Types.Schema.IsolatedBorrowRate,
CCXT.Types.Schema.LastPrice,
CCXT.Types.Schema.LongShortRatio,
CCXT.Types.Schema.LeverageTier
]
# Derived at compile time from schema @fields attributes.
# Maps field atoms to their source strings where they differ
# (e.g., :bid_volume -> "bidVolume"). Simple names like :ask -> "ask"
# are handled by the Atom.to_string fallback in to_unified_key/1.
@field_to_source (for schema <- @parser_schemas,
%{name: name, source: source} <-
:attributes |> schema.__info__() |> Keyword.get(:fields, []),
Atom.to_string(name) != source,
into: %{} do
{name, source}
end)
@doc """
Parses a single response map using an instruction list.
Applies each instruction's coercion to extract and convert values from the
raw exchange data, then merges results into the original map.
Returns data unchanged if mapping is nil or empty.
## Parameters
- `data` - Raw exchange response map
- `instructions` - List of `{field, coercion, source_keys}` tuples, or nil
- `param_map` - Optional map of endpoint function parameters (e.g., `%{symbol: "BTC/USDT"}`).
Used by `{:param, key}` coercion instructions to populate fields from caller context.
## Examples
iex> instructions = [{:ask, :number, ["askPrice"]}, {:bid, :number, ["bidPrice"]}]
iex> CCXT.ResponseParser.parse_single(%{"askPrice" => "42000.5", "bidPrice" => "41999.0"}, instructions)
%{"askPrice" => "42000.5", "bidPrice" => "41999.0", "ask" => 42000.5, "bid" => 41999.0}
"""
@spec parse_single(map(), [instruction()] | nil, map()) :: map()
def parse_single(data, instructions, param_map \\ %{})
def parse_single(data, nil, _param_map), do: data
def parse_single(data, [], _param_map), do: data
def parse_single(data, instructions, param_map) when is_map(data) and is_list(instructions) do
parsed =
for {field, coercion, source_keys} <- instructions,
value = apply_coercion(data, coercion, source_keys, param_map),
value != nil,
into: %{} do
{to_unified_key(field), value}
end
Map.merge(data, parsed)
end
def parse_single(data, _instructions, _param_map), do: data
# Applies the coercion function from CCXT.Safe based on the coercion atom.
# The param_map argument provides endpoint function parameters for {:param, key} instructions.
@doc false
@spec apply_coercion(map(), coercion(), [String.t()], map()) :: term()
defp apply_coercion(_data, {:param, key}, _keys, param_map), do: Map.get(param_map, key)
defp apply_coercion(_data, {:literal, value}, _keys, _param_map), do: value
defp apply_coercion(data, :number, keys, _param_map), do: Safe.number(data, keys)
defp apply_coercion(data, :integer, keys, _param_map), do: Safe.integer(data, keys)
defp apply_coercion(data, :string, keys, _param_map), do: Safe.string(data, keys)
defp apply_coercion(data, :string_lower, keys, _param_map), do: Safe.string_lower(data, keys)
defp apply_coercion(data, :bool, keys, _param_map), do: Safe.bool(data, keys)
defp apply_coercion(data, :timestamp, keys, _param_map), do: Safe.timestamp(data, keys)
defp apply_coercion(data, :value, keys, _param_map), do: Safe.value(data, keys)
defp apply_coercion(data, {:bool_enum, true_value, false_value}, keys, _param_map) do
case find_boolean(data, keys) do
true -> true_value
false -> false_value
_ -> nil
end
end
defp apply_coercion(data, :string_upper, keys, _param_map), do: Safe.string_upper(data, keys)
defp apply_coercion(data, {:enum_map, extraction, table}, keys, param_map) when is_map(table) do
case apply_coercion(data, extraction, keys, param_map) do
nil -> nil
value when is_binary(value) -> Map.get(table, value, value)
_ -> nil
end
end
defp apply_coercion(data, :iso8601_timestamp, keys, _param_map) do
case Safe.string(data, keys) do
nil -> nil
str -> parse_iso8601_to_ms(str)
end
end
defp apply_coercion(data, {:array_index, index, inner_coercion}, keys, param_map) do
case Safe.value(data, keys) do
list when is_list(list) ->
case Enum.at(list, index) do
nil -> nil
val -> apply_coercion(%{"_v" => val}, inner_coercion, ["_v"], param_map)
end
_ ->
nil
end
end
defp apply_coercion(_data, _coercion, _keys, _param_map), do: nil
@doc false
# Parses an ISO 8601 string to millisecond unix timestamp.
# Three-level fallback: full ISO 8601 with tz → naive datetime (assumes UTC) → date-only (midnight UTC).
@spec parse_iso8601_to_ms(String.t()) :: integer() | nil
defp parse_iso8601_to_ms(str) when is_binary(str) do
with {:error, _} <- parse_datetime_to_ms(str),
{:error, _} <- parse_naive_to_ms(str),
{:error, _} <- parse_date_to_ms(str) do
nil
else
{:ok, ms} -> ms
end
end
defp parse_datetime_to_ms(str) do
case DateTime.from_iso8601(str) do
{:ok, dt, _offset} -> {:ok, DateTime.to_unix(dt, :millisecond)}
{:error, _} = err -> err
end
end
defp parse_naive_to_ms(str) do
case NaiveDateTime.from_iso8601(str) do
{:ok, ndt} -> {:ok, ndt |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix(:millisecond)}
{:error, _} = err -> err
end
end
defp parse_date_to_ms(str) do
case Date.from_iso8601(str) do
{:ok, date} -> {:ok, date |> DateTime.new!(~T[00:00:00], "Etc/UTC") |> DateTime.to_unix(:millisecond)}
{:error, _} = err -> err
end
end
@doc false
# Finds a boolean value trying keys in order. Unlike Safe.prop with lists,
# this correctly handles `false` as a found value (not falsy/missing).
@spec find_boolean(map(), [String.t()]) :: boolean() | nil
defp find_boolean(_data, []), do: nil
defp find_boolean(data, [key | rest]) do
case Map.get(data, key) do
val when is_boolean(val) -> val
_ -> find_boolean(data, rest)
end
end
@doc false
# Converts a struct field atom to the string key that from_map/1 expects.
# Uses @field_to_source (derived from schema @fields at compile time).
# Falls back to Atom.to_string for simple names like :ask -> "ask".
@spec to_unified_key(atom()) :: String.t()
defp to_unified_key(field) do
Map.get(@field_to_source, field, Atom.to_string(field))
end
end