Packages

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

Current section

Files

Jump to
ccxt_client lib ccxt validate.ex
Raw

lib/ccxt/validate.ex

defmodule CCXT.Validate do
@moduledoc """
Validation utilities with helpful error hints.
This module provides validation functions for symbols, parameters, and WebSocket
symbol formats. Each function returns helpful error messages that guide users
toward correct usage.
## Functions
- `symbol/3` - Validates that a symbol is in unified format (BASE/QUOTE)
- `params/3` - Validates that required parameters are present for an endpoint
- `ws_symbol/3` - Validates and transforms a symbol for WebSocket subscriptions
## Exchange Resolution
All functions accept exchange identifiers in multiple formats:
- Exchange module (e.g., `CCXT.Bybit`)
- Exchange atom (e.g., `:bybit`)
- Exchange string (e.g., `"bybit"`)
- Spec struct directly (e.g., `%CCXT.Spec{}`)
"""
alias CCXT.Spec
alias CCXT.Symbol
# WebSocket symbol transformations by exchange
# These exchanges require lowercase symbols for WebSocket subscriptions
@ws_lowercase_exchanges ~w(binance binanceusdm binancecoinm)
# =============================================================================
# Task 126: Symbol Format Validation
# =============================================================================
@doc """
Validates that a symbol is in unified format.
Returns `{:ok, symbol}` if valid, or `{:error, message}` with a helpful hint
showing the expected format for the exchange.
## Parameters
- `exchange` - Exchange module, atom, string, or spec struct
- `symbol` - The symbol to validate
- `opts` - Optional keyword list:
- `:market_type` - Market type for derivatives (`:swap`, `:future`, `:option`)
## Examples
CCXT.Validate.symbol(:bybit, "BTC/USDT")
# => {:ok, "BTC/USDT"}
CCXT.Validate.symbol(:bybit, "BTC-USDT")
# => {:error, "Invalid symbol format 'BTC-USDT'. Bybit uses 'BASE/QUOTE' format (separator: /, case: upper). Example: BTC/USDT"}
CCXT.Validate.symbol(:bybit, "BTC/USDT:USDT", market_type: :swap)
# => {:ok, "BTC/USDT:USDT"}
"""
@spec symbol(module() | atom() | String.t() | Spec.t(), String.t(), keyword()) ::
{:ok, String.t()} | {:error, String.t()}
def symbol(exchange, symbol, opts \\ [])
def symbol(exchange, symbol, opts) when is_binary(symbol) do
case resolve_spec(exchange) do
{:ok, spec} ->
validate_symbol_format(symbol, spec, opts)
{:error, reason} ->
{:error, reason}
end
end
@doc false
# Validates a symbol against the unified format using Symbol.parse/1
@spec validate_symbol_format(String.t(), Spec.t(), keyword()) ::
{:ok, String.t()} | {:error, String.t()}
defp validate_symbol_format(symbol, spec, _opts) do
case Symbol.parse(symbol) do
{:ok, _parsed} ->
{:ok, symbol}
{:error, :invalid_format} ->
{:error, build_symbol_error_hint(symbol, spec)}
end
end
@doc false
# Builds a helpful error message for invalid symbol format
@spec build_symbol_error_hint(String.t(), Spec.t()) :: String.t()
defp build_symbol_error_hint(symbol, spec) do
exchange_name = spec.name
format = get_symbol_format(spec)
separator_desc =
case format[:separator] do
"" -> "no separator"
"/" -> "/"
sep -> sep
end
case_desc =
case format[:case] do
:upper -> "upper"
:lower -> "lower"
:mixed -> "mixed"
_ -> "upper"
end
# Build example using a common pair
example = build_example_symbol(format)
"Invalid symbol format '#{symbol}'. #{exchange_name} uses 'BASE/QUOTE' format " <>
"(separator: #{separator_desc}, case: #{case_desc}). Example: #{example}"
end
@doc false
# Gets the symbol format from a spec, with defaults
@spec get_symbol_format(Spec.t()) :: map()
defp get_symbol_format(%Spec{symbol_format: nil}), do: %{separator: "", case: :upper}
defp get_symbol_format(%Spec{symbol_format: format}), do: format
@doc false
# Builds an example unified symbol
@spec build_example_symbol(map()) :: String.t()
defp build_example_symbol(_format), do: "BTC/USDT"
# =============================================================================
# Task 127: Required Param Validation
# =============================================================================
@doc """
Validates that required parameters are present for an endpoint.
Returns `{:ok, params_map}` if all required parameters are present,
or `{:error, message}` listing the missing parameters.
## Parameters
- `exchange` - Exchange module, atom, string, or spec struct
- `method` - The endpoint method name (e.g., `:create_order`, `:fetch_balance`)
- `params` - The parameters provided (map or keyword list)
## Examples
CCXT.Validate.params(:bybit, :create_order, %{symbol: "BTC/USDT"})
# => {:error, "Missing required parameters: [:type, :side, :amount]. Endpoint 'create_order' requires: symbol, type, side, amount, price"}
CCXT.Validate.params(:bybit, :create_order, %{symbol: "BTC/USDT", type: "limit", side: "buy", amount: 0.1, price: 50000})
# => {:ok, %{symbol: "BTC/USDT", type: "limit", side: "buy", amount: 0.1, price: 50000}}
"""
@spec params(module() | atom() | String.t() | Spec.t(), atom(), map() | keyword()) ::
{:ok, map()} | {:error, String.t()}
def params(exchange, method, params) when is_atom(method) do
params_map = normalize_params(params)
case resolve_spec(exchange) do
{:ok, spec} ->
validate_endpoint_params(spec, method, params_map)
{:error, reason} ->
{:error, reason}
end
end
@doc false
# Normalizes params to a map with atom keys.
# String keys are converted to atoms only if the atom already exists (security).
# Unknown string keys are kept as strings to avoid atom table exhaustion.
@spec normalize_params(map() | keyword()) :: map()
defp normalize_params(params) when is_map(params) do
Map.new(params, fn
{k, v} when is_atom(k) -> {k, v}
{k, v} when is_binary(k) -> {safe_to_atom(k), v}
end)
end
defp normalize_params(params) when is_list(params) do
Map.new(params)
end
@doc false
# Safely converts a string to an existing atom, keeping as string if not found.
# This prevents atom table exhaustion from user-provided keys.
@spec safe_to_atom(String.t()) :: atom() | String.t()
defp safe_to_atom(key) when is_binary(key) do
String.to_existing_atom(key)
rescue
ArgumentError -> key
end
@doc false
# Validates endpoint parameters against required params list.
# Handles both atom and string keys (string keys kept when atom doesn't exist).
@spec validate_endpoint_params(Spec.t(), atom(), map()) ::
{:ok, map()} | {:error, String.t()}
defp validate_endpoint_params(spec, method, params_map) do
case find_endpoint(spec, method) do
nil ->
{:error, "Unknown endpoint '#{method}' for #{spec.name}"}
endpoint ->
required = get_required_params(endpoint)
# Normalize keys to strings for comparison (handles mixed atom/string keys)
provided_keys = params_map |> Map.keys() |> MapSet.new(&to_string/1)
missing = Enum.filter(required, fn p -> to_string(p) not in provided_keys end)
if Enum.empty?(missing) do
{:ok, params_map}
else
{:error, build_params_error_hint(method, missing, required)}
end
end
end
@doc false
# Finds an endpoint by name in the spec's endpoint list
@spec find_endpoint(Spec.t(), atom()) :: map() | nil
defp find_endpoint(%Spec{endpoints: endpoints}, name) do
Enum.find(endpoints, fn ep -> ep[:name] == name end)
end
@doc false
# Gets required params from an endpoint
# Uses :required_params if available, falls back to :params
@spec get_required_params(map()) :: [atom()]
defp get_required_params(endpoint) do
case Map.get(endpoint, :required_params) do
nil -> Map.get(endpoint, :params, [])
required -> required
end
end
@doc false
# Builds a helpful error message for missing parameters
@spec build_params_error_hint(atom(), [atom()], [atom()]) :: String.t()
defp build_params_error_hint(method, missing, required) do
required_str = Enum.map_join(required, ", ", &to_string/1)
"Missing required parameters: #{inspect(missing)}. " <>
"Endpoint '#{method}' requires: #{required_str}"
end
# =============================================================================
# Task 144: WS Symbol Validation
# =============================================================================
@doc """
Validates and transforms a symbol for WebSocket subscriptions.
First validates the symbol format, then denormalizes it to exchange format,
and applies any WebSocket-specific transformations (e.g., Binance uses lowercase).
## Parameters
- `exchange` - Exchange module, atom, string, or spec struct
- `symbol` - The unified symbol to validate and transform
- `opts` - Optional keyword list:
- `:market_type` - Market type for derivatives (`:swap`, `:future`, `:option`)
## Examples
CCXT.Validate.ws_symbol(:binance, "BTC/USDT")
# => {:ok, "btcusdt"}
CCXT.Validate.ws_symbol(:bybit, "BTC/USDT")
# => {:ok, "BTCUSDT"}
CCXT.Validate.ws_symbol(:binance, "BTC-USDT")
# => {:error, "Invalid symbol format 'BTC-USDT'. ..."}
"""
@spec ws_symbol(module() | atom() | String.t() | Spec.t(), String.t(), keyword()) ::
{:ok, String.t()} | {:error, String.t()}
def ws_symbol(exchange, symbol, opts \\ [])
def ws_symbol(exchange, symbol, opts) when is_binary(symbol) do
case resolve_spec(exchange) do
{:ok, spec} ->
# First validate the symbol format
case validate_symbol_format(symbol, spec, opts) do
{:ok, valid_symbol} ->
transform_for_websocket(valid_symbol, spec, opts)
{:error, _} = error ->
error
end
{:error, reason} ->
{:error, reason}
end
end
@doc false
# Transforms a validated symbol for WebSocket subscriptions
@spec transform_for_websocket(String.t(), Spec.t(), keyword()) :: {:ok, String.t()}
defp transform_for_websocket(symbol, spec, opts) do
market_type = Keyword.get(opts, :market_type)
# Denormalize to exchange format
exchange_symbol = Symbol.denormalize(symbol, spec, market_type)
# Apply WS-specific transforms
transformed = apply_ws_transform(exchange_symbol, spec.id)
{:ok, transformed}
end
@doc false
# Applies WebSocket-specific transformations
@spec apply_ws_transform(String.t(), String.t()) :: String.t()
defp apply_ws_transform(symbol, exchange_id) do
if exchange_id in @ws_lowercase_exchanges do
String.downcase(symbol)
else
symbol
end
end
# =============================================================================
# Exchange Resolution
# =============================================================================
@doc false
# Resolves an exchange identifier to a spec struct.
# Accepts: module, atom, string, or spec struct.
@spec resolve_spec(module() | atom() | String.t() | Spec.t()) ::
{:ok, Spec.t()} | {:error, String.t()}
defp resolve_spec(%Spec{} = spec), do: {:ok, spec}
defp resolve_spec(exchange) when is_atom(exchange) do
# Check if it's a module with __ccxt_spec__/0
if function_exported?(exchange, :__ccxt_spec__, 0) do
{:ok, exchange.__ccxt_spec__()}
else
# Try to construct the module name
resolve_spec_from_id(Atom.to_string(exchange))
end
end
defp resolve_spec(exchange) when is_binary(exchange) do
resolve_spec_from_id(exchange)
end
@doc false
# Resolves a spec from an exchange ID string
@spec resolve_spec_from_id(String.t()) :: {:ok, Spec.t()} | {:error, String.t()}
defp resolve_spec_from_id(exchange_id) do
module_name = Module.concat(CCXT, Macro.camelize(exchange_id))
if Code.ensure_loaded?(module_name) and function_exported?(module_name, :__ccxt_spec__, 0) do
{:ok, module_name.__ccxt_spec__()}
else
# Try loading spec from file as fallback
load_spec_from_file(exchange_id)
end
end
@doc false
# Loads a spec from priv/specs/ directory
@spec load_spec_from_file(String.t()) :: {:ok, Spec.t()} | {:error, String.t()}
defp load_spec_from_file(exchange_id) do
# Check extracted specs first, then curated
paths = [
Path.join([:code.priv_dir(:ccxt_client), "specs", "extracted", "#{exchange_id}.exs"]),
Path.join([:code.priv_dir(:ccxt_client), "specs", "curated", "#{exchange_id}.exs"])
]
case Enum.find(paths, &File.exists?/1) do
nil ->
{:error, "Exchange '#{exchange_id}' not found. Check that the exchange ID is correct."}
path ->
spec = Spec.load!(path)
{:ok, spec}
end
rescue
e ->
{:error, "Failed to load spec for '#{exchange_id}': #{Exception.message(e)}"}
end
end