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