Current section
Files
Jump to
Current section
Files
lib/ccxt/generator/helpers.ex
defmodule CCXT.Generator.Helpers do
@moduledoc """
Helper functions for generated exchange modules.
These functions are called by code generated by `CCXT.Generator` at runtime.
"""
alias CCXT.HTTP.Client
alias CCXT.ResponseTransformer
require Logger
@doc """
Strips default_params entries that would be overridden by non-nil function args.
Default_params (string keys) can collide with function args (atom keys) when both
map to the same target key via param_mappings. This function removes such defaults
before the merge chain, so function args and opts[:params] maintain correct precedence.
## Examples
iex> CCXT.Generator.Helpers.strip_overridden_defaults(%{"instrument_name" => "BTC-DEFAULT"}, %{symbol: "BTC/USD"}, %{"symbol" => "instrument_name"})
%{}
iex> CCXT.Generator.Helpers.strip_overridden_defaults(%{"interval" => "1m"}, %{symbol: "BTC/USD"}, %{})
%{"interval" => "1m"}
"""
@spec strip_overridden_defaults(map(), map(), map()) :: map()
def strip_overridden_defaults(defaults, _args, _mappings) when map_size(defaults) == 0, do: defaults
def strip_overridden_defaults(defaults, args, mappings) do
overridden_targets =
args
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> MapSet.new(fn {k, _v} ->
string_key = Atom.to_string(k)
Map.get(mappings, string_key, string_key)
end)
Map.reject(defaults, fn {k, _v} -> MapSet.member?(overridden_targets, k) end)
end
@doc """
Applies endpoint-level parameter name mappings.
Transforms unified param names (e.g., :timeframe) to exchange-specific names
(e.g., "interval" for Bybit OHLCV).
## Examples
iex> apply_endpoint_mappings(%{timeframe: "1", symbol: "BTCUSDT"}, %{"timeframe" => "interval"})
%{"interval" => "1", "symbol" => "BTCUSDT"}
iex> apply_endpoint_mappings(%{foo: "bar"}, %{})
%{"foo" => "bar"}
"""
@spec apply_endpoint_mappings(map(), map()) :: map()
def apply_endpoint_mappings(params, mappings) do
Map.new(params, fn {key, value} ->
string_key = if is_atom(key), do: Atom.to_string(key), else: key
case Map.get(mappings, string_key) do
# Always normalize to string keys for HTTP serialization
nil -> {string_key, value}
mapped_key -> {mapped_key, value}
end
end)
end
@doc """
Looks up a path parameter value from params map with fallback.
First tries the atom key (from function args), then falls back to string key
(from default_params). Returns empty string if neither found.
## Examples
iex> get_path_param(%{symbol: "BTCUSDT"}, :symbol, "symbol")
"BTCUSDT"
iex> get_path_param(%{"order_id" => "123"}, :order_id, "order_id")
"123"
iex> get_path_param(%{}, :missing, "missing")
""
"""
@spec get_path_param(map(), atom(), String.t()) :: String.t()
def get_path_param(params, atom_key, string_key) do
case Map.fetch(params, atom_key) do
{:ok, value} -> to_string(value)
:error -> to_string(Map.get(params, string_key, ""))
end
end
@doc """
Translates the `:timeframe` value in a params map to exchange-native format.
Called by `Dispatch.call/5` for OHLCV endpoints. Looks up the `:timeframe`
value in the timeframes map and replaces it with the exchange-native value.
Passes through unchanged if no `:timeframe` key or no matching translation.
Supports per-market-type timeframes (e.g., MEXC spot: "1h" => "60m",
swap: "1h" => "Min60") — resolves the correct sub-map based on market_type.
## Examples
iex> translate_timeframe(%{timeframe: "1h", symbol: "BTCUSDT"}, %{"1h" => "60", "1d" => "D"}, :spot)
%{timeframe: "60", symbol: "BTCUSDT"}
iex> translate_timeframe(%{timeframe: "60"}, %{"1h" => "60"}, :spot)
%{timeframe: "60"}
iex> translate_timeframe(%{symbol: "BTCUSDT"}, %{"1h" => "60"}, :spot)
%{symbol: "BTCUSDT"}
"""
@spec translate_timeframe(map(), map(), atom() | nil) :: map()
def translate_timeframe(params, timeframes, _market_type) when map_size(timeframes) == 0, do: params
def translate_timeframe(params, timeframes, market_type) do
resolved = resolve_timeframes(timeframes, market_type)
case Map.fetch(params, :timeframe) do
{:ok, value} -> Map.put(params, :timeframe, Map.get(resolved, value, value))
:error -> params
end
end
@doc """
Translates a single timeframe value to exchange-native format.
Called by generated WS OHLCV subscription functions. Simple lookup with
passthrough fallback for already-native values.
Supports per-market-type timeframes — resolves based on market_type.
## Examples
iex> translate_timeframe_value("1h", %{"1h" => "60", "1d" => "D"}, :spot)
"60"
iex> translate_timeframe_value("60", %{"1h" => "60"}, :spot)
"60"
iex> translate_timeframe_value("1h", %{}, :spot)
"1h"
"""
@spec translate_timeframe_value(String.t(), map(), atom() | nil) :: String.t()
def translate_timeframe_value(value, timeframes, _market_type) when map_size(timeframes) == 0, do: value
def translate_timeframe_value(value, timeframes, market_type) do
resolved = resolve_timeframes(timeframes, market_type)
Map.get(resolved, value, value)
end
@doc false
# Resolves per-market-type timeframes to a flat map for the given market_type.
# If timeframes is already flat (values are strings), returns as-is.
# If nested (values are maps keyed by "spot", "swap", etc.), selects the sub-map.
@spec resolve_timeframes(map(), atom() | nil) :: map()
defp resolve_timeframes(timeframes, market_type) do
case Enum.at(timeframes, 0) do
{_key, sub} when is_map(sub) ->
type_key = if market_type, do: Atom.to_string(market_type), else: "spot"
Map.get(timeframes, type_key, %{})
_ ->
timeframes
end
end
@doc """
Converts OHLCV timestamp from milliseconds to the exchange's expected resolution.
Users always pass timestamps in milliseconds. For exchanges that expect seconds
(e.g., HTX, Gate, Bitstamp), this converts by dividing by 1000.
## Examples
iex> convert_ohlcv_timestamp(1704067200000, :milliseconds)
1704067200000
iex> convert_ohlcv_timestamp(1704067200000, :seconds)
1704067200
iex> convert_ohlcv_timestamp(nil, :seconds)
nil
"""
@spec convert_ohlcv_timestamp(integer() | nil, atom()) :: integer() | nil
def convert_ohlcv_timestamp(nil, _resolution), do: nil
def convert_ohlcv_timestamp(ms, :seconds) when is_integer(ms), do: div(ms, 1000)
def convert_ohlcv_timestamp(ms, _resolution) when is_integer(ms), do: ms
# Timestamp params that need conversion for OHLCV endpoints.
# Includes both atom keys (from function args) and string keys (from default_params).
@timestamp_params [:since, "since", "from", "to", "startTime", "endTime", "start", "end"]
# Time window for generated timestamp placeholders
@one_hour_ms 3_600_000
# Timestamp param names that should get real values when they have "<generated>" placeholder.
# These are params that some APIs require (e.g., Deribit requires end_timestamp for OHLCV).
# Covers all timestamp-like keys found across 56+ exchange specs.
@generated_timestamp_params MapSet.new([
# Original keys
"end_timestamp",
"start_timestamp",
"timestamp",
"endTime",
"startTime",
# End-time variants (26+ exchanges use "to", 14 use "end")
"to",
"end",
"end_at",
"endAt",
"end_ts",
# Start-time variants
"from",
"start_ts",
# Range/cursor variants
"before",
"after",
"until",
"time"
])
# Keys that resolve to one-hour-ago (start of time window).
# Everything else resolves to now (end of time window).
@start_timestamp_keys MapSet.new([
"start_timestamp",
"startTime",
"from",
"after",
"start_ts"
])
@doc """
Resolves "<generated>" placeholder values in params.
During extraction, non-deterministic values (timestamps, IDs) are replaced with
"<generated>" placeholders to make specs reproducible. At runtime:
- Timestamp params get real values (current time or time window)
- Other params with "<generated>" are filtered out
## Examples
iex> resolve_generated_placeholders(%{"end_timestamp" => "<generated>", "symbol" => "BTC"})
%{"end_timestamp" => 1704067200000, "symbol" => "BTC"} # timestamp substituted
iex> resolve_generated_placeholders(%{"nonce" => "<generated>", "symbol" => "BTC"})
%{"symbol" => "BTC"} # non-timestamp filtered out
"""
@spec resolve_generated_placeholders(map()) :: map()
def resolve_generated_placeholders(params) do
now_ms = System.system_time(:millisecond)
one_hour_ago_ms = now_ms - @one_hour_ms
params
|> Enum.map(&resolve_placeholder(&1, now_ms, one_hour_ago_ms))
|> Enum.reject(&is_nil/1)
|> Map.new()
end
@doc false
# Resolves a single param's "<generated>" placeholder.
# Returns the original pair if not a placeholder, a timestamped pair for known
# timestamp params, or nil for other placeholders (to be filtered out).
@spec resolve_placeholder({any(), any()}, integer(), integer()) :: {any(), any()} | nil
defp resolve_placeholder({_k, v} = pair, _now, _start) when v != "<generated>", do: pair
defp resolve_placeholder({k, "<generated>"}, now_ms, one_hour_ago_ms) do
if MapSet.member?(@generated_timestamp_params, k) do
timestamp = if MapSet.member?(@start_timestamp_keys, k), do: one_hour_ago_ms, else: now_ms
{k, timestamp}
end
# Returns nil for non-timestamp placeholders, filtered out by Enum.reject/2
end
@doc """
Converts all timestamp params in a map from milliseconds to the exchange's expected resolution.
Users always pass timestamps in milliseconds. For exchanges that expect seconds
(e.g., HTX, Gate, Bitstamp, Krakenfutures), this converts by dividing by 1000.
## Examples
iex> convert_ohlcv_timestamps(%{:since => 1704067200000, "to" => 1704153600000}, :seconds)
%{:since => 1704067200, "to" => 1704153600}
iex> convert_ohlcv_timestamps(%{:since => 1704067200000}, :milliseconds)
%{:since => 1704067200000}
"""
@spec convert_ohlcv_timestamps(map(), atom()) :: map()
def convert_ohlcv_timestamps(params, resolution) do
Map.new(params, fn {k, v} ->
if k in @timestamp_params and is_integer(v) do
{k, convert_ohlcv_timestamp(v, resolution)}
else
{k, v}
end
end)
end
@doc """
Denormalizes symbol in params to exchange-specific format.
Converts unified format (e.g., "BTC/USDT") to exchange format (e.g., "btcusdt" for HTX).
Returns params unchanged if no symbol present or symbol is empty.
"""
@spec denormalize_symbol_param(map(), CCXT.Spec.t(), atom() | nil) ::
map() | {:error, :no_symbol_format}
def denormalize_symbol_param(params, spec, market_type) do
case Map.get(params, :symbol) do
symbol when is_binary(symbol) and symbol != "" ->
case CCXT.Symbol.denormalize(symbol, spec, market_type) do
{:error, _} = error -> error
denormalized -> Map.put(params, :symbol, denormalized)
end
_ ->
params
end
end
@doc """
Executes HTTP request and transforms the response.
Handles the common pattern of making an HTTP request and applying response
transformation (envelope unwrapping). When a coercer module is provided,
delegates to it for type coercion after transformation. Returns raw maps
when no coercer is configured.
## Pipeline parameters
The last 4 parameters support the optional normalization pipeline:
- `response_type` - atom identifying the response type (e.g., `:ticker`)
- `parser_mapping` - parser function for this type (from module attribute)
- `user_opts` - user-provided keyword opts passed through to coercer
- `coercer` - module implementing `coerce/4`, or nil for raw output
"""
@spec execute_request(
CCXT.Spec.t(),
atom(),
String.t(),
keyword(),
term(),
term(),
term(),
keyword(),
module() | nil
) ::
{:ok, term()} | {:error, CCXT.Error.t()}
# credo:disable-for-next-line Credo.Check.Refactor.FunctionArity
def execute_request(
spec,
method,
path,
client_opts,
response_transformer,
response_type,
parser_mapping,
user_opts,
coercer \\ nil
) do
case Client.request(spec, method, path, client_opts) do
{:ok, %{body: body}} ->
transformed = ResponseTransformer.transform(body, response_transformer)
maybe_coerce(transformed, response_type, parser_mapping, user_opts, coercer)
{:error, _} = error ->
error
end
end
@doc false
# Applies coercion when a coercer module is configured, otherwise wraps raw result.
@spec maybe_coerce(term(), term(), term(), keyword(), module() | nil) ::
{:ok, term()} | {:error, CCXT.Error.t()}
defp maybe_coerce(transformed, response_type, _parser_mapping, user_opts, nil) do
if Keyword.get(user_opts, :normalize, true) do
Logger.warning(
"normalize: true but no coercer configured for response_type=#{inspect(response_type)}. " <>
"Returning raw data. Check pipeline config or pass normalize: false."
)
end
{:ok, transformed}
end
defp maybe_coerce(transformed, response_type, parser_mapping, user_opts, coercer) do
case coercer.coerce(transformed, response_type, user_opts, parser_mapping) do
{:ok, _} = ok -> ok
{:error, _} = error -> error
coerced -> {:ok, coerced}
end
end
end