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
@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 """
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)
@generated_timestamp_params MapSet.new([
"end_timestamp",
"start_timestamp",
"timestamp",
"endTime",
"startTime"
])
@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 String.contains?(to_string(k), "start"), 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()
def denormalize_symbol_param(params, spec, market_type) do
case Map.get(params, :symbol) do
symbol when is_binary(symbol) and symbol != "" ->
Map.put(params, :symbol, CCXT.Symbol.denormalize(symbol, spec, market_type))
_ ->
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
{: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