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 "" 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 "" placeholder values in params. During extraction, non-deterministic values (timestamps, IDs) are replaced with "" placeholders to make specs reproducible. At runtime: - Timestamp params get real values (current time or time window) - Other params with "" are filtered out ## Examples iex> resolve_generated_placeholders(%{"end_timestamp" => "", "symbol" => "BTC"}) %{"end_timestamp" => 1704067200000, "symbol" => "BTC"} # timestamp substituted iex> resolve_generated_placeholders(%{"nonce" => "", "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 "" 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 != "", do: pair defp resolve_placeholder({k, ""}, 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