Current section
Files
Jump to
Current section
Files
lib/ccxt/exchange.ex
defmodule CCXT.Exchange do
@moduledoc """
Exchange configuration struct and constructor.
Holds everything needed to make API calls for a specific exchange instance:
resolved base URLs, rate limits, credentials, capabilities, and lean spec data.
This is a pure data struct — no process. Rate limiting, HTTP execution, and
signing are handled by other modules that receive `%Exchange{}` as input.
## Examples
# Public-only (no credentials)
{:ok, exchange} = CCXT.Exchange.new("bybit")
exchange.base_urls
#=> %{"public" => "https://api.bybit.com", ...}
# With credentials
{:ok, exchange} = CCXT.Exchange.new("bybit", api_key: "abc", secret: "xyz")
exchange.credentials
#=> %CCXT.Credentials{api_key: "abc", secret: "xyz", ...}
# Sandbox mode (uses testnet URLs)
{:ok, exchange} = CCXT.Exchange.new("bybit", sandbox: true)
"""
alias CCXT.Signing.Classifier
# TODO(Task 60): Replace with spec-driven directional aliases once upstream
# Phase 16 Task 97 lands — only the population path migrates; the struct
# field shape stays.
#
# Per-exchange outbound currency aliases applied during unified → exchange-id
# conversion in `CCXT.Symbol.to_exchange_id/2`. Populated only for exchanges
# known to accept the alias on input (Kraken accepts `XBT` for `BTC`).
#
# Inbound conversion (`from_exchange_id`) keeps using `common_currencies`
# directly — every spec carries `XBT → BTC` as a defensive base default and
# naive reversal would leak Kraken's quirk to all exchanges (Task 91).
@outbound_aliases %{
"kraken" => %{"BTC" => "XBT"}
}
@enforce_keys [:id, :name]
defstruct [
:id,
:name,
:credentials,
:hostname,
sandbox: false,
rate_limit_ms: 0,
base_urls: %{},
has: %{},
required_credentials: %{},
signing_pattern: nil,
signing_config: %{},
common_currencies: %{},
outbound_aliases: %{},
symbol_patterns: %{},
options: %{},
error_codes: %{},
broad_error_patterns: %{},
error_body_checks: [],
error_code_fields: [],
http_exceptions: %{},
request_defaults: %{},
module: nil,
tier: nil,
spec: %{}
]
@type error_body_role :: :error_code | :status_sentinel
@type sentinel_operator :: String.t()
@type sentinel_value :: %{operator: sentinel_operator(), value: String.t()}
@type error_body_check :: %{
field: String.t() | nil,
field2: String.t() | nil,
roles: [error_body_role()],
sentinel_values: [sentinel_value()]
}
@type t :: %__MODULE__{
id: String.t(),
name: String.t(),
credentials: CCXT.Credentials.t() | nil,
sandbox: boolean(),
rate_limit_ms: number(),
hostname: String.t() | nil,
base_urls: map(),
has: %{String.t() => boolean() | String.t()},
required_credentials: %{String.t() => boolean()},
signing_pattern: CCXT.Signing.pattern() | nil,
signing_config: map(),
common_currencies: %{String.t() => String.t()},
outbound_aliases: %{String.t() => String.t()},
symbol_patterns: %{atom() => CCXT.Symbol.pattern_config()},
options: map(),
error_codes: %{String.t() => CCXT.Error.error_type()},
broad_error_patterns: %{String.t() => CCXT.Error.error_type()},
error_body_checks: [error_body_check()],
error_code_fields: [String.t()],
http_exceptions: %{String.t() => CCXT.Error.error_type()},
request_defaults: %{String.t() => %{String.t() => term()}},
module: module() | nil,
tier: String.t() | nil,
spec: map()
}
# ---------------------------------------------------------------------------
# Generator Macro
#
# `use CCXT.Exchange, spec: "bybit"` generates an exchange module at compile
# time from a JSON spec. Stores lean spec, pre-computed endpoint configs, and
# introspection functions. Wires up Descripex for self-describing API functions.
# ---------------------------------------------------------------------------
@doc "Macro entry point: `use CCXT.Exchange, spec: \"bybit\"` generates an exchange module."
defmacro __using__(opts) do
spec_id =
Keyword.get(opts, :spec) ||
raise ArgumentError, "use CCXT.Exchange requires spec: \"exchange_id\""
quote do
require CCXT.Exchange
CCXT.Exchange.__generate__(unquote(spec_id))
end
end
@doc "Generates introspection functions and endpoint wrappers from a spec ID."
defmacro __generate__(spec_id) do
data = CCXT.Exchange.prepare_generate_data(spec_id)
CCXT.Exchange.build_module_body(data)
end
@doc """
Builds the quoted module body from prepared generate data.
Called by both `__generate__/1` (macro path) and `CCXT.Exchanges`
(`Module.create` path) to ensure a single source of truth.
## Options
* `:moduledoc` — optional `@moduledoc` string to inject. The macro path
leaves this to the caller; `CCXT.Exchanges` provides one per exchange.
"""
@spec build_module_body(map(), keyword()) :: Macro.t()
def build_module_body(data, opts \\ []) do
%{
exchange_id: exchange_id,
spec_file: spec_file,
parent_resource: parent_resource,
escaped_lean: escaped_lean,
escaped_endpoints: escaped_endpoints,
escaped_unified: escaped_unified,
endpoint_functions: endpoint_functions
} = data
namespace = "/#{exchange_id}"
moduledoc_ast = build_moduledoc_ast(opts)
introspection = build_introspection_ast(data)
quote do
use Descripex, namespace: unquote(namespace)
unquote_splicing(moduledoc_ast)
@external_resource unquote(spec_file)
unquote_splicing(parent_resource)
@ccxt_spec unquote(escaped_lean)
@ccxt_endpoint_configs unquote(escaped_endpoints)
@ccxt_unified_mapping unquote(escaped_unified)
unquote_splicing(introspection)
unquote_splicing(endpoint_functions)
end
end
# Builds optional @moduledoc AST from opts.
defp build_moduledoc_ast(opts) do
case Keyword.get(opts, :moduledoc) do
nil -> []
doc -> [quote(do: @moduledoc(unquote(doc)))]
end
end
# Builds all introspection function ASTs (__id__, __name__, __spec__, etc.).
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp build_introspection_ast(data) do
%{
exchange_id: exchange_id,
exchange_name: exchange_name,
exchange_tier: exchange_tier,
signing_pattern: signing_pattern,
escaped_features: escaped_features,
escaped_signing_config: escaped_signing_config
} = data
[
quote do
@doc "Returns the exchange ID string."
@spec __id__() :: String.t()
def __id__, do: unquote(exchange_id)
@doc "Returns the exchange display name."
@spec __name__() :: String.t()
def __name__, do: unquote(exchange_name)
@doc ~s{Returns the exchange tier ("tier1" | "tier2" | "dex" | "tier3" | "unclassified" | nil).}
@spec __tier__() :: String.t() | nil
def __tier__, do: unquote(exchange_tier)
@doc "Returns the lean spec (runtime describe minus API tree)."
@spec __spec__() :: map()
def __spec__, do: @ccxt_spec
@doc "Returns all pre-computed endpoint configs."
@spec __endpoints__() :: [map()]
def __endpoints__, do: @ccxt_endpoint_configs
@doc "Returns the exchange capability map."
@spec __features__() :: map()
def __features__, do: unquote(escaped_features)
@doc "Returns the signing pattern and config for this exchange."
@spec __signing__() :: %{pattern: atom(), config: map()}
def __signing__ do
%{pattern: unquote(signing_pattern), config: unquote(escaped_signing_config)}
end
@doc "Returns the full unified method → endpoint configs mapping."
@spec __unified_endpoints__() :: %{atom() => [map()]}
def __unified_endpoints__, do: @ccxt_unified_mapping
@doc "Returns endpoint configs for a specific unified method, or `[]` if unknown."
@spec __unified_endpoint__(atom()) :: [map()]
def __unified_endpoint__(method) when is_atom(method) do
Map.get(@ccxt_unified_mapping, method, [])
end
end
]
end
@doc "Prepares all compile-time data for the generator macro."
@spec prepare_generate_data(String.t()) :: map()
def prepare_generate_data(spec_id) do
spec = CCXT.Spec.load!(spec_id)
describe = spec["runtime"]["describe"]
exchange_id = spec["exchange"]["id"]
hostname = describe["hostname"] || ""
url_prefixes = compute_url_prefixes(spec, hostname)
auth_sections = get_in(spec, ["structure", "authenticated_sections"]) || []
endpoint_configs =
CCXT.Exchange.build_endpoint_configs(describe["api"] || %{}, url_prefixes, auth_sections)
features = describe["has"] || %{}
lean = Map.delete(describe, "api")
{signing_pattern, signing_config, parent_resource} =
CCXT.Exchange.classify_and_track_parent(spec, exchange_id)
unified_mapping =
CCXT.Exchange.build_unified_method_mapping(spec, endpoint_configs)
%{
exchange_id: exchange_id,
exchange_name: spec["exchange"]["name"],
exchange_tier: spec["exchange"]["tier"],
spec_file: CCXT.Spec.spec_path(spec_id),
signing_pattern: signing_pattern,
parent_resource: parent_resource,
escaped_lean: Macro.escape(lean),
escaped_endpoints: Macro.escape(endpoint_configs),
escaped_features: Macro.escape(features),
escaped_signing_config: Macro.escape(signing_config),
escaped_unified: Macro.escape(unified_mapping),
endpoint_functions: CCXT.Exchange.build_endpoint_functions(endpoint_configs)
}
end
@doc "Classifies signing pattern and builds `@external_resource` AST for parent specs."
@spec classify_and_track_parent(map(), String.t()) ::
{atom(), map(), [Macro.t()]}
def classify_and_track_parent(spec, exchange_id) do
{signing_pattern, signing_config} = Classifier.classify(spec)
parent_resource =
case Classifier.parent_for(exchange_id) do
nil -> []
parent_id -> [quote(do: @external_resource(unquote(CCXT.Spec.spec_path(parent_id))))]
end
{signing_pattern, signing_config, parent_resource}
end
@doc "Builds unified method mapping from spec data and pre-computed endpoint configs."
@spec build_unified_method_mapping(map(), [map()]) :: %{atom() => [map()]}
def build_unified_method_mapping(spec, endpoint_configs) do
# Canonical JS→atom lookup from method_defs ensures atoms like :fetch_uta_ohlcv
# match exactly, avoiding Macro.underscore mangling (fetchUTAOHLCV → :fetch_utaohlcv).
js_to_atom = Map.new(CCXT.Unified.method_defs(), fn {atom, js, _, _} -> {js, atom} end)
spec
|> get_in(["structure", "unified_endpoints"])
|> CCXT.UnifiedMethod.build_unified_mapping(endpoint_configs, js_to_atom)
end
@doc """
Builds quoted endpoint wrapper functions from a list of endpoint configs.
Called at compile time by `__generate__/1`. Each generated function embeds
its endpoint config as a literal and delegates to `CCXT.Dispatch.call/4`.
## Example
For a config `%{name: :public_get_v5_market_tickers, ...}`, generates:
def public_get_v5_market_tickers(exchange, params \\\\ %{}, opts \\\\ [])
def public_get_v5_market_tickers(%CCXT.Exchange{} = exchange, params, opts) do
CCXT.Dispatch.call(exchange, %{...}, params, opts)
end
"""
@spec build_endpoint_functions([map()]) :: [Macro.t()]
def build_endpoint_functions(endpoint_configs) do
Enum.map(endpoint_configs, fn config ->
fn_name = config.name
escaped_config = Macro.escape(config)
# credo:disable-for-next-line ExSlop.Check.Readability.DocFalseOnPublicFunction
quote do
@doc false
def unquote(fn_name)(exchange, params \\ %{}, opts \\ [])
def unquote(fn_name)(%CCXT.Exchange{} = exchange, params, opts) do
CCXT.Dispatch.call(exchange, unquote(escaped_config), params, opts)
end
end
end)
end
# NOTE: HTTP OPTIONS intentionally excluded — no CCXT exchange uses it as an
# HTTP method, and Gate/GateIO use "options" as a section name for options
# trading derivatives. Including it here would cause those sections to be
# treated as HTTP method leaves instead of traversed as API tree branches.
@http_methods ~w(get post put delete patch head)
@default_endpoint_weight 1
@doc """
Pre-computes a flat list of endpoint configs from the nested API tree.
Called at compile time by `__generate__/1`. Recursively traverses the spec's
API tree until it finds HTTP method keys (`get`, `post`, etc.), then extracts
endpoint configs from the level below.
Handles three spec patterns:
- **Standard**: `%{visibility => %{method => %{path => weight}}}`
- **Deep nesting**: `%{api_type => %{version => %{visibility => %{method => ...}}}}`
- **Array endpoints**: `%{... => %{method => [list_of_paths]}}`
All intermediate keys above the HTTP method become the `:sections` list.
## Examples
CCXT.Exchange.build_endpoint_configs(%{
"public" => %{"get" => %{"v5/market/tickers" => 5}},
"private" => %{"post" => %{"v5/order/create" => 2.5}}
})
#=> [
#=> %{name: :public_get_v5_market_tickers, method: :get,
#=> path: "v5/market/tickers", sections: ["public"], weight: 5},
#=> %{name: :private_post_v5_order_create, method: :post,
#=> path: "v5/order/create", sections: ["private"], weight: 2.5}
#=> ]
"""
@spec build_endpoint_configs(map(), map(), [String.t()]) :: [map()]
def build_endpoint_configs(api_tree, url_prefixes \\ %{}, authenticated_sections \\ [])
def build_endpoint_configs(api_tree, url_prefixes, authenticated_sections) when is_map(api_tree) do
auth_list = authenticated_sections || []
api_tree
|> traverse_api_tree([], url_prefixes, auth_list)
|> List.flatten()
end
# Recursively traverses the API tree. When a key is an HTTP method,
# extracts endpoints from its value. Otherwise, recurses deeper.
defp traverse_api_tree(tree, sections, url_prefixes, auth_set) when is_map(tree) do
for {key, value} <- tree do
if key in @http_methods do
extract_endpoints(key, value, sections, url_prefixes, auth_set)
else
traverse_api_tree(value, sections ++ [key], url_prefixes, auth_set)
end
end
end
defp traverse_api_tree(_non_map, _sections, _url_prefixes, _auth_set), do: []
# Map-style endpoints: %{path => weight}
defp extract_endpoints(method, endpoints, sections, url_prefixes, auth_set) when is_map(endpoints) do
prefix = Map.get(url_prefixes, Enum.join(sections, "."), "/")
authenticated = section_authenticated?(sections, auth_set)
for {path, weight} when is_binary(path) <- endpoints do
%{
name: build_function_name(sections, method, path),
method: String.to_existing_atom(method),
path: path,
sections: sections,
weight: normalize_weight(weight),
url_prefix: prefix,
authenticated: authenticated
}
end
end
# Array-style endpoints: [list_of_path_strings] — no per-path weight
defp extract_endpoints(method, endpoints, sections, url_prefixes, auth_set) when is_list(endpoints) do
prefix = Map.get(url_prefixes, Enum.join(sections, "."), "/")
authenticated = section_authenticated?(sections, auth_set)
for path when is_binary(path) <- endpoints do
%{
name: build_function_name(sections, method, path),
method: String.to_existing_atom(method),
path: path,
sections: sections,
weight: @default_endpoint_weight,
url_prefix: prefix,
authenticated: authenticated
}
end
end
defp extract_endpoints(_method, _other, _sections, _url_prefixes, _auth_set), do: []
# Authenticated if the top-level section name appears in the spec's
# structure.authenticated_sections list (schema 1.7.1+).
defp section_authenticated?([top | _], auth_list), do: top in auth_list
defp section_authenticated?([], _auth_list), do: false
# Extracts a numeric weight from various spec formats.
# Complex objects (e.g., Binance's {byLimit: [...], cost: 1}) → extract cost.
defp normalize_weight(weight) when is_number(weight), do: weight
defp normalize_weight(%{"cost" => cost}) when is_number(cost), do: cost
defp normalize_weight(_), do: @default_endpoint_weight
# Derives a function name atom from sections, HTTP method, and path.
# e.g., (["private"], "post", "v5/order/create") => :private_post_v5_order_create
# e.g., (["spot", "v1", "private"], "get", "account/balance") => :spot_v1_private_get_account_balance
# Called at compile time — atom creation is safe (bounded by spec).
defp build_function_name(sections, http_method, path) do
sanitized_path =
path
|> String.replace(~r/[\/\-\.\{\}]/, "_")
|> String.trim_leading("_")
|> String.trim_trailing("_")
|> String.downcase()
prefix = Enum.join(sections, "_")
String.to_atom("#{prefix}_#{http_method}_#{sanitized_path}")
end
@allowed_opts [:api_key, :secret, :password, :uid, :sandbox, :credentials, :hostname, :options]
@doc """
Creates an exchange configuration from an exchange ID and options.
Loads the spec, resolves base URLs (with hostname interpolation and
sandbox/testnet switching), and optionally builds credentials.
## Options
* `:api_key` - API key string (builds credentials automatically)
* `:secret` - API secret string (builds credentials automatically)
* `:password` - API password (OKX, KuCoin)
* `:uid` - User ID
* `:credentials` - Pre-built `%CCXT.Credentials{}` (overrides key/secret opts)
* `:sandbox` - Use testnet URLs (default: `false`)
* `:hostname` - Override the default hostname
* `:options` - Exchange-specific options map
## Examples
{:ok, exchange} = CCXT.Exchange.new("bybit")
{:ok, exchange} = CCXT.Exchange.new("okx", api_key: "k", secret: "s", password: "p")
{:error, :missing_secret} = CCXT.Exchange.new("bybit", api_key: "k")
"""
@spec new(String.t() | atom(), keyword()) ::
{:ok, t()}
| {:error, term()}
def new(exchange_id, opts \\ []) when (is_binary(exchange_id) or is_atom(exchange_id)) and is_list(opts) do
exchange_id = to_string(exchange_id)
with :ok <- validate_opts(opts),
{:ok, spec} <- load_spec(exchange_id),
{:ok, credentials} <- build_credentials(opts) do
describe = spec["runtime"]["describe"]
sandbox = resolve_sandbox(credentials, opts)
hostname = Keyword.get(opts, :hostname) || describe["hostname"]
urls = describe["urls"] || %{}
testnet_urls = spec["runtime"]["testnet_urls"]
with {:ok, base_urls, sandbox_options} <-
resolve_base_urls(sandbox, testnet_urls, urls, hostname) do
{signing_pattern, signing_config} = Classifier.classify(spec)
error_body_checks = build_error_body_checks(spec)
options = Map.merge(Keyword.get(opts, :options, %{}), sandbox_options)
exchange = %__MODULE__{
id: spec["exchange"]["id"],
name: spec["exchange"]["name"],
credentials: credentials,
sandbox: sandbox,
rate_limit_ms: describe["rateLimit"] || 0,
hostname: hostname,
base_urls: base_urls,
has: describe["has"] || %{},
required_credentials: describe["requiredCredentials"] || %{},
signing_pattern: signing_pattern,
signing_config: signing_config,
common_currencies: describe["commonCurrencies"] || %{},
outbound_aliases: Map.get(@outbound_aliases, exchange_id, %{}),
symbol_patterns: build_symbol_patterns(spec),
options: options,
error_codes: build_error_codes(describe),
broad_error_patterns: build_broad_error_patterns(describe),
error_body_checks: error_body_checks,
error_code_fields: build_error_code_fields(error_body_checks),
http_exceptions: build_http_exceptions(describe),
request_defaults: build_request_defaults(spec),
module: CCXT.Registry.module_for(exchange_id),
tier: spec["exchange"]["tier"],
spec: lean_spec(describe)
}
{:ok, exchange}
end
end
end
@doc """
Creates an exchange configuration, raising on error.
## Examples
exchange = CCXT.Exchange.new!("bybit")
exchange = CCXT.Exchange.new!("bybit", api_key: "abc", secret: "xyz")
"""
@spec new!(String.t() | atom(), keyword()) :: t()
def new!(exchange_id, opts \\ []) do
case new(exchange_id, opts) do
{:ok, exchange} -> exchange
{:error, reason} -> raise ArgumentError, format_error(reason)
end
end
@doc """
Checks if the exchange supports a given capability.
Returns `true` for capabilities marked `true` or `"emulated"` in the spec.
Returns `false` for `false`, `"__undefined"`, or missing capabilities.
Capability names use camelCase strings matching the CCXT spec
(e.g., `"fetchTicker"`, `"createOrder"`).
## Examples
CCXT.Exchange.has?(exchange, "fetchTicker")
#=> true
CCXT.Exchange.has?(exchange, "fetchFundingRateHistory")
#=> false
"""
@spec has?(t(), String.t()) :: boolean()
def has?(%__MODULE__{has: has}, capability) when is_binary(capability) do
has[capability] == true or has[capability] == "emulated"
end
@doc """
Returns the exchange's tier string, or `nil` if the spec omits it.
Values mirror `priv/priority_tiers.json` from ccxt_extract: `"tier1"`,
`"tier2"`, `"dex"`, `"tier3"`, `"unclassified"`. Specs emitted before
ccxt_extract schema 1.8.0 (2026-04-14) have no tier field and return `nil`.
## Examples
CCXT.Exchange.tier(CCXT.Exchange.new!("bybit"))
#=> "tier1"
"""
@spec tier(t()) :: String.t() | nil
def tier(%__MODULE__{tier: tier}), do: tier
# Loads and validates the spec exists
defp load_spec(exchange_id) do
{:ok, CCXT.Spec.load!(exchange_id)}
rescue
e in [File.Error] ->
if e.reason == :enoent do
{:error, {:unknown_exchange, exchange_id}}
else
{:error, {:spec_load_failed, e.reason}}
end
e in [ArgumentError] ->
{:error, {:invalid_exchange_id, Exception.message(e)}}
end
# Validates option keys are recognized
defp validate_opts(opts) do
case Enum.find(Keyword.keys(opts), &(&1 not in @allowed_opts)) do
nil -> :ok
key -> {:error, {:unknown_option, key}}
end
end
# Builds credentials from opts, or uses pre-built credentials
defp build_credentials(opts) do
cond do
creds = Keyword.get(opts, :credentials) ->
if is_struct(creds, CCXT.Credentials) do
{:ok, creds}
else
{:error, {:invalid_credentials, "expected %CCXT.Credentials{}, got: #{inspect(creds)}"}}
end
Keyword.has_key?(opts, :api_key) || Keyword.has_key?(opts, :secret) ->
cred_opts =
opts
|> Keyword.take([:api_key, :secret, :password, :uid, :sandbox])
|> Keyword.reject(fn {_k, v} -> is_nil(v) end)
CCXT.Credentials.new(cred_opts)
true ->
{:ok, nil}
end
end
# Sandbox from explicit opt, falling back to credentials.
#
# `exchange.sandbox` is authoritative — it drives URL resolution and is the
# only flag callers should read. `credentials.sandbox` is a construction-time
# hint (used only here as a fallback) and is intentionally left as-is when
# an explicit `:sandbox` opt overrides it. The two fields may therefore read
# differently after construction; trust `exchange.sandbox`.
defp resolve_sandbox(credentials, opts) do
cond do
Keyword.has_key?(opts, :sandbox) -> Keyword.get(opts, :sandbox)
credentials != nil -> credentials.sandbox
true -> false
end
end
# Resolves the base URL map. Production path interpolates `{hostname}` against
# the spec's `urls.api`. Sandbox path reads `runtime.testnet_urls` (schema 2.4.0):
#
# * `pattern: "separate_host"` — `urls` is a section map already
# `{hostname}`-resolved upstream; bypass interpolation.
# * `pattern: "sandbox_flag"` — no separate URL; keep prod URLs and let the
# `sandbox_flag_field` propagate into `exchange.options`.
# * `pattern: "none"` — no testnet exists; refuse to construct a sandbox
# exchange instead of silently falling through to production URLs.
#
# `sandbox_flag_field` is independent of `pattern`: okx/gate/hyperliquid/binance
# carry both a separate host AND a runtime flag (typically `"sandboxMode"`),
# which gets merged into `exchange.options` so signing or downstream code can
# observe it.
defp resolve_base_urls(false, _testnet_urls, urls, hostname) do
base = resolve_urls(urls["api"] || %{}, hostname, urls["hostnames"])
{:ok, base, %{}}
end
defp resolve_base_urls(true, %{"pattern" => "none"}, _urls, _hostname) do
{:error, :no_testnet_data}
end
defp resolve_base_urls(true, %{"urls" => urls_map} = testnet_urls, _urls, _hostname) when is_map(urls_map) do
sandbox_options = sandbox_flag_options(testnet_urls["sandbox_flag_field"])
{:ok, urls_map, sandbox_options}
end
defp resolve_base_urls(true, %{"pattern" => "sandbox_flag"} = testnet_urls, urls, hostname) do
base = resolve_urls(urls["api"] || %{}, hostname, urls["hostnames"])
sandbox_options = sandbox_flag_options(testnet_urls["sandbox_flag_field"])
{:ok, base, sandbox_options}
end
defp resolve_base_urls(true, _missing_or_malformed, _urls, _hostname) do
{:error, :no_testnet_data}
end
defp sandbox_flag_options(nil), do: %{}
defp sandbox_flag_options(field) when is_binary(field), do: %{field => true}
# Recursively interpolates {hostname} in all string values at any depth.
# Preserves full nested structure — dispatch (Phase 2) handles URL lookup.
#
# Top-level section keys (e.g., "contract", "spot") may be overridden by a
# matching string entry in `urls["hostnames"]`. Used by htx/huobi which split
# the API across hosts (contract → api.hbdm.vn, spot → api.huobi.pro) while
# `urls.api` uniformly uses the "{hostname}" placeholder. Nested-map values
# in `hostnames` (e.g., htx's status page by market type) are ignored — those
# sections need a market-type aware lookup that isn't modelled yet.
# TODO(Task 80): market-type-aware hostname routing for nested `urls.hostnames`
# entries (htx/huobi `status` page). Deferred until a consumer surfaces need.
defp resolve_urls(url_set, default_hostname, hostnames) when is_map(url_set) do
Map.new(url_set, fn {key, value} ->
section_hostname = section_hostname(hostnames, key, default_hostname)
{key, resolve_urls_value(value, section_hostname)}
end)
end
defp resolve_urls(_url_set, _hostname, _hostnames), do: %{}
defp resolve_urls_value(value, hostname) when is_binary(value), do: interpolate_hostname(value, hostname)
defp resolve_urls_value(value, hostname) when is_map(value), do: resolve_urls(value, hostname, nil)
defp resolve_urls_value(value, _hostname), do: value
defp section_hostname(hostnames, key, default) when is_map(hostnames) do
case Map.get(hostnames, key) do
h when is_binary(h) -> h
_ -> default
end
end
defp section_hostname(_hostnames, _key, default), do: default
# Replaces {hostname} placeholder in URL strings.
# If hostname is nil, returns the URL unchanged (already absolute).
defp interpolate_hostname(url, nil), do: url
defp interpolate_hostname(url, hostname), do: String.replace(url, "{hostname}", hostname)
# ---------------------------------------------------------------------------
# URL Prefix Computation
#
# Many exchanges have invisible URL prefixes (e.g., /api/v5/, /spot/) not
# encoded in the API tree paths. The url_prefix field in specs (schema 1.2.0)
# provides pre-computed full URLs from ccxt_extract's runtime probing.
# We extract the path prefix relative to the section's base URL.
#
# Design: config over inference — ccxt_extract probes sign() at runtime and
# provides the answer. ccxt_client just consumes it. No heuristic derivation.
# ---------------------------------------------------------------------------
@doc false
@spec compute_url_prefixes(map(), String.t()) :: %{String.t() => String.t()}
def compute_url_prefixes(spec, hostname) do
url_templates = get_in(spec, ["runtime", "url_templates"]) || %{}
urls_api = get_in(spec, ["runtime", "describe", "urls", "api"]) || %{}
hostnames = get_in(spec, ["runtime", "describe", "urls", "hostnames"])
resolved_api = resolve_urls(urls_api, hostname, hostnames)
# First pass: read url_prefix from spec, extract path relative to base URL
direct =
for {section_key, template} <- url_templates,
url_prefix_full = template["url_prefix"],
is_binary(url_prefix_full),
into: %{} do
base_url = section_base_url(section_key, resolved_api)
{section_key, path_from_url_prefix(url_prefix_full, base_url)}
end
# Second pass: inherit prefix only for private sections without url_prefix.
# Non-private sections (e.g., "history", "broker") that lack url_prefix get
# the default "/" — they have their own base URLs and must not borrow prefixes.
for {section_key, _template} <- url_templates, into: direct do
{section_key, resolve_section_prefix(section_key, direct)}
end
end
# Extracts the path prefix by removing the base URL from the full url_prefix.
# When no base URL is found (e.g., OKX uses "rest" key), falls back to URI path.
defp path_from_url_prefix(url_prefix_full, base_url) when is_binary(base_url) do
trimmed = String.trim_trailing(base_url, "/")
if String.starts_with?(url_prefix_full, trimmed) do
url_prefix_full
|> String.slice(String.length(trimmed)..-1//1)
|> normalize_prefix()
else
normalize_prefix(URI.parse(url_prefix_full).path || "/")
end
end
defp path_from_url_prefix(url_prefix_full, _nil_base) do
normalize_prefix(URI.parse(url_prefix_full).path || "/")
end
# Resolves the base URL for a section key by navigating the urls.api map.
# "public" → urls.api["public"], "public.spot" → urls.api["public"]["spot"]
defp section_base_url(section_key, resolved_api) do
section_key
|> String.split(".")
|> navigate_to_url(resolved_api)
end
defp navigate_to_url([key | rest], map) when is_map(map) do
case Map.get(map, key) do
value when is_binary(value) -> value
value when is_map(value) and rest != [] -> navigate_to_url(rest, value)
_ -> nil
end
end
defp navigate_to_url(_, _), do: nil
# Ensures prefix starts and ends with "/"
defp normalize_prefix(""), do: "/"
defp normalize_prefix(prefix) do
prefix = if String.starts_with?(prefix, "/"), do: prefix, else: "/" <> prefix
if String.ends_with?(prefix, "/"), do: prefix, else: prefix <> "/"
end
# Resolves prefix for a section: returns existing direct prefix, inherits for
# private sections, or defaults to "/" for non-private sections without resolved_url.
defp resolve_section_prefix(section_key, direct_prefixes) do
case Map.get(direct_prefixes, section_key) do
nil when is_binary(section_key) -> maybe_inherit_prefix(section_key, direct_prefixes)
nil -> "/"
existing -> existing
end
end
# Private sections inherit from their public counterpart.
# Non-private sections (e.g., "history", "broker") get default "/" —
# they have their own base URLs and must not borrow prefixes.
defp maybe_inherit_prefix(section_key, direct_prefixes) do
if String.contains?(String.downcase(section_key), "private") do
section_key
|> build_private_candidates()
|> Enum.find_value("/", &Map.get(direct_prefixes, &1))
else
"/"
end
end
# Builds ordered candidate list for private→public prefix inheritance.
# Rule 1: Direct sibling replacement (contract.private→contract.public, fapiPrivate→fapiPublic)
# Rule 2: Stripped suffix (utaPrivate→uta, exchangePrivate→exchange)
# No bare "public" fallback for namespaced sections — plain "private" naturally
# gets "public" via Rule 1 (String.replace("private", "private", "public") = "public").
defp build_private_candidates(section_key) do
sibling = [
String.replace(section_key, "private", "public"),
String.replace(section_key, "Private", "Public")
]
stripped = [
String.replace(section_key, "Private", ""),
String.replace(section_key, "private", "")
]
(sibling ++ stripped)
|> Enum.uniq()
|> Enum.reject(&(&1 == section_key or &1 == ""))
end
# Builds symbol_patterns from spec's runtime.symbol_patterns.
# Converts string keys to atom keys and classifies each market type into a pattern config.
@market_type_keys %{"spot" => :spot, "swap" => :swap, "future" => :future, "option" => :option}
defp build_symbol_patterns(spec) do
case get_in(spec, ["runtime", "symbol_patterns"]) do
nil ->
%{}
patterns when is_map(patterns) ->
for {key, entry} <- patterns,
market_type = Map.get(@market_type_keys, key),
market_type != nil,
entry != nil,
config = CCXT.Symbol.classify_pattern(market_type, entry),
config != nil,
into: %{} do
{market_type, config}
end
end
end
# Pre-processes spec exact exceptions into %{error_code => error_type} map.
# Exact entries are keyed by error codes (e.g., "10001" => :insufficient_funds).
defp build_error_codes(describe) do
exact = get_in(describe, ["exceptions", "exact"]) || %{}
Map.new(exact, fn {code, class} -> {code, CCXT.Error.from_spec_class(class)} end)
end
# Pre-processes spec broad exceptions into %{message_substring => error_type} map.
# Broad entries are keyed by error message substrings for runtime substring matching
# (e.g., "Insufficient balance!" => :insufficient_funds).
defp build_broad_error_patterns(describe) do
broad = get_in(describe, ["exceptions", "broad"]) || %{}
Map.new(broad, fn {pattern, class} -> {pattern, CCXT.Error.from_spec_class(class)} end)
end
# Hardcoded fallback when spec's handleErrors analysis has no usable top-level
# exact-code lookups. Covers the most common CCXT conventions across exchanges.
@default_error_code_fields ~w(code ret_code retCode error_code)
# Builds request_defaults map from spec's structure.request_defaults (schema 2.0.1+,
# upstream ccxt_extract Task 73c). Filters to kind: "literal" entries only —
# "unresolved" entries are left for upstream to resolve per the Honesty Rule.
# Result shape: %{"fetchTime" => %{"type" => "exchangeStatus"}, ...}
defp build_request_defaults(spec) do
spec
|> get_in(["structure", "request_defaults"])
|> do_build_request_defaults()
end
defp do_build_request_defaults(rd) when is_map(rd) do
rd
|> Enum.flat_map(&method_literal_pair/1)
|> Map.new()
end
defp do_build_request_defaults(_), do: %{}
defp method_literal_pair({method_name, entries}) do
case extract_literal_defaults(entries) do
literals when map_size(literals) > 0 -> [{method_name, literals}]
_ -> []
end
end
defp extract_literal_defaults(entries) when is_map(entries) do
entries
|> Enum.flat_map(fn
{key, %{"kind" => "literal", "value" => value}} -> [{key, value}]
_ -> []
end)
|> Map.new()
end
defp extract_literal_defaults(_), do: %{}
# Extracts top-level response checks from handleErrors analysis.
# We only keep entries that are safe for the current runtime to evaluate:
# - top-level `response` object (`object_path == nil`)
# - at least one string field name (`field` or `field2`)
# - relevant to body-level errors (`error_code` or `status_sentinel`)
# - not also tagged `error_message` (avoids free-form message fields like Binance msg)
defp build_error_body_checks(spec) do
spec
|> get_in(["structure", "handle_errors", "error_code_fields"])
|> Kernel.||([])
|> Enum.filter(&usable_error_body_check?/1)
|> Enum.map(&normalize_error_body_check/1)
end
# Builds the legacy exact-code probe order from the richer body checks.
# Falls back to the historical hardcoded list when the spec has no usable
# top-level exact lookup fields.
defp build_error_code_fields(error_body_checks) do
fields =
error_body_checks
|> Enum.filter(&(:error_code in &1.roles))
|> Enum.flat_map(fn entry -> [entry.field, entry.field2] end)
|> Enum.filter(&string_field?/1)
|> Enum.uniq()
if fields == [], do: @default_error_code_fields, else: fields
end
defp usable_error_body_check?(entry) do
roles = entry["roles"] || []
Enum.any?(roles, &(&1 in ["error_code", "status_sentinel"])) and
"error_message" not in roles and
entry["object"] == "response" and
is_nil(entry["object_path"]) and
Enum.any?([entry["field"], entry["field2"]], &string_field?/1)
end
defp normalize_error_body_check(entry) do
%{
field: entry["field"],
field2: entry["field2"],
roles: normalize_error_body_roles(entry["roles"] || []),
sentinel_values: normalize_sentinel_values(entry["sentinel_values"])
}
end
defp normalize_error_body_roles(roles) do
roles
|> Enum.map(fn
"error_code" -> :error_code
"status_sentinel" -> :status_sentinel
_ -> nil
end)
|> Enum.reject(&is_nil/1)
end
defp normalize_sentinel_values(nil), do: []
defp normalize_sentinel_values(values) when is_list(values) do
values
|> Enum.filter(&valid_sentinel_value?/1)
|> Enum.map(fn %{"operator" => operator, "value" => value} ->
%{operator: operator, value: value}
end)
end
defp normalize_sentinel_values(_), do: []
defp valid_sentinel_value?(%{"operator" => operator, "value" => value})
when operator in ["===", "!=="] and is_binary(value), do: true
defp valid_sentinel_value?(_), do: false
defp string_field?(field) when is_binary(field) and field != "", do: true
defp string_field?(_), do: false
# Pre-processes HTTP status code exception mappings.
defp build_http_exceptions(describe) do
Map.new(describe["httpExceptions"] || %{}, fn {status, class} -> {status, CCXT.Error.from_spec_class(class)} end)
end
# TODO: lean_spec only strips "api" — may need to strip more keys as spec evolves.
# Full spec access is via generated modules (Phase 2).
defp lean_spec(describe) do
Map.delete(describe, "api")
end
# Formats error tuples into human-readable messages
defp format_error({:unknown_exchange, id}), do: "unknown exchange: #{inspect(id)}"
defp format_error({:invalid_exchange_id, msg}), do: msg
defp format_error({:spec_load_failed, reason}), do: "failed to load spec: #{inspect(reason)}"
defp format_error({:unknown_option, key}), do: "unknown option: #{inspect(key)}"
defp format_error({:invalid_credentials, msg}), do: msg
defp format_error(:missing_api_key), do: "api_key is required when secret is provided"
defp format_error(:missing_secret), do: "secret is required when api_key is provided"
defp format_error({:invalid_type, key}), do: "#{key} must be a string"
defp format_error(:no_testnet_data) do
~s|no testnet data in spec (runtime.testnet_urls.pattern = "none"). Cannot construct sandbox exchange.|
end
defp format_error(reason), do: inspect(reason)
end