Packages

Elixir client library for cryptocurrency exchanges — generated from CCXT specs via compile-time macros.

Current section

Files

Jump to
ccxt_client lib ccxt generator functions endpoints.ex
Raw

lib/ccxt/generator/functions/endpoints.ex

defmodule CCXT.Generator.Functions.Endpoints do
@moduledoc """
Endpoint function generation for exchange modules.
Generates the AST for public and private endpoint functions,
including argument handling, path interpolation, and client calls.
"""
alias CCXT.Generator.Functions.Docs
alias CCXT.Generator.Functions.Typespecs
alias CCXT.Spec
# Parameters that are optional and get default nil value
# These are commonly optional across CCXT's unified API methods:
# - limit: pagination limit (fetchOrders, fetchOHLCV, etc.)
# - since: start timestamp for historical data (fetchOrders, fetchOHLCV, etc.)
# - until: end timestamp for historical data
# - price: for market orders (createOrder)
# - code: currency code (fetchBalance when fetching specific currency)
# - trigger_price: for conditional orders
# - stop_loss_price: for orders with stop loss
# - take_profit_price: for orders with take profit
@optional_params [:limit, :since, :until, :price, :code, :trigger_price, :stop_loss_price, :take_profit_price]
# Inline priv path resolution to avoid compile-time dependency on CCXT.Priv
# (which would cause cascading recompilation of ~200 exchange modules)
# Method signatures loaded at compile time for generating extraction failure stubs
# Use stdlib JSON (Elixir 1.18+) at compile time to avoid Jason dependency ordering issues
@method_signatures_path (case :code.priv_dir(Mix.Project.config()[:app]) do
{:error, :bad_name} ->
[__DIR__, "..", "..", "..", "priv", "extractor/ccxt_method_signatures.json"]
|> Path.join()
|> Path.expand()
priv when is_list(priv) ->
Path.join(List.to_string(priv), "extractor/ccxt_method_signatures.json")
end)
@external_resource @method_signatures_path
@method_signatures (case File.read(@method_signatures_path) do
{:ok, content} ->
case JSON.decode(content) do
{:ok, %{"methods" => methods}} ->
methods
{:error, reason} ->
IO.warn(
"Failed to parse method signatures from #{@method_signatures_path}: #{inspect(reason)}"
)
%{}
end
{:error, reason} ->
IO.warn("Failed to load method signatures from #{@method_signatures_path}: #{inspect(reason)}")
%{}
end)
# Inline priv path resolution to avoid compile-time dependency on CCXT.Priv
# Emulated method list loaded at compile time for generating emulation stubs
# Use stdlib JSON (Elixir 1.18+) at compile time to avoid Jason dependency ordering issues
@emulated_methods_path (case :code.priv_dir(Mix.Project.config()[:app]) do
{:error, :bad_name} ->
[__DIR__, "..", "..", "..", "priv", "extractor/ccxt_emulated_methods.json"]
|> Path.join()
|> Path.expand()
priv when is_list(priv) ->
Path.join(List.to_string(priv), "extractor/ccxt_emulated_methods.json")
end)
@external_resource @emulated_methods_path
@emulated_methods (case File.read(@emulated_methods_path) do
{:ok, content} ->
case JSON.decode(content) do
{:ok, %{"emulated_methods" => methods}} ->
methods
{:ok, _} ->
IO.warn("Unexpected format in #{@emulated_methods_path}: missing emulated_methods key")
%{}
{:error, reason} ->
IO.warn(
"Failed to parse emulated methods from #{@emulated_methods_path}: #{inspect(reason)}"
)
%{}
end
{:error, reason} ->
IO.warn("Failed to load emulated methods from #{@emulated_methods_path}: #{inspect(reason)}")
%{}
end)
@doc """
Generates all endpoint functions from the spec.
Returns AST for all functions defined in `spec.endpoints`, plus stub functions
for methods that failed during extraction (e.g., inherited methods that don't
work for this exchange class).
"""
@spec generate_endpoints(Spec.t(), keyword()) :: Macro.t()
def generate_endpoints(spec, pipeline \\ []) do
endpoint_fns =
spec.endpoints
|> Enum.map(&generate_endpoint_function(&1, spec, pipeline))
|> List.flatten()
# Generate stubs for extraction failures
failure_stubs = generate_extraction_failure_stubs(spec)
# Generate stubs for emulated methods without HTTP endpoints
emulated_stubs = generate_emulated_method_stubs(spec)
endpoint_fns ++ failure_stubs ++ emulated_stubs
end
# Generate a function for a single endpoint
@doc false
@spec generate_endpoint_function(map(), Spec.t(), keyword()) :: Macro.t() | []
defp generate_endpoint_function(endpoint, spec, pipeline) do
name = endpoint[:name]
auth = endpoint[:auth]
params = Map.get(endpoint, :params, [])
# Get capability from spec.has to determine if this should be generated
has_capability = Map.get(spec.has, name, true)
if has_capability do
# Conditional response_type inference via pipeline coercer
coercer = pipeline[:coercer]
response_type = if coercer, do: coercer.infer_response_type(name)
endpoint_opts = %{
method: endpoint[:method],
path: endpoint[:path],
auth: auth,
params: params,
param_mappings: Map.get(endpoint, :param_mappings, %{}),
required_params: Map.get(endpoint, :required_params, []),
default_params: Map.get(endpoint, :default_params, %{}),
base_url: endpoint[:base_url],
market_type: endpoint[:market_type],
response_transformer: endpoint[:response_transformer],
response_type: response_type
}
generate_endpoint(name, endpoint_opts, spec, pipeline)
else
# Generate a stub that returns {:error, :not_supported}
generate_unsupported_endpoint(name, auth, params)
end
end
# Shared endpoint generation logic for public and private endpoints.
# Uses an options map to keep arity low while supporting many configuration options.
@doc false
@spec generate_endpoint(atom(), map(), Spec.t(), keyword()) :: Macro.t()
defp generate_endpoint(name, opts, spec, pipeline) do
%{
method: method,
path: path,
auth: auth,
params: params,
param_mappings: param_mappings,
required_params: required_params,
default_params: default_params,
base_url: base_url,
market_type: market_type,
response_transformer: response_transformer,
response_type: response_type
} = opts
{args, param_map} = build_args_and_params(params, auth, required_params)
# Convert param_mappings to compile-time value for injection
param_mappings_ast = Macro.escape(param_mappings)
# Convert default_params to compile-time value (e.g., %{"type" => "step0"} for HTX order book)
default_params_ast = Macro.escape(default_params)
response_type_ast = Macro.escape(response_type)
doc = Docs.generate_doc(name, params, auth, spec)
spec_signature = Typespecs.generate_typespec_signature(name, params, auth, required_params)
return_type_ast = Typespecs.ok_error_return_type_ast(name)
path_ast = interpolate_path(path, param_mappings)
# Reference variables from function args (same context: Elixir)
opts_var = {:opts, [], Elixir}
creds_var = {:credentials, [], Elixir}
# Build client_opts AST - add credentials and base_url for endpoints with custom API base
client_opts_ast = build_client_opts_ast(opts_var, creds_var, auth, base_url)
# Convert market_type to compile-time value for symbol format lookup
market_type_ast = Macro.escape(market_type)
# Conditional parser lookup via pipeline
coercer = pipeline[:coercer]
parsers_mod = pipeline[:parsers]
parser_ast =
if parsers_mod && response_type do
parser_attr = parsers_mod.parser_attr_for_type(coercer.singularize(response_type))
if parser_attr, do: {:@, [], [{parser_attr, [], Elixir}]}
end
quote do
@spec unquote(spec_signature) :: unquote(return_type_ast)
@doc unquote(doc)
def unquote(name)(unquote_splicing(args)) do
# Build params: defaults → function args → extra opts params
params =
unquote(default_params_ast)
|> Map.merge(unquote(param_map))
|> Map.merge(Map.new(Keyword.get(unquote(opts_var), :params, [])))
# Capture credentials (nil for public endpoints) for emulation dispatch
credentials = unquote(if auth, do: creds_var)
# Emulated method dispatch (REST scope)
case CCXT.Emulation.dispatch(@ccxt_spec, unquote(name), :rest, %{
exchange_module: __MODULE__,
params: params,
opts: unquote(opts_var),
credentials: credentials
}) do
:passthrough ->
# credo:disable-for-lines:4 Credo.Check.Design.AliasUsage
params =
params
|> CCXT.Generator.Helpers.resolve_generated_placeholders()
|> CCXT.Generator.Helpers.denormalize_symbol_param(@ccxt_spec, unquote(market_type_ast))
# Convert OHLCV timestamps if needed (users always pass milliseconds, library converts)
params = unquote(build_ohlcv_timestamp_conversion_ast(name))
# Interpolate path, apply mappings, prepend prefix
base_path = unquote(path_ast)
# credo:disable-for-next-line Credo.Check.Design.AliasUsage
params = CCXT.Generator.Helpers.apply_endpoint_mappings(params, unquote(param_mappings_ast))
path = unquote(build_prefixed_path_ast(spec.path_prefix, path))
# Execute HTTP request with response transformation and optional coercion
client_opts = unquote(client_opts_ast)
# credo:disable-for-next-line Credo.Check.Design.AliasUsage
CCXT.Generator.Helpers.execute_request(
@ccxt_spec,
unquote(method),
path,
client_opts,
unquote(response_transformer),
unquote(response_type_ast),
unquote(parser_ast),
unquote(opts_var),
unquote(Macro.escape(coercer))
)
{:ok, result} ->
{:ok, result}
{:error, _} = error ->
error
end
end
end
end
# Generate a stub for unsupported endpoints
@doc false
@spec generate_unsupported_endpoint(atom(), boolean(), [atom()]) :: Macro.t()
defp generate_unsupported_endpoint(name, auth, params) do
{args, _param_map} = build_args_and_params(params, auth, [])
spec_signature = Typespecs.generate_typespec_signature(name, params, auth, [])
return_type_ast = Typespecs.ok_error_return_type_ast(name)
quote do
@spec unquote(spec_signature) :: unquote(return_type_ast)
@doc false
def unquote(name)(unquote_splicing(args)) do
{:error,
%CCXT.Error{
type: :exchange_error,
message: "#{unquote(name)} not supported by this exchange"
}}
end
end
end
# Builds client_opts AST for HTTP client calls.
# Adds credentials for authenticated endpoints and base_url for custom API sections.
@doc false
@spec build_client_opts_ast(Macro.t(), Macro.t(), boolean(), String.t() | nil) :: Macro.t()
defp build_client_opts_ast(opts_var, creds_var, auth, base_url) do
# Build base client_opts (params + credentials if auth)
base_opts_ast =
if auth do
quote do
client_opts = Keyword.delete(unquote(opts_var), :params)
client_opts = Keyword.put(client_opts, :params, params)
Keyword.put(client_opts, :credentials, unquote(creds_var))
end
else
quote do
client_opts = Keyword.delete(unquote(opts_var), :params)
Keyword.put(client_opts, :params, params)
end
end
# Add base_url if endpoint uses a custom API section
if base_url do
quote do
client_opts = unquote(base_opts_ast)
Keyword.put(client_opts, :base_url, unquote(base_url))
end
else
base_opts_ast
end
end
# Builds function arguments and parameter map AST for endpoint functions.
#
# Returns a tuple of {args_ast, param_map_ast} where:
# - args_ast: List of function argument AST nodes (credentials if auth, params, opts)
# - param_map_ast: AST that builds a map from param names to their runtime values,
# filtering out nil values for optional params
#
# Uses consistent Elixir context for all variables so they can be referenced in the function body.
# `required_params` overrides @optional_params - if a param is in both, it's required.
@doc false
@spec build_args_and_params([atom()], boolean(), [atom()]) :: {list(), Macro.t()}
defp build_args_and_params(params, auth, required_params) do
# Start with credentials if auth required (using Elixir context)
base_args = if auth, do: [{:credentials, [], Elixir}], else: []
# Add params as individual arguments (with defaults for optional ones)
# A param gets default nil if it's in @optional_params AND NOT in required_params
param_args =
Enum.map(params, fn param ->
is_optional = param in @optional_params and param not in required_params
if is_optional do
{:\\, [], [{param, [], Elixir}, nil]}
else
{param, [], Elixir}
end
end)
# Always add opts at the end
opts_arg = {:\\, [], [{:opts, [], Elixir}, []]}
args = base_args ++ param_args ++ [opts_arg]
# Build AST that creates the parameter map at runtime
# Generate: [{:symbol, symbol}, {:limit, limit}, ...] |> Enum.reject(...) |> Map.new()
param_pairs =
Enum.map(params, fn param ->
# Generate a {key, var} tuple AST
{:{}, [], [param, {param, [], Elixir}]}
end)
param_map_ast =
quote do
unquote(param_pairs)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
end
{args, param_map_ast}
end
# Generates AST for path interpolation at runtime.
#
# Handles two placeholder styles:
# - Phoenix-style: `/ticker/:symbol` - colon prefix
# - CCXT/OpenAPI-style: `/ticker/{pair}/` - curly braces
#
# Path parameters come from trusted spec files loaded at compile time, not user input.
# The atoms already exist because they're defined in endpoint[:params], making
# String.to_existing_atom safe - it will only match atoms created during spec loading.
#
# Generates AST for path placeholder interpolation using reversed param_mappings.
# For {param} style, reverses param_mappings to map request names to unified params.
# Example: param_mappings %{"id" => "order-id"} becomes %{"order-id" => :id}
#
# sobelow_skip ["DOS.StringToAtom"]
@doc false
@spec interpolate_path(String.t(), map()) :: String.t() | Macro.t()
defp interpolate_path(path, param_mappings) do
# For paths with :param or {param} placeholders, generate interpolation code
# :param style: /ticker/:symbol (Phoenix-style)
# {param} style: /ticker/{pair}/ (CCXT/OpenAPI-style)
cond do
String.contains?(path, ":") ->
quote do
Regex.replace(~r/:(\w+)/, unquote(path), fn _match, key ->
# Safe: path placeholders come from trusted spec files loaded at compile time
# sobelow_skip ["DOS.StringToAtom"]
key_atom = String.to_atom(key)
to_string(Map.get(params, key_atom, ""))
end)
end
String.contains?(path, "{") ->
interpolate_curly_brace_path(path, param_mappings)
true ->
path
end
end
# Generates AST for {param} style path interpolation (CCXT/OpenAPI-style).
# Reverses param_mappings at compile time: %{"id" => "order-id"} → %{"order-id" => :id}
# Generates different code when aliases map is empty to avoid Elixir 1.19+ type warnings.
#
# sobelow_skip ["DOS.StringToAtom"]
@doc false
@spec interpolate_curly_brace_path(String.t(), map()) :: Macro.t()
defp interpolate_curly_brace_path(path, param_mappings) do
reversed =
for {unified, request} <- param_mappings, into: %{} do
{request, String.to_atom(unified)}
end
# Generate different code depending on whether aliases exist
# This avoids Elixir 1.19+ type warnings about Map.get on empty maps
if map_size(reversed) == 0 do
interpolate_curly_brace_path_simple(path)
else
interpolate_curly_brace_path_with_aliases(path, reversed)
end
end
@doc false
defp interpolate_curly_brace_path_simple(path) do
quote do
Regex.replace(~r/\{(\w+)\}/, unquote(path), fn _match, key ->
# Safe: path placeholders come from trusted spec files loaded at compile time
# sobelow_skip ["DOS.StringToAtom"]
param_key = String.to_atom(key)
# credo:disable-for-next-line Credo.Check.Design.AliasUsage
CCXT.Generator.Helpers.get_path_param(params, param_key, key)
end)
end
end
@doc false
defp interpolate_curly_brace_path_with_aliases(path, reversed) do
escaped_reversed = Macro.escape(reversed)
quote do
aliases = unquote(escaped_reversed)
Regex.replace(~r/\{(\w+)\}/, unquote(path), fn _match, key ->
# First check reversed param_mappings, then try direct atom conversion
# Safe: path placeholders come from trusted spec files loaded at compile time
# sobelow_skip ["DOS.StringToAtom"]
param_key = Map.get(aliases, key, String.to_atom(key))
# credo:disable-for-next-line Credo.Check.Design.AliasUsage
CCXT.Generator.Helpers.get_path_param(params, param_key, key)
end)
end
end
# Generates AST for combining path prefix with base path, avoiding double slashes.
#
# Handles cases like:
# - prefix="/api/v3/", base_path="/brokerage" -> "/api/v3/brokerage"
# - prefix="/api/v3", base_path="/brokerage" -> "/api/v3/brokerage"
# - prefix="", base_path="/brokerage" -> "/brokerage"
@doc false
@spec build_prefixed_path_ast(String.t(), String.t()) :: Macro.t()
defp build_prefixed_path_ast(prefix, path) do
cond do
prefix == "" ->
quote do
base_path
end
String.starts_with?(path, prefix) ->
# Path already includes prefix (semantic endpoints)
quote do
base_path
end
Regex.match?(~r{^/v\d+/}, path) and Regex.match?(~r{v\d+}, prefix) ->
# Version override: path starts with /vN/ and prefix contains a different version.
# Replace the version in the prefix to avoid double-versioning.
# Example: prefix="/api/v1/", path="/v2/symbols" → "/api/v2/symbols"
[_, path_ver] = Regex.run(~r{^/(v\d+)}, path)
new_prefix = Regex.replace(~r{v\d+}, prefix, path_ver, global: false)
version_prefix = "/" <> path_ver
quote do
stripped = String.replace_prefix(base_path, unquote(version_prefix), "")
unquote(new_prefix) <> String.trim_leading(stripped, "/")
end
String.ends_with?(prefix, "/") and String.starts_with?(path, "/") ->
# Avoid double slashes: if prefix ends with "/" and path starts with "/",
# strip the leading "/" from path
quote do
unquote(prefix) <> String.slice(base_path, 1..-1//1)
end
true ->
quote do
unquote(prefix) <> base_path
end
end
end
# Generates AST for OHLCV timestamp conversion (ms → seconds for some exchanges).
# Users always pass milliseconds; the library converts internally based on spec.
# Only applies to fetch_ohlcv endpoint; other endpoints pass params unchanged.
# Converts all timestamp params (:since, "to", "from", etc.) - not just :since.
@doc false
@spec build_ohlcv_timestamp_conversion_ast(atom()) :: Macro.t()
defp build_ohlcv_timestamp_conversion_ast(:fetch_ohlcv) do
quote do
# credo:disable-for-next-line Credo.Check.Design.AliasUsage
CCXT.Generator.Helpers.convert_ohlcv_timestamps(params, @ccxt_spec.ohlcv_timestamp_resolution)
end
end
defp build_ohlcv_timestamp_conversion_ast(_name) do
# For non-OHLCV endpoints, no conversion needed - just return params unchanged
quote do
params
end
end
# =============================================================================
# Extraction Failure Stub Generation
#
# When CCXT reports has.X: true but the method fails during extraction
# (e.g., "fetchPosition() supports option markets only"), we generate a
# stub function that returns the helpful error message instead of leaving
# the user with a confusing "function undefined" compile error.
# =============================================================================
@doc false
@spec generate_extraction_failure_stubs(Spec.t()) :: [Macro.t()]
defp generate_extraction_failure_stubs(spec) do
failures = get_in(spec.endpoint_extraction_stats, ["failures"]) || []
# Get names of endpoints that were successfully extracted
existing_endpoints = MapSet.new(spec.endpoints, & &1[:name])
failures
|> Enum.map(&parse_failure/1)
|> Enum.reject(fn
nil -> true
{name, _msg} -> MapSet.member?(existing_endpoints, name)
end)
|> Enum.map(fn {name, message} ->
generate_failure_stub(name, message, spec)
end)
end
# Parse failure entry into {atom_name, error_message}
@doc false
@spec parse_failure(map()) :: {atom(), String.t()} | nil
defp parse_failure(%{"method" => method, "error" => error}) when is_binary(method) do
# Convert camelCase to snake_case atom
name = method |> Macro.underscore() |> String.to_atom()
{name, error}
end
defp parse_failure(_), do: nil
# Generate a stub function for a failed extraction
@doc false
@spec generate_failure_stub(atom(), String.t(), Spec.t()) :: Macro.t()
defp generate_failure_stub(name, message, spec) do
{params, auth} = method_metadata(name)
{args, _param_map} = build_args_and_params(params, auth, [])
spec_signature = Typespecs.generate_typespec_signature(name, params, auth, [])
return_type_ast = Typespecs.ok_error_return_type_ast(name)
exchange_id = spec.exchange_id || String.to_atom(spec.id)
quote do
@doc """
**Not supported on this exchange.**
#{unquote(message)}
This method exists in CCXT's capability list but cannot be called on this
exchange class due to market type restrictions or inheritance limitations.
"""
@spec unquote(spec_signature) :: unquote(return_type_ast)
def unquote(name)(unquote_splicing(args)) do
# credo:disable-for-next-line Credo.Check.Design.AliasUsage
{:error,
CCXT.Error.not_supported(
message: unquote(message),
exchange: unquote(exchange_id)
)}
end
end
end
@doc false
# Generates stub functions for emulated methods that have no HTTP endpoint mapping.
@spec generate_emulated_method_stubs(Spec.t()) :: [Macro.t()]
defp generate_emulated_method_stubs(spec) do
entries = Map.get(@emulated_methods, spec.id, [])
excluded = build_excluded_names(spec)
entries
|> Enum.map(&emulated_entry_to_name_pair/1)
|> Enum.reject(fn
nil -> true
{name, _entry} -> MapSet.member?(excluded, name)
end)
|> Map.new()
|> Enum.map(fn {name, entry} -> generate_emulated_stub(name, entry, spec) end)
end
@doc false
# Builds a set of method names that should not get emulated stubs.
@spec build_excluded_names(Spec.t()) :: MapSet.t()
defp build_excluded_names(spec) do
endpoint_names = MapSet.new(spec.endpoints, & &1[:name])
failure_names = MapSet.new(failure_stub_names(spec))
MapSet.union(endpoint_names, failure_names)
end
@doc false
# Converts an emulated entry to {name, entry} tuple or nil.
@spec emulated_entry_to_name_pair(map()) :: {atom(), map()} | nil
defp emulated_entry_to_name_pair(entry) do
case emulated_entry_to_name(entry) do
nil -> nil
name -> {name, entry}
end
end
@doc false
# Extracts emulated method names from failure entries to avoid duplicate stubs.
@spec failure_stub_names(Spec.t()) :: [atom()]
defp failure_stub_names(spec) do
failures = get_in(spec.endpoint_extraction_stats, ["failures"]) || []
failures
|> Enum.map(&parse_failure/1)
|> Enum.reject(&is_nil/1)
|> Enum.map(fn {name, _msg} -> name end)
end
@doc false
# Converts an emulated method entry to its snake_case atom name.
@spec emulated_entry_to_name(map()) :: atom() | nil
defp emulated_entry_to_name(%{"name" => name}) when is_binary(name) do
name
|> Macro.underscore()
|> String.to_atom()
end
defp emulated_entry_to_name(_), do: nil
@doc false
# Generates a stub function for an emulated method.
@spec generate_emulated_stub(atom(), map(), Spec.t()) :: Macro.t()
defp generate_emulated_stub(name, entry, spec) do
{params, auth} = method_metadata(name)
{args, param_map} = build_args_and_params(params, auth, [])
spec_signature = Typespecs.generate_typespec_signature(name, params, auth, [])
return_type_ast = Typespecs.ok_error_return_type_ast(name)
exchange_id = spec.exchange_id || String.to_atom(spec.id)
reason_suffix = emulated_reason_suffix(entry)
opts_var = {:opts, [], Elixir}
creds_var = {:credentials, [], Elixir}
quote do
@doc """
**Emulated method (no HTTP call).**
CCXT marks this method as emulated for this exchange#{unquote(reason_suffix)}.
"""
@spec unquote(spec_signature) :: unquote(return_type_ast)
def unquote(name)(unquote_splicing(args)) do
params = unquote(param_map)
extra_params = Keyword.get(unquote(opts_var), :params, [])
params = Map.merge(params, Map.new(extra_params))
credentials = unquote(if auth, do: creds_var)
case CCXT.Emulation.dispatch(@ccxt_spec, unquote(name), :rest, %{
exchange_module: __MODULE__,
params: params,
opts: unquote(opts_var),
credentials: credentials
}) do
:passthrough ->
{:error,
CCXT.Error.not_supported(
message: "Emulated method not registered: #{unquote(Atom.to_string(name))}",
exchange: unquote(exchange_id)
)}
{:ok, result} ->
{:ok, result}
{:error, _} = error ->
error
end
end
end
end
@doc false
# Builds the reason suffix for emulated method docs.
@spec emulated_reason_suffix(map()) :: String.t()
defp emulated_reason_suffix(entry) do
case Map.get(entry, "reasons", []) do
[] -> ""
reasons -> " (#{Enum.join(reasons, ", ")})"
end
end
# Convert snake_case to camelCase for CCXT method lookup
@doc false
@spec camelize(String.t()) :: String.t()
defp camelize(string) do
string
|> String.split("_")
|> Enum.with_index()
|> Enum.map_join(fn
{word, 0} -> word
{word, _} -> String.capitalize(word)
end)
end
# Extracts method parameters and auth requirement from cached CCXT signatures.
@doc false
@spec method_metadata(atom()) :: {[atom()], boolean()}
defp method_metadata(name) do
camel_name = name |> Atom.to_string() |> camelize()
params = @method_signatures |> Map.get(camel_name, []) |> Enum.map(&String.to_atom/1)
auth = infer_auth_from_name(name)
{params, auth}
end
# Infer authentication requirement from method name
@doc false
@spec infer_auth_from_name(atom()) :: boolean()
defp infer_auth_from_name(name) do
name_str = Atom.to_string(name)
# Public methods typically start with fetch_ and are market data
public_prefixes = [
"fetch_ticker",
"fetch_order_book",
"fetch_trades",
"fetch_ohlcv",
"fetch_markets",
"fetch_currencies",
"fetch_time",
"fetch_status"
]
not Enum.any?(public_prefixes, &String.starts_with?(name_str, &1))
end
end