Current section
Files
Jump to
Current section
Files
lib/ccxt/generator/functions/endpoints.ex
defmodule CCXT.Generator.Functions.Endpoints do
@moduledoc """
Endpoint function generation for exchange modules.
Generates thin wrapper functions that delegate to `CCXT.Generator.Dispatch.call/5`
for the shared pipeline (param merging, path interpolation, HTTP execution).
Also generates stubs for unsupported, failed-extraction, and emulated methods.
"""
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
# Generates a thin wrapper that delegates to Dispatch.call/5.
# All pipeline logic (param merging, emulation, path interpolation, HTTP execution)
# lives in Dispatch at runtime, not inlined per-endpoint.
@doc false
@spec generate_endpoint(atom(), map(), Spec.t(), keyword()) :: Macro.t()
defp generate_endpoint(name, opts, spec, pipeline) do
%{auth: auth, params: params, required_params: required_params, response_type: response_type} = opts
{args, param_map} = build_args_and_params(params, auth, required_params)
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)
opts_var = {:opts, [], Elixir}
creds_var = {:credentials, [], Elixir}
# Resolve parser attribute for pipeline (nil when no pipeline — always nil in ccxt_ex)
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
# Auth endpoints inject credentials into opts for Dispatch
dispatch_opts_ast =
if auth do
quote do: Keyword.put(unquote(opts_var), :credentials, unquote(creds_var))
else
opts_var
end
quote do
@spec unquote(spec_signature) :: unquote(return_type_ast)
@doc unquote(doc)
def unquote(name)(unquote_splicing(args)) do
# credo:disable-for-next-line Credo.Check.Design.AliasUsage
CCXT.Generator.Dispatch.call(
__MODULE__,
unquote(name),
unquote(param_map),
unquote(dispatch_opts_ast),
unquote(parser_ast)
)
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 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
# =============================================================================
# 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