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.ResponseCoercer 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/coerces the response. Handles the common pattern of making an HTTP request, applying response transformation, and coercing to typed structs. Optionally applies parser mappings to convert exchange-specific field names to unified names before coercion. """ @spec execute_request( CCXT.Spec.t(), atom(), String.t(), keyword(), term(), term(), [{atom(), atom(), [String.t()]}] | nil, keyword() ) :: {:ok, term()} | {:error, CCXT.Error.t()} def execute_request(spec, method, path, client_opts, response_transformer, response_type, parser_mapping, user_opts) do case Client.request(spec, method, path, client_opts) do {:ok, %{body: body}} -> transformed = ResponseTransformer.transform(body, response_transformer) coerced = ResponseCoercer.coerce(transformed, response_type, user_opts, parser_mapping) {:ok, coerced} {:error, _} = error -> error end end end