Current section
Files
Jump to
Current section
Files
lib/ccxt/spec.ex
# This struct is a comprehensive mapping of CCXT's exchange data - the field count
# is dictated by the external API, not our design choice.
# credo:disable-for-this-file Credo.Check.Warning.StructFieldAmount
defmodule CCXT.Spec do
@moduledoc """
Exchange specification struct.
This is the contract between the CCXT extractor and the generator macro.
Spec files are stored in `priv/specs/*.exs` and loaded at compile time
to generate exchange modules.
## Fields
- `id` - Exchange identifier (e.g., "binance", "bybit")
- `name` - Human-readable name (e.g., "Binance", "Bybit")
- `countries` - List of country codes where exchange operates
- `version` - API version string
- `classification` - Classification (:certified_pro, :pro, or :supported)
- `urls` - Map of URL types to URLs
- `rate_limits` - Rate limiting configuration
- `signing` - Signing pattern configuration
- `has` - Capabilities map (which methods are supported)
- `timeframes` - OHLCV timeframe mappings
- `endpoints` - List of endpoint definitions
- `error_codes` - Map of error codes to error types
- `error_code_details` - Map of error codes to type + description
## Spec Authoring
For writing custom exchange specs (e.g., for ccxt_client), see
`docs/spec-authoring-guide.md`. Key points:
- Current spec format version: 1
- Required fields: `:id`, `:name`, `:urls`
- Use `CCXT.Spec.current_spec_format_version/0` to query the version
## Example
%CCXT.Spec{
id: "bybit",
name: "Bybit",
classification: :certified_pro,
urls: %{
api: "https://api.bybit.com",
sandbox: "https://api-testnet.bybit.com",
www: "https://www.bybit.com",
doc: "https://bybit-exchange.github.io/docs/"
},
signing: %{
pattern: :hmac_sha256_headers,
timestamp_key: "X-BAPI-TIMESTAMP",
signature_key: "X-BAPI-SIGN",
api_key_header: "X-BAPI-API-KEY"
},
has: %{
fetch_ticker: true,
fetch_balance: true,
create_order: true,
cancel_order: true
}
}
"""
@current_spec_format_version 1
@type url_value :: String.t() | map()
@type url_map :: %{
required(:api) => url_value(),
optional(:sandbox) => url_value() | nil,
optional(:www) => url_value() | nil,
optional(:doc) => url_value() | [url_value()] | nil,
optional(:other) => map() | nil
}
@type rate_limit :: %{
optional(:requests) => non_neg_integer(),
optional(:period) => non_neg_integer(),
optional(:rolling_window_size) => non_neg_integer(),
optional(:interval_ms) => non_neg_integer(),
optional(atom()) => term()
}
# Signing config is flexible to accommodate exchange-specific fields.
# Only :pattern is required. Other common fields include:
# - api_key_header, signature_header, timestamp_header (header names)
# - signature_encoding (:hex, :base64)
# - recv_window, recv_window_header (Binance/Bybit style)
# - has_passphrase (OKX/KuCoin style)
@type signing_config :: %{
required(:pattern) => atom(),
optional(atom()) => term()
}
# Semantic endpoints have required fields plus optional extras (e.g., :approximate)
@type endpoint :: %{
required(:name) => atom(),
required(:method) => atom(),
required(:path) => String.t(),
required(:auth) => boolean(),
required(:params) => [term()],
optional(atom()) => term()
}
# Raw endpoints extracted from CCXT (different structure than semantic endpoints)
@type raw_endpoint :: %{
path: String.t(),
method: atom(),
auth: boolean(),
cost: number()
}
@type raw_endpoints :: %{
public: [raw_endpoint()],
private: [raw_endpoint()]
}
@typedoc "Capability values from CCXT describe().has"
@type capability_value :: boolean() | String.t() | atom() | nil
@typedoc "Capabilities map (unified method support flags)"
@type capabilities :: %{atom() => capability_value()}
# Per-market-type format (spot, swap, option may have different separators/symbols)
# e.g., Binance spot: "" separator = "BTCUSDT"
# Binance options: "-" separator = "BTC-250103-100000-C"
@type market_type_format :: %{
required(:separator) => String.t(),
required(:case) => :upper | :lower | :mixed,
required(:sample) => String.t()
}
# Symbol formats per market type (Task 76: market-type-aware extraction)
# Includes precision data from Task 68
# Note: 'swap' = perpetual futures, 'future' = expiring futures
@type symbol_formats :: %{
optional(:spot) => map(),
optional(:swap) => map(),
optional(:future) => map(),
optional(:option) => map(),
# Task 68: Precision handling (from primary market)
optional(:precision_mode) => non_neg_integer(),
optional(:sample_precision) => map(),
optional(:sample_limits) => map(),
optional(atom()) => term()
}
# Symbol pattern detection result (R5: detected from symbol_formats samples)
# Used for static to_exchange_id/2 conversion without loadMarkets()
@type pattern_config :: %{
pattern: atom() | nil,
separator: String.t(),
case: :upper | :lower | :mixed,
date_format: atom() | nil,
suffix: String.t() | nil,
component_order: [atom() | String.t()] | nil
}
@type symbol_patterns :: %{
optional(:spot) => pattern_config(),
optional(:swap) => pattern_config(),
optional(:future) => pattern_config(),
optional(:option) => pattern_config()
}
# Legacy symbol format (deprecated, use symbol_formats instead)
# Kept for backward compatibility with existing specs
@type symbol_format :: %{
required(:separator) => String.t(),
required(:case) => :upper | :lower | :mixed,
optional(:precision_mode) => non_neg_integer(),
optional(:sample_market) => String.t(),
optional(:sample_precision) => map(),
optional(:sample_limits) => map()
}
# Order mappings describe how exchange formats order types and sides (Task 66)
# e.g., Binance: :uppercase = "limit" → "LIMIT", "buy" → "BUY"
# Bybit: :capitalize = "limit" → "Limit", "buy" → "Buy"
# OKX: :lowercase = "limit" → "limit", "buy" → "buy"
@type order_mappings :: %{
optional(:side_format) => :uppercase | :lowercase | :capitalize | nil,
optional(:side_key) => String.t() | nil,
optional(:type_format) => :uppercase | :lowercase | :capitalize | nil,
optional(:type_key) => String.t() | nil
}
# WebSocket configuration types (Phase 4)
# See docs/ws-generator-architecture.md for full documentation
@typedoc "Flexible WS URL - string or nested map of any depth (Bybit: 3+ levels)"
@type ws_url_value :: String.t() | map()
@typedoc "WebSocket URL configuration (legacy, for derived specs after W4)"
@type ws_urls :: %{
public: %{atom() => String.t()} | String.t(),
private: String.t()
}
@typedoc """
WebSocket authentication configuration.
## Patterns (W11)
| Pattern | Exchanges | Description |
|---------|-----------|-------------|
| `:direct_hmac_expiry` | Bybit, Bitmex | GET/realtime + expires, op: "auth" |
| `:iso_passphrase` | OKX, Bitget | ISO timestamp + passphrase |
| `:jsonrpc_linebreak` | Deribit | JSON-RPC with linebreak payload |
| `:sha384_nonce` | Bitfinex | AUTH + nonce with SHA384 |
| `:sha512_newline` | Gate | Newline-separated with SHA512 |
| `:listen_key` | Binance | REST pre-auth for listen key |
| `:rest_token` | Kraken | REST pre-auth for token |
| `:inline_subscribe` | Coinbase | Auth in subscribe messages |
"""
@type ws_auth_config :: %{
optional(:pattern) => atom(),
optional(:algorithm) => atom() | String.t() | nil,
optional(:encoding) => atom() | String.t() | nil,
optional(:payload_format) => String.t(),
optional(:message_format) => atom() | String.t(),
optional(:op_field) => String.t(),
optional(:op_value) => String.t(),
optional(:timestamp_unit) => atom() | String.t() | nil,
optional(:expires_offset_ms) => non_neg_integer(),
optional(:requires_passphrase) => boolean(),
optional(:pre_auth) => map(),
optional(atom()) => term()
}
@typedoc "WebSocket heartbeat configuration"
@type ws_heartbeat_config :: %{
optional(:type) => atom(),
optional(:interval) => non_neg_integer() | nil,
optional(:message) => map() | nil
}
@typedoc "WebSocket message pattern detected from ws_raw"
@type ws_message_pattern :: %{
required(:format) => atom(),
optional(:kind_field) => String.t(),
optional(:kind_value) => String.t(),
optional(:id_field) => String.t(),
optional(:args_field) => String.t(),
optional(:keys) => [String.t()],
optional(:element_keys) => [String.t()],
optional(:sources) => [atom()]
}
@typedoc "WebSocket message pattern list"
@type ws_message_patterns :: [ws_message_pattern()]
@typedoc "WebSocket subscription pattern"
@type ws_subscription_pattern :: atom()
@typedoc "WebSocket configuration (derived after W4 - requires subscription_pattern)"
@type ws_config :: %{
required(:urls) => ws_url_value(),
required(:subscription_pattern) => ws_subscription_pattern(),
required(:subscription_config) => map(),
optional(:sandbox_urls) => ws_url_value(),
optional(:auth) => ws_auth_config(),
optional(:heartbeat) => ws_heartbeat_config(),
optional(:message_patterns) => ws_message_patterns(),
optional(:channels) => %{atom() => String.t()},
optional(:has) => %{atom() => capability_value()},
optional(:channel_templates) => map(),
optional(:watch_methods) => [String.t() | atom()],
optional(:hostname) => String.t(),
optional(:test_urls) => ws_url_value(),
optional(:demo_urls) => ws_url_value()
}
@typedoc """
Raw WebSocket configuration from CCXT Pro describe().
Phase 1 (W1): Captures raw data only. No filtering, no pattern detection.
Subscription patterns and auth are derived in later phases (W4, W11).
## URL Structure Patterns (varies by exchange)
- **Flat string**: OKX, Deribit → `"wss://ws.okx.com:8443/ws/v5"`
- **Nested map**: Bybit → `%{public: %{spot: "wss://...", linear: "wss://..."}, private: %{...}}`
## Fields
- `urls` - Production WS URLs (string or nested map)
- `test_urls` - Testnet WS URLs
- `demo_urls` - Demo trading WS URLs
- `streaming` - Keep-alive interval and ping config
- `options` - WS-specific options from describe().options.ws
- `has` - watch* capabilities (including false/nil to document unsupported)
- `hostname` - Default hostname for URL interpolation
"""
@type ws_raw_config :: %{
optional(:urls) => ws_url_value(),
optional(:test_urls) => ws_url_value(),
optional(:demo_urls) => ws_url_value(),
optional(:streaming) => %{optional(:keep_alive) => non_neg_integer()},
optional(:options) => map(),
optional(:has) => %{atom() => capability_value()},
optional(:hostname) => String.t(),
# Derived fields present in some extracted specs (mixed raw+derived)
optional(:auth) => ws_auth_config(),
optional(:subscription_pattern) => ws_subscription_pattern(),
optional(:subscription_config) => map(),
optional(:message_patterns) => ws_message_patterns(),
optional(:channel_templates) => map(),
optional(:watch_methods) => [String.t() | atom()]
}
@typedoc "Required credentials configuration"
@type required_credentials :: %{
api_key: boolean(),
secret: boolean(),
password: boolean(),
uid: boolean()
}
@typedoc "Fee tier entry (volume-based fee schedule)"
@type fee_tier :: %{
volume: number(),
fee: number()
}
@typedoc "Trading fee configuration"
@type trading_fees :: %{
optional(:maker) => number(),
optional(:taker) => number(),
optional(:tier_based) => boolean(),
optional(:percentage) => boolean(),
optional(:fee_side) => String.t(),
optional(:tiers) => %{maker: [fee_tier()], taker: [fee_tier()]}
}
@typedoc "Funding fee configuration (withdraw/deposit fees by currency)"
@type funding_fees :: %{
optional(:tier_based) => boolean(),
optional(:percentage) => boolean(),
optional(:withdraw) => %{String.t() => number()},
optional(:deposit) => %{String.t() => number()}
}
@typedoc "Market-type specific fee configuration"
@type market_type_fees :: %{
optional(:trading) => trading_fees()
}
@typedoc "Fee configuration (Task 106b: all 77 CCXT fee paths)"
@type fees :: %{
optional(:trading) => trading_fees(),
optional(:funding) => funding_fees(),
optional(:spot) => trading_fees() | market_type_fees(),
optional(:swap) => trading_fees() | market_type_fees(),
optional(:future) => trading_fees() | market_type_fees(),
optional(:option) => trading_fees() | market_type_fees(),
optional(:linear) => market_type_fees(),
optional(:inverse) => market_type_fees(),
optional(:tier_based) => boolean()
}
# Task 16c: Complete CCXT Metadata Extraction types
@typedoc "Exception mappings (error code -> error type atom)"
@type exceptions :: %{
optional(:exact) => %{String.t() => atom()},
optional(:broad) => %{String.t() => atom()},
optional(:http) => %{integer() => atom()}
}
@typedoc "Detailed error code entry including type and description"
@type error_code_detail :: %{
type: atom(),
description: String.t() | nil
}
@typedoc "Detailed error code mappings (error code -> detail map)"
@type error_code_details :: %{(String.t() | integer()) => error_code_detail()}
@typedoc """
Parse method metadata extracted from CCXT TypeScript sources.
This is the raw parse* method entry captured by
`priv/extractor/extract-parse-methods.cjs`.
"""
@type parse_method :: %{optional(String.t()) => term()}
@typedoc "Parse method metadata for an exchange."
@type parse_methods :: [parse_method()]
@typedoc "Features configuration by market type"
@type features :: %{
optional(:default) => map(),
optional(:spot) => map(),
optional(:swap) => map(),
optional(:future) => map(),
optional(:option) => map(),
optional(:margin) => map()
}
@typedoc "Exchange options configuration"
@type options :: %{
# String fields
optional(:default_type) => String.t(),
optional(:default_sub_type) => String.t(),
optional(:default_settle) => String.t(),
optional(:default_network) => String.t(),
optional(:default_time_in_force) => String.t(),
optional(:broker_id) => String.t(),
# Number fields
optional(:recv_window) => non_neg_integer(),
optional(:time_difference) => integer(),
# Boolean fields
optional(:adjust_for_time_difference) => boolean(),
optional(:sandbox_mode) => boolean(),
optional(:create_market_buy_order_requires_price) => boolean(),
# Map fields
optional(:networks) => %{String.t() => String.t()},
optional(:networks_by_id) => %{String.t() => String.t()},
optional(:default_networks) => %{String.t() => String.t()},
optional(:accounts_by_type) => %{String.t() => String.t()},
optional(:accounts_by_id) => %{String.t() => String.t()},
optional(:broker) => map(),
optional(atom()) => term(),
optional(String.t()) => term()
}
@typedoc "Extended metadata"
@type extended_metadata :: %{
optional(:logo_url) => String.t(),
optional(:fees_url) => String.t(),
optional(:referral_url) => String.t(),
optional(:referral_discount) => number(),
optional(:precision_mode) => integer(),
optional(:padding_mode) => integer(),
optional(:timeout_ms) => integer(),
optional(:dex) => boolean(),
optional(:limits) => map(),
optional(atom()) => term()
}
# Task 16d: HTTP client configuration
@typedoc "HTTP client configuration for exchanges requiring specific headers/user agents"
@type http_config :: %{
optional(:headers) => %{String.t() => String.t()},
optional(:user_agent) => String.t(),
optional(:alias) => boolean(),
optional(:comment) => String.t()
}
# Task 37: Body-level error response detection
@typedoc """
Response error detection configuration.
Exchanges often return HTTP 200 with error information in the body.
This config tells the HTTP client how to detect such errors.
## Pattern Types
- `:success_code` - Error if code field != expected success values (Bybit, OKX, KuCoin)
- `:error_present` - Error if specific field exists (Gate.io, Deribit)
- `:error_array` - Error if array field is non-empty (Kraken)
- `:error_field_present` - Error if field exists; success has no such field (Binance)
## Examples
# Bybit: retCode must be "0"
%{type: :success_code, field: "retCode", success_values: ["0"],
code_field: "retCode", message_field: "retMsg"}
# Gate.io: error if "label" field exists
%{type: :error_present, field: "label",
code_field: "label", message_field: "message"}
# Kraken: error if "error" array non-empty
%{type: :error_array, field: "error", message_field: "error"}
# Binance: error if "code" field present
%{type: :error_field_present, field: "code",
code_field: "code", message_field: "msg"}
"""
@type response_error_config :: %{
required(:type) => atom(),
required(:field) => String.t() | [String.t()] | atom() | [atom()],
optional(:success_values) => [String.t() | atom() | integer()],
optional(:code_field) => String.t() | [String.t()] | atom() | [atom()],
optional(:message_field) => String.t() | [String.t()] | atom() | [atom()]
}
@typedoc "OHLCV timestamp resolution - whether exchange expects milliseconds or seconds"
@type ohlcv_timestamp_resolution :: :milliseconds | :seconds | :unknown
@typedoc "URL strategy pattern detection result"
@type url_strategy :: %{
optional(:pattern) => atom() | nil,
optional(:prefix) => String.t() | nil,
optional(:detected_from) => atom() | String.t() | nil,
optional(:note) => String.t() | nil
}
@typedoc "Extraction metadata (when/how spec was extracted)"
@type extracted_metadata :: %{
optional(:ccxt_version) => String.t() | nil,
optional(:extracted_at) => String.t() | nil,
optional(atom()) => term()
}
@typedoc "Endpoint extraction statistics"
@type endpoint_extraction_stats :: %{
optional(String.t()) => term(),
optional(atom()) => term()
}
@type t :: %__MODULE__{
id: String.t(),
exchange_id: atom() | nil,
name: String.t(),
countries: [String.t()] | nil,
version: String.t() | nil,
classification: :certified_pro | :pro | :supported,
urls: url_map(),
rate_limits: rate_limit() | nil,
signing: signing_config() | nil,
has: capabilities(),
timeframes: %{String.t() => String.t()} | nil,
symbol_format: symbol_format() | nil,
symbol_formats: symbol_formats() | nil,
symbol_patterns: symbol_patterns() | nil,
order_mappings: order_mappings() | nil,
currency_aliases: %{String.t() => String.t()},
required_credentials: required_credentials() | nil,
fees: fees() | nil,
endpoints: [endpoint()],
raw_endpoints: raw_endpoints() | nil,
error_codes: %{(String.t() | integer()) => atom()},
error_code_details: error_code_details(),
parse_methods: parse_methods(),
ws: ws_raw_config() | ws_config() | nil,
# Task 16c: Complete metadata extraction
exceptions: exceptions() | nil,
features: features() | nil,
options: options() | nil,
extended_metadata: extended_metadata() | nil,
# Task 16d: HTTP client configuration
http_config: http_config() | nil,
# Task 37: Body-level error response detection
response_error: response_error_config() | nil,
# OHLCV timestamp resolution (ms vs seconds)
ohlcv_timestamp_resolution: ohlcv_timestamp_resolution(),
# Task 26: Parameter mappings (symbol → instId, etc.)
param_mappings: map() | nil,
# Task 26: Path prefix for API versioning (e.g., "/api/v5" for OKX)
path_prefix: String.t(),
# New fields for complete CCXT data passthrough
# Exchange metadata (previously lost after SpecBuilder)
certified: boolean() | nil,
pro: boolean() | nil,
dex: boolean() | nil,
status: map() | nil,
enable_rate_limit: boolean(),
# Options variants
exchange_options: map() | nil,
# Full market data (for reference/introspection)
currencies: map() | nil,
markets: map() | nil,
# URL pattern detection metadata
url_strategy: url_strategy() | nil,
# Extraction metadata
extracted_metadata: extracted_metadata() | nil,
endpoint_extraction_stats: endpoint_extraction_stats() | nil,
# Additional CCXT fields
comment: String.t() | nil,
requires_eddsa: boolean() | nil,
quote_json_numbers: boolean() | nil,
handle_content_type_application_zip: boolean() | nil,
# Raw API param requirements (keep raw structure)
api_param_requirements: map() | nil,
# Error handler source (for debugging/analysis)
handle_errors_source: String.t() | nil,
# Spec format version for migration support
spec_format_version: pos_integer()
}
@enforce_keys [:id, :name, :urls]
defstruct [
:id,
:exchange_id,
:name,
:version,
:signing,
:rate_limits,
:raw_endpoints,
:symbol_format,
:symbol_formats,
:symbol_patterns,
:order_mappings,
:required_credentials,
:fees,
:ws,
# Task 16c: Complete metadata extraction
:exceptions,
:features,
:options,
:extended_metadata,
# Task 16d: HTTP client configuration
:http_config,
# Task 37: Body-level error response detection
:response_error,
# OHLCV timestamp resolution (ms vs seconds)
:ohlcv_timestamp_resolution,
# Task 26: Parameter mappings (symbol → instId, etc.)
:param_mappings,
# New fields for complete CCXT data passthrough
# Exchange metadata (previously lost after SpecBuilder)
:certified,
:pro,
:dex,
:status,
# Options variants
:exchange_options,
# Full market data (for reference/introspection)
:currencies,
:markets,
# URL pattern detection metadata
:url_strategy,
# Extraction metadata
:extracted_metadata,
:endpoint_extraction_stats,
# Additional CCXT fields
:comment,
:requires_eddsa,
:quote_json_numbers,
:handle_content_type_application_zip,
# Raw API param requirements (keep raw structure)
:api_param_requirements,
# Error handler source (for debugging/analysis)
:handle_errors_source,
# Task 26: Path prefix for API versioning (e.g., "/api/v5" for OKX)
path_prefix: "",
countries: [],
classification: :supported,
urls: %{},
has: %{},
timeframes: %{},
currency_aliases: %{},
endpoints: [],
error_codes: %{},
error_code_details: %{},
parse_methods: [],
# Defaults for new fields
enable_rate_limit: true,
# Spec format version for migration support
spec_format_version: @current_spec_format_version
]
@doc """
Returns the current spec format version.
Used by the extractor to stamp new specs and by `load!/1` to validate
that specs aren't from a newer (unsupported) format version.
"""
@spec current_spec_format_version() :: pos_integer()
def current_spec_format_version, do: @current_spec_format_version
@doc """
Loads a spec from a file path.
The file should contain an Elixir term that can be evaluated to a Spec struct.
Specs are migrated to the current format version at load time.
Note: This function uses `Code.eval_file/1` which is safe here because paths
are hardcoded at compile time from the library's own `priv/specs/` directory,
not from user input.
"""
# sobelow_skip ["RCE.CodeModule"]
@spec load!(Path.t()) :: t()
def load!(path) do
{spec, _bindings} = Code.eval_file(path)
case spec do
%__MODULE__{} = s -> migrate(s)
map when is_map(map) -> map |> from_map() |> migrate()
_ -> raise ArgumentError, "Invalid spec file: #{path}"
end
end
@doc """
Creates a Spec from a map.
"""
@spec from_map(map()) :: t()
def from_map(map) when is_map(map) do
%__MODULE__{
id: get_required(map, :id),
name: get_required(map, :name),
countries: Map.get(map, :countries, []),
version: Map.get(map, :version),
classification: Map.get(map, :classification, :supported),
urls: get_required(map, :urls),
rate_limits: Map.get(map, :rate_limits),
signing: Map.get(map, :signing),
has: Map.get(map, :has, %{}),
timeframes: Map.get(map, :timeframes, %{}),
symbol_format: Map.get(map, :symbol_format),
symbol_formats: Map.get(map, :symbol_formats),
symbol_patterns: Map.get(map, :symbol_patterns),
order_mappings: Map.get(map, :order_mappings),
currency_aliases: Map.get(map, :currency_aliases, %{}),
required_credentials: Map.get(map, :required_credentials),
fees: Map.get(map, :fees),
endpoints: Map.get(map, :endpoints, []),
raw_endpoints: Map.get(map, :raw_endpoints),
error_codes: Map.get(map, :error_codes, %{}),
error_code_details: Map.get(map, :error_code_details, %{}),
parse_methods: Map.get(map, :parse_methods, []),
ws: Map.get(map, :ws),
# Task 16c: Complete metadata extraction
exceptions: Map.get(map, :exceptions),
features: Map.get(map, :features),
options: Map.get(map, :options),
extended_metadata: Map.get(map, :extended_metadata),
# Task 16d: HTTP client configuration
http_config: Map.get(map, :http_config),
# Task 37: Body-level error response detection
response_error: Map.get(map, :response_error),
# OHLCV timestamp resolution (ms vs seconds) - defaults to :milliseconds
ohlcv_timestamp_resolution: Map.get(map, :ohlcv_timestamp_resolution, :milliseconds),
# Task 26: Parameter mappings (symbol → instId, etc.)
param_mappings: Map.get(map, :param_mappings),
# Task 26: Path prefix for API versioning (defaults to "" for exchanges without versioned paths)
path_prefix: Map.get(map, :path_prefix, ""),
# New fields for complete CCXT data passthrough
# Exchange metadata (previously lost after SpecBuilder)
certified: Map.get(map, :certified),
pro: Map.get(map, :pro),
dex: Map.get(map, :dex),
status: Map.get(map, :status),
enable_rate_limit: Map.get(map, :enable_rate_limit, true),
# Options variants
exchange_options: Map.get(map, :exchange_options),
# Full market data (for reference/introspection)
currencies: Map.get(map, :currencies),
markets: Map.get(map, :markets),
# URL pattern detection metadata
url_strategy: Map.get(map, :url_strategy),
# Extraction metadata
extracted_metadata: Map.get(map, :extracted_metadata),
endpoint_extraction_stats: Map.get(map, :endpoint_extraction_stats),
# Additional CCXT fields
comment: Map.get(map, :comment),
requires_eddsa: Map.get(map, :requires_eddsa),
quote_json_numbers: Map.get(map, :quote_json_numbers),
handle_content_type_application_zip: Map.get(map, :handle_content_type_application_zip),
# Raw API param requirements (keep raw structure)
api_param_requirements: Map.get(map, :api_param_requirements),
# Error handler source (for debugging/analysis)
handle_errors_source: Map.get(map, :handle_errors_source),
# Spec format version (defaults to 1 for backward compatibility)
spec_format_version: Map.get(map, :spec_format_version, @current_spec_format_version)
}
end
@doc """
Returns the API URL for the spec, respecting sandbox mode.
## Usage
- `api_url(spec, false)` - Returns production API URL
- `api_url(spec, true)` - Returns default sandbox URL (backward compatible)
- `api_url(spec, "fapiPrivate")` - Returns sandbox URL for specific api_section
Multi-API exchanges (Binance, OKX) have different testnets per API section:
- Spot endpoints → testnet.binance.vision
- Futures endpoints → testnet.binancefuture.com
The api_section parameter enables per-endpoint sandbox routing.
"""
@spec api_url(t(), boolean() | String.t()) :: String.t() | nil
def api_url(spec, sandbox_or_section \\ false)
def api_url(%__MODULE__{urls: urls}, false), do: Map.fetch!(urls, :api)
def api_url(%__MODULE__{urls: urls}, true) do
# Legacy: boolean true = use default sandbox
case urls[:sandbox] do
sandbox when is_binary(sandbox) ->
sandbox
sandbox when is_map(sandbox) ->
# For map-based sandbox URLs, prefer "default" or common fallbacks
sandbox["default"] || sandbox["rest"] || sandbox["private"] || sandbox["public"] ||
Map.fetch!(urls, :api)
_ ->
Map.fetch!(urls, :api)
end
end
def api_url(%__MODULE__{urls: urls}, api_section) when is_binary(api_section) do
# New: specific api_section = look up in sandbox map
case urls[:sandbox] do
sandbox when is_map(sandbox) ->
sandbox[api_section] || sandbox["default"]
sandbox when is_binary(sandbox) ->
# Legacy single URL applies to all sections
sandbox
_ ->
# No sandbox URL available
nil
end
end
@doc """
Checks if the exchange supports a given capability.
"""
@spec has?(t(), atom()) :: boolean()
def has?(%__MODULE__{has: has}, capability) when is_atom(capability) do
Map.get(has, capability, false)
end
@doc """
Returns the list of supported capabilities.
"""
@spec capabilities(t()) :: [atom()]
def capabilities(%__MODULE__{has: has}) do
has
|> Enum.filter(fn {_k, v} -> v end)
|> Enum.map(fn {k, _v} -> k end)
|> Enum.sort()
end
defp get_required(map, key) do
case Map.fetch(map, key) do
{:ok, value} -> value
:error -> raise ArgumentError, "Missing required field: #{key}"
end
end
@doc false
# Migrates a spec to the current format version.
# v1 is the current version - passthrough.
# Future versions will chain: v1→v2, v2→v3, etc.
@spec migrate(t()) :: t()
defp migrate(%__MODULE__{spec_format_version: @current_spec_format_version} = spec), do: spec
defp migrate(%__MODULE__{spec_format_version: v}) when v > @current_spec_format_version do
raise ArgumentError,
"Spec format version #{v} is newer than supported version #{@current_spec_format_version}. " <>
"Upgrade ccxt_ex to load this spec."
end
defp migrate(%__MODULE__{spec_format_version: v}) do
raise ArgumentError,
"Invalid spec format version: #{inspect(v)}. " <>
"Expected a positive integer <= #{@current_spec_format_version}."
end
# ===========================================================================
# Features Helper Functions (Task 107)
# ===========================================================================
@doc """
Returns features for a specific market type.
Features include capabilities, numeric limits, and nested configs organized
by market type (spot, swap, future, option, margin).
## Parameters
- `spec` - The exchange specification
- `market_type` - Market type atom (:spot, :swap, :future, :option, :margin)
## Examples
iex> CCXT.Spec.features_for_market(spec, :spot)
%{
fetch_my_trades: %{limit: 1000},
margin_mode: false,
trigger_price: true,
time_in_force: %{ioc: true, fok: true, gtc: true}
}
iex> CCXT.Spec.features_for_market(spec, :unknown)
nil
"""
@spec features_for_market(t(), atom()) :: map() | nil
def features_for_market(%__MODULE__{features: nil}, _market_type), do: nil
def features_for_market(%__MODULE__{features: features}, market_type) when is_atom(market_type) do
Map.get(features, market_type)
end
@doc """
Returns the list of supported market types from features.
## Examples
iex> CCXT.Spec.supported_market_types(spec)
[:spot, :swap, :future]
"""
@spec supported_market_types(t()) :: [atom()]
def supported_market_types(%__MODULE__{features: nil}), do: []
def supported_market_types(%__MODULE__{features: features}) when is_map(features) do
features
|> Map.keys()
|> Enum.filter(&is_atom/1)
|> Enum.sort()
end
@doc """
Returns a specific feature value for a market type.
Useful for checking numeric limits like `fetchMyTrades.limit: 1000`.
## Parameters
- `spec` - The exchange specification
- `market_type` - Market type atom (:spot, :swap, etc.)
- `feature_key` - Feature key atom (e.g., :fetch_my_trades)
## Examples
iex> CCXT.Spec.feature_value(spec, :spot, :fetch_my_trades)
%{limit: 1000}
iex> CCXT.Spec.feature_value(spec, :spot, :margin_mode)
false
"""
@spec feature_value(t(), atom(), atom()) :: term()
def feature_value(%__MODULE__{features: nil}, _market_type, _feature_key), do: nil
def feature_value(%__MODULE__{features: features}, market_type, feature_key) do
features
|> Map.get(market_type, %{})
|> Map.get(feature_key)
end
# ===========================================================================
# Fee Helper Functions (Task 107)
# ===========================================================================
@doc """
Returns trading fees configuration.
Returns the base trading fee structure including maker/taker rates,
tier-based flag, and fee tiers if available.
## Examples
iex> CCXT.Spec.trading_fees(spec)
%{
maker: 0.001,
taker: 0.002,
tier_based: true,
percentage: true,
fee_side: "quote",
tiers: %{maker: [...], taker: [...]}
}
iex> CCXT.Spec.trading_fees(spec_without_fees)
nil
"""
@spec trading_fees(t()) :: trading_fees() | nil
def trading_fees(%__MODULE__{fees: nil}), do: nil
def trading_fees(%__MODULE__{fees: fees}), do: Map.get(fees, :trading)
@doc """
Returns fee tiers (volume-based fee schedules).
Returns the tiers map containing maker and taker fee schedules
based on trading volume.
## Examples
iex> CCXT.Spec.fee_tiers(spec)
%{
maker: [%{volume: 0, fee: 0.001}, %{volume: 10000, fee: 0.0008}],
taker: [%{volume: 0, fee: 0.002}, %{volume: 10000, fee: 0.0015}]
}
"""
@spec fee_tiers(t()) :: %{maker: [fee_tier()], taker: [fee_tier()]} | nil
def fee_tiers(%__MODULE__{fees: nil}), do: nil
def fee_tiers(%__MODULE__{fees: fees}) do
case Map.get(fees, :trading) do
nil -> nil
trading -> Map.get(trading, :tiers)
end
end
@doc """
Returns fees for a specific market type.
Some exchanges have different fee structures for different market types
(spot, swap, future, option, linear, inverse).
## Examples
iex> CCXT.Spec.fees_for_market(spec, :spot)
%{maker: 0.001, taker: 0.002}
iex> CCXT.Spec.fees_for_market(spec, :swap)
%{trading: %{maker: 0.0002, taker: 0.0005}}
"""
@spec fees_for_market(t(), atom()) :: map() | nil
def fees_for_market(%__MODULE__{fees: nil}, _market_type), do: nil
def fees_for_market(%__MODULE__{fees: fees}, market_type) when is_atom(market_type) do
Map.get(fees, market_type)
end
@doc """
Returns the maker fee rate.
Checks trading fees first, then falls back to market-type specific fees.
## Examples
iex> CCXT.Spec.maker_fee(spec)
0.001
iex> CCXT.Spec.maker_fee(spec, :swap)
0.0002
"""
@spec maker_fee(t(), atom() | nil) :: float() | nil
def maker_fee(spec, market_type \\ nil)
def maker_fee(%__MODULE__{fees: nil}, _market_type), do: nil
def maker_fee(%__MODULE__{fees: fees}, nil) do
get_in(fees, [:trading, :maker])
end
def maker_fee(%__MODULE__{fees: fees} = spec, market_type) do
# Try market-type specific first, fall back to base trading fees
case get_in(fees, [market_type, :maker]) do
nil ->
case get_in(fees, [market_type, :trading, :maker]) do
nil -> maker_fee(spec, nil)
fee -> fee
end
fee ->
fee
end
end
@doc """
Returns the taker fee rate.
Checks trading fees first, then falls back to market-type specific fees.
## Examples
iex> CCXT.Spec.taker_fee(spec)
0.002
iex> CCXT.Spec.taker_fee(spec, :swap)
0.0005
"""
@spec taker_fee(t(), atom() | nil) :: float() | nil
def taker_fee(spec, market_type \\ nil)
def taker_fee(%__MODULE__{fees: nil}, _market_type), do: nil
def taker_fee(%__MODULE__{fees: fees}, nil) do
get_in(fees, [:trading, :taker])
end
def taker_fee(%__MODULE__{fees: fees} = spec, market_type) do
# Try market-type specific first, fall back to base trading fees
case get_in(fees, [market_type, :taker]) do
nil ->
case get_in(fees, [market_type, :trading, :taker]) do
nil -> taker_fee(spec, nil)
fee -> fee
end
fee ->
fee
end
end
end