Current section
Files
Jump to
Current section
Files
lib/ccxt/symbol.ex
defmodule CCXT.Symbol do
@moduledoc """
Bidirectional symbol normalization between unified and exchange-specific formats.
CCXT uses a unified symbol format: `BASE/QUOTE` (e.g., "BTC/USDT").
Different exchanges use different formats:
- Binance: "BTCUSDT" (no separator)
- Coinbase: "BTC-USD" (dash separator)
- Gate.io: "BTC_USDT" (underscore separator)
- Bitstamp: "btcusd" (lowercase)
- Some derivatives: "BTC/USDT:USDT" (with settle currency)
- Kraken: "XXBTZUSD" (X/Z prefixes for currencies)
- KrakenFutures: "PI_XBTUSD" (PI_/PF_/FI_ prefixes for contract types)
## Usage
Use with a spec's `symbol_format` for automatic normalization:
spec = CCXT.Bybit.__ccxt_spec__()
CCXT.Symbol.normalize("BTCUSDT", spec)
# => "BTC/USDT"
CCXT.Symbol.denormalize("BTC/USDT", spec)
# => "BTCUSDT"
Or use with a format map directly:
format = %{separator: "-", case: :upper}
CCXT.Symbol.normalize("BTC-USD", format)
# => "BTC/USD"
## Validation
Use bang functions for explicit error handling:
# Raises CCXT.Symbol.Error on failure
CCXT.Symbol.to_exchange_id!("BTC/USDT", spec)
# Check if conversion will work
case CCXT.Symbol.validate_symbol_conversion("BTC/USDT", spec) do
:ok -> CCXT.Symbol.to_exchange_id("BTC/USDT", spec)
{:error, reason} -> handle_error(reason)
end
"""
alias CCXT.Symbol.Error, as: SymbolError
require Logger
# Default quote currencies - used when spec doesn't provide known_quote_currencies
@default_quote_currencies ~w(USDT USDC USD EUR GBP JPY BTC ETH BUSD TUSD DAI USDD FDUSD)
# KrakenFutures contract prefixes that should be stripped before parsing
# Note: Kraken currency prefixes (X for crypto, Z for fiat) are handled separately
# in strip_currency_prefix/1 with more nuanced logic (XX→X, Z+4char only)
@known_contract_prefixes ["PI_", "PF_", "FI_", "FF_", "PV_"]
@type symbol_format :: %{separator: String.t(), case: :upper | :lower | :mixed}
@doc """
Converts an exchange-specific symbol to unified format.
## Parameters
- `symbol` - The exchange-specific symbol (e.g., "BTCUSDT")
- `spec_or_format` - A CCXT.Spec struct, or map with `:symbol_format`, or symbol_format map directly
## Returns
The unified symbol (e.g., "BTC/USDT") or the original if cannot parse.
## Examples
# With a spec
spec = CCXT.Bybit.__ccxt_spec__()
CCXT.Symbol.normalize("BTCUSDT", spec)
# => "BTC/USDT"
# With a format map
CCXT.Symbol.normalize("BTC-USD", %{separator: "-", case: :upper})
# => "BTC/USD"
"""
@spec normalize(String.t(), map()) :: String.t()
def normalize(symbol, spec_or_format) when is_binary(symbol) and is_map(spec_or_format) do
{format, aliases} = extract_format_and_aliases(spec_or_format)
do_normalize(symbol, format, aliases)
end
@doc """
Converts a unified symbol to exchange-specific format.
## Parameters
- `symbol` - The unified symbol (e.g., "BTC/USDT")
- `spec_or_format` - A CCXT.Spec struct, or map with `:symbol_format`, or symbol_format map directly
- `market_type` - Optional market type (:spot, :swap, :future, :option) for per-market-type formats
## Returns
The exchange-specific symbol (e.g., "BTCUSDT").
## Examples
# With a spec
spec = CCXT.Coinbase.__ccxt_spec__()
CCXT.Symbol.denormalize("BTC/USD", spec)
# => "BTC-USD"
# With a format map
CCXT.Symbol.denormalize("BTC/USDT", %{separator: "_", case: :upper})
# => "BTC_USDT"
# With market type for per-market-type exchanges
spec = CCXT.HTX.__ccxt_spec__()
CCXT.Symbol.denormalize("BTC/USDT", spec, :spot)
# => "btcusdt"
"""
@spec denormalize(String.t(), map(), atom() | nil) :: String.t()
def denormalize(symbol, spec_or_format, market_type \\ nil)
def denormalize(symbol, spec_or_format, market_type) when is_binary(symbol) and is_map(spec_or_format) do
{format, _aliases} = extract_format_and_aliases(spec_or_format, market_type)
do_denormalize(symbol, format)
end
@doc false
# Extracts symbol format and currency aliases from spec or format map.
# When market_type is provided, checks symbol_formats (plural) first.
@spec extract_format_and_aliases(map(), atom() | nil) :: {symbol_format(), map()}
defp extract_format_and_aliases(spec_or_format, market_type \\ nil) do
# Get symbol_formats value - must be a map, not just key existence
# (Spec struct defines the key but value may be nil)
symbol_formats = Map.get(spec_or_format, :symbol_formats)
cond do
# Check symbol_formats (plural) first when market_type is provided
market_type != nil and is_map(symbol_formats) ->
formats = symbol_formats
case Map.get(formats, market_type) do
%{separator: _, case: _} = format ->
aliases = Map.get(spec_or_format, :currency_aliases, %{})
{format, aliases}
_ ->
# No format for this market type, try symbol_format (singular)
extract_format_and_aliases(spec_or_format, nil)
end
# It's a spec with symbol_format field
Map.has_key?(spec_or_format, :symbol_format) ->
format = spec_or_format.symbol_format || default_format()
aliases = Map.get(spec_or_format, :currency_aliases, %{})
{format, aliases}
# It's a format map directly (has :separator and :case)
Map.has_key?(spec_or_format, :separator) ->
{spec_or_format, %{}}
# Unknown format, use defaults
true ->
{default_format(), %{}}
end
end
@spec default_format() :: symbol_format()
defp default_format, do: %{separator: "", case: :upper}
# Internal normalize implementation
@spec do_normalize(String.t(), symbol_format(), map()) :: String.t()
defp do_normalize(symbol, %{separator: sep, case: sym_case}, currency_aliases) do
# First apply case normalization
symbol = if sym_case == :lower, do: String.upcase(symbol), else: symbol
# Apply currency alias mapping (reverse: exchange code -> unified code)
# commonCurrencies is unified -> exchange, we need exchange -> unified
reverse_aliases = Map.new(currency_aliases, fn {unified, exchange} -> {exchange, unified} end)
symbol = apply_currency_aliases(symbol, reverse_aliases)
# Split by separator
case sep do
"" -> find_and_split(symbol)
"/" -> symbol
_ -> String.replace(symbol, sep, "/")
end
end
# Internal denormalize implementation
@spec do_denormalize(String.t(), symbol_format()) :: String.t()
defp do_denormalize(symbol, %{separator: sep, case: sym_case}) do
# Handle settle currency (e.g., "BTC/USDT:USDT" -> just use "BTC/USDT" part)
pair = symbol |> String.split(":") |> hd()
# Replace unified separator with exchange separator
result = String.replace(pair, "/", sep)
# Apply case transformation
case sym_case do
:lower -> String.downcase(result)
:upper -> String.upcase(result)
_ -> result
end
end
# Apply currency aliases to symbol (e.g., XBT -> BTC)
@spec apply_currency_aliases(String.t(), map()) :: String.t()
defp apply_currency_aliases(symbol, aliases) when map_size(aliases) == 0, do: symbol
defp apply_currency_aliases(symbol, aliases) do
Enum.reduce(aliases, symbol, fn {from, to}, acc ->
String.replace(acc, from, to)
end)
end
# Split symbol without separator by finding known quote currency
@spec find_and_split(String.t()) :: String.t()
defp find_and_split(symbol) do
# Already has separator
if String.contains?(symbol, "/") do
symbol
else
# Try to find a known quote currency at the end
case Enum.find(@default_quote_currencies, &String.ends_with?(symbol, &1)) do
nil ->
symbol
quote_currency ->
base = String.replace_suffix(symbol, quote_currency, "")
"#{base}/#{quote_currency}"
end
end
end
# =============================================================================
# PARSING UTILITIES
# =============================================================================
@type parsed_symbol :: %{base: String.t(), quote: String.t(), settle: String.t() | nil}
@doc """
Parses a unified symbol into its components.
## Example
CCXT.Symbol.parse("BTC/USDT")
# => {:ok, %{base: "BTC", quote: "USDT", settle: nil}}
CCXT.Symbol.parse("BTC/USDT:USDT")
# => {:ok, %{base: "BTC", quote: "USDT", settle: "USDT"}}
"""
@spec parse(String.t()) :: {:ok, parsed_symbol()} | {:error, :invalid_format}
def parse(symbol) when is_binary(symbol) do
case String.split(symbol, ":") do
[pair, settle] -> parse_pair(pair, settle)
[pair] -> parse_pair(pair, nil)
_ -> {:error, :invalid_format}
end
end
defp parse_pair(pair, settle) do
case String.split(pair, "/") do
[base, quote_currency] when base != "" and quote_currency != "" ->
{:ok, %{base: base, quote: quote_currency, settle: settle}}
_ ->
{:error, :invalid_format}
end
end
@doc """
Parses a unified symbol into its components, raising on error.
"""
@spec parse!(String.t()) :: parsed_symbol()
def parse!(symbol) when is_binary(symbol) do
case parse(symbol) do
{:ok, result} -> result
{:error, :invalid_format} -> raise ArgumentError, "invalid symbol format: #{inspect(symbol)}"
end
end
@doc """
Builds a unified symbol from components.
## Example
CCXT.Symbol.build("BTC", "USDT")
# => "BTC/USDT"
CCXT.Symbol.build("BTC", "USDT", "USDT")
# => "BTC/USDT:USDT"
"""
@spec build(String.t(), String.t(), String.t() | nil) :: String.t()
def build(base, quote_currency, settle \\ nil)
def build(base, quote_currency, nil), do: "#{base}/#{quote_currency}"
def build(base, quote_currency, settle), do: "#{base}/#{quote_currency}:#{settle}"
# =============================================================================
# WEBSOCKET SYMBOL DENORMALIZATION
# =============================================================================
@type ws_symbol_format :: :dash_separated | :lowercase_no_slash | :uppercase_no_slash | :slash | :unknown
@doc """
Converts a unified symbol to WebSocket channel format.
WebSocket channels often use different symbol formats than REST APIs.
This function converts unified symbols to the format detected from
captured WebSocket subscribe messages.
## Parameters
- `symbol` - The unified symbol (e.g., "BTC/USDT")
- `format` - The WS symbol format
- `opts` - Reserved for future extensibility (e.g., exchange-specific overrides)
## Returns
The WebSocket-formatted symbol.
## Examples
iex> CCXT.Symbol.denormalize_ws("BTC/USDT", :dash_separated)
"BTC-USDT"
iex> CCXT.Symbol.denormalize_ws("BTC/USDT", :lowercase_no_slash)
"btcusdt"
iex> CCXT.Symbol.denormalize_ws("BTC/USDT", :uppercase_no_slash)
"BTCUSDT"
iex> CCXT.Symbol.denormalize_ws("BTC/USDT", :slash)
"BTC/USDT"
"""
@spec denormalize_ws(String.t(), ws_symbol_format(), keyword()) :: String.t()
def denormalize_ws(symbol, format, _opts \\ [])
def denormalize_ws(symbol, :dash_separated, _opts), do: String.replace(symbol, "/", "-")
def denormalize_ws(symbol, :lowercase_no_slash, _opts) do
symbol |> String.replace("/", "") |> String.downcase()
end
def denormalize_ws(symbol, :uppercase_no_slash, _opts), do: String.replace(symbol, "/", "")
def denormalize_ws(symbol, :slash, _opts), do: symbol
# Safe default for unknown formats - log and remove slash
def denormalize_ws(symbol, format, _opts) do
Logger.debug("[CCXT.Symbol] Unknown WS symbol format #{inspect(format)} for #{symbol}, using uppercase_no_slash")
String.replace(symbol, "/", "")
end
# =============================================================================
# PATTERN-BASED SYMBOL CONVERSION (R6)
# =============================================================================
# Uses detected patterns from R5 (SymbolPatternDetector) to convert symbols
# without requiring loadMarkets() API calls.
# Month abbreviations for DDMMMYY format parsing
@month_abbrevs %{
1 => "JAN",
2 => "FEB",
3 => "MAR",
4 => "APR",
5 => "MAY",
6 => "JUN",
7 => "JUL",
8 => "AUG",
9 => "SEP",
10 => "OCT",
11 => "NOV",
12 => "DEC"
}
@month_numbers %{
"JAN" => 1,
"FEB" => 2,
"MAR" => 3,
"APR" => 4,
"MAY" => 5,
"JUN" => 6,
"JUL" => 7,
"AUG" => 8,
"SEP" => 9,
"OCT" => 10,
"NOV" => 11,
"DEC" => 12
}
@type parsed_extended :: %{
base: String.t(),
quote: String.t(),
settle: String.t() | nil,
expiry: String.t() | nil,
strike: String.t() | nil,
option_type: String.t() | nil
}
@type pattern_config :: %{
pattern: atom(),
separator: String.t(),
case: :upper | :lower | :mixed,
date_format: :yymmdd | :ddmmmyy | :yyyymmdd | nil,
suffix: String.t() | nil,
component_order: [atom()] | nil
}
@doc """
Converts a unified symbol to exchange-specific ID using detected patterns.
This function uses the `symbol_patterns` field from a spec (detected by R5)
to perform static symbol conversion without requiring `loadMarkets()` API calls.
## Parameters
- `unified_symbol` - The unified symbol (e.g., "BTC/USDT:USDT-260327")
- `spec` - A CCXT.Spec struct containing `symbol_patterns`
- `opts` - Options:
- `:market_type` - Override auto-detected market type (:spot, :swap, :future, :option)
## Returns
The exchange-specific ID (e.g., "BTCUSDT_260327")
## Examples
# Spot conversion
CCXT.Symbol.to_exchange_id("BTC/USDT", binance_spec)
# => "BTCUSDT"
# Perpetual/swap conversion
CCXT.Symbol.to_exchange_id("BTC/USDT:USDT", binance_spec)
# => "BTCUSDT"
CCXT.Symbol.to_exchange_id("BTC/USD:BTC", deribit_spec)
# => "BTC-PERPETUAL"
# Future conversion
CCXT.Symbol.to_exchange_id("BTC/USDT:USDT-260327", binance_spec)
# => "BTCUSDT_260327"
CCXT.Symbol.to_exchange_id("BTC/USD:BTC-260116", deribit_spec)
# => "BTC-16JAN26"
# Option conversion
CCXT.Symbol.to_exchange_id("BTC/USD:BTC-260112-84000-C", deribit_spec)
# => "BTC-12JAN26-84000-C"
"""
@spec to_exchange_id(String.t(), map(), keyword()) :: String.t()
def to_exchange_id(unified_symbol, spec, opts \\ [])
def to_exchange_id(unified_symbol, spec, opts) when is_binary(unified_symbol) and is_map(spec) do
case parse_extended(unified_symbol) do
{:ok, parsed} ->
market_type = opts[:market_type] || detect_market_type(parsed)
pattern_config = get_pattern_config(spec, market_type)
if pattern_config do
apply_pattern(parsed, pattern_config, spec)
else
# Fall back to legacy denormalize
denormalize(unified_symbol, spec, market_type)
end
{:error, _} ->
# Invalid symbol, return as-is
unified_symbol
end
end
@doc """
Converts an exchange-specific ID to unified symbol format.
Requires `market_type` since it cannot be auto-detected from the exchange ID alone.
## Parameters
- `exchange_id` - The exchange-specific ID (e.g., "BTCUSDT_260327")
- `spec` - A CCXT.Spec struct containing `symbol_patterns`
- `market_type` - The market type (:spot, :swap, :future, :option)
## Returns
The unified symbol (e.g., "BTC/USDT:USDT-260327")
## Examples
CCXT.Symbol.from_exchange_id("BTCUSDT", binance_spec, :spot)
# => "BTC/USDT"
CCXT.Symbol.from_exchange_id("BTC-PERPETUAL", deribit_spec, :swap)
# => "BTC/USD:BTC"
CCXT.Symbol.from_exchange_id("BTCUSDT_260327", binance_spec, :future)
# => "BTC/USDT:USDT-260327"
"""
@spec from_exchange_id(String.t(), map(), atom()) :: String.t()
def from_exchange_id(exchange_id, spec, market_type)
when is_binary(exchange_id) and is_map(spec) and is_atom(market_type) do
pattern_config = get_pattern_config(spec, market_type)
if pattern_config do
reverse_pattern(exchange_id, pattern_config, market_type, spec)
else
# Fall back to normalize
normalize(exchange_id, spec)
end
end
# =============================================================================
# VALIDATION FUNCTIONS (R7 - Explicit Error Handling)
# =============================================================================
@doc """
Converts a unified symbol to exchange-specific ID, raising on failure.
Unlike `to_exchange_id/3`, this function raises `CCXT.Symbol.Error` when:
- The symbol format is invalid and cannot be parsed
- No pattern configuration is found for the market type
## Parameters
Same as `to_exchange_id/3`.
## Returns
The exchange-specific ID string.
## Raises
- `CCXT.Symbol.Error` with reason `:invalid_format` if symbol cannot be parsed
- `CCXT.Symbol.Error` with reason `:pattern_not_found` if no pattern for market type
## Examples
CCXT.Symbol.to_exchange_id!("BTC/USDT", binance_spec)
# => "BTCUSDT"
CCXT.Symbol.to_exchange_id!("INVALID", binance_spec)
# ** (CCXT.Symbol.Error) Invalid symbol format: "INVALID"
"""
@spec to_exchange_id!(String.t(), map(), keyword()) :: String.t()
def to_exchange_id!(unified_symbol, spec, opts \\ [])
def to_exchange_id!(unified_symbol, spec, opts) when is_binary(unified_symbol) and is_map(spec) do
case parse_extended(unified_symbol) do
{:ok, parsed} ->
market_type = opts[:market_type] || detect_market_type(parsed)
pattern_config = get_pattern_config(spec, market_type)
if pattern_config do
apply_pattern(parsed, pattern_config, spec)
else
spec_name = Map.get(spec, :name) || Map.get(spec, :id)
raise SymbolError.pattern_not_found(unified_symbol, market_type, spec_name)
end
{:error, :invalid_format} ->
raise SymbolError.invalid_format(unified_symbol)
end
end
@doc """
Converts an exchange-specific ID to unified symbol format, raising on failure.
Unlike `from_exchange_id/3`, this function raises `CCXT.Symbol.Error` when:
- No pattern configuration is found for the market type
## Parameters
Same as `from_exchange_id/3`.
## Returns
The unified symbol string.
## Raises
- `CCXT.Symbol.Error` with reason `:pattern_not_found` if no pattern for market type
## Examples
CCXT.Symbol.from_exchange_id!("BTCUSDT", binance_spec, :spot)
# => "BTC/USDT"
CCXT.Symbol.from_exchange_id!("BTCUSDT", %{}, :spot)
# ** (CCXT.Symbol.Error) No symbol pattern found for market type :spot
"""
@spec from_exchange_id!(String.t(), map(), atom()) :: String.t()
def from_exchange_id!(exchange_id, spec, market_type)
when is_binary(exchange_id) and is_map(spec) and is_atom(market_type) do
pattern_config = get_pattern_config(spec, market_type)
if pattern_config do
reverse_pattern(exchange_id, pattern_config, market_type, spec)
else
spec_name = Map.get(spec, :name) || Map.get(spec, :id)
raise SymbolError.pattern_not_found(exchange_id, market_type, spec_name)
end
end
@doc """
Validates that a symbol conversion will succeed without actually performing it.
Use this to check whether `to_exchange_id/3` will use pattern matching or
fall back to legacy denormalization.
## Parameters
- `unified_symbol` - The unified symbol (e.g., "BTC/USDT")
- `spec` - A CCXT.Spec struct containing `symbol_patterns`
- `opts` - Options:
- `:market_type` - Override auto-detected market type
## Returns
- `:ok` - Pattern will match
- `{:error, :invalid_format}` - Symbol cannot be parsed
- `{:error, {:pattern_not_found, market_type}}` - No pattern for market type
- `{:error, {:unknown_quote_currency, symbol}}` - Quote currency not recognized
## Examples
CCXT.Symbol.validate_symbol_conversion("BTC/USDT", spec)
# => :ok
CCXT.Symbol.validate_symbol_conversion("BTC/UNKNOWN", spec)
# => {:error, {:unknown_quote_currency, "BTC/UNKNOWN"}}
"""
@spec validate_symbol_conversion(String.t(), map(), keyword()) ::
:ok | {:error, :invalid_format | {:pattern_not_found, atom()} | {:unknown_quote_currency, String.t()}}
def validate_symbol_conversion(unified_symbol, spec, opts \\ [])
def validate_symbol_conversion(unified_symbol, spec, opts) when is_binary(unified_symbol) and is_map(spec) do
case parse_extended(unified_symbol) do
{:ok, parsed} ->
market_type = opts[:market_type] || detect_market_type(parsed)
pattern_config = get_pattern_config(spec, market_type)
cond do
pattern_config != nil ->
# Pattern exists - conversion will work
:ok
has_legacy_format?(spec) ->
# Will fall back to legacy denormalize - that's OK
:ok
true ->
{:error, {:pattern_not_found, market_type}}
end
{:error, :invalid_format} ->
{:error, :invalid_format}
end
end
@doc false
# Checks if spec has legacy symbol_format or symbol_formats for fallback
defp has_legacy_format?(spec) do
Map.has_key?(spec, :symbol_format) or Map.has_key?(spec, :symbol_formats)
end
# =============================================================================
# PREFIX HANDLING (R7 - Kraken X/Z and KrakenFutures PI_/PF_/FI_)
# =============================================================================
@doc """
Strips known exchange prefixes from a symbol or currency code.
Handles:
- Kraken currency prefixes: X (crypto), Z (fiat) - e.g., "XXBT" → "XBT", "ZUSD" → "USD"
- KrakenFutures contract prefixes: PI_, PF_, FI_, FF_, PV_ - e.g., "PI_XBTUSD" → "XBTUSD"
## Examples
CCXT.Symbol.strip_prefix("PI_XBTUSD")
# => {"PI_", "XBTUSD"}
CCXT.Symbol.strip_prefix("XXBT")
# => {"X", "XBT"}
CCXT.Symbol.strip_prefix("BTCUSDT")
# => {nil, "BTCUSDT"}
"""
@spec strip_prefix(String.t()) :: {String.t() | nil, String.t()}
def strip_prefix(symbol) when is_binary(symbol) do
# Try contract prefixes first (more specific, longer)
case find_matching_prefix(symbol, @known_contract_prefixes) do
{prefix, rest} -> {prefix, rest}
nil -> strip_currency_prefix(symbol)
end
end
@doc false
# Strips Kraken-style X/Z currency prefixes (only at start, for specific patterns)
defp strip_currency_prefix(symbol) do
cond do
# XXBT → X + XBT (doubled X prefix)
String.starts_with?(symbol, "XX") ->
{"X", String.slice(symbol, 1..-1//1)}
# ZUSD → Z + USD (fiat prefix)
String.starts_with?(symbol, "Z") and String.length(symbol) == 4 ->
{"Z", String.slice(symbol, 1..-1//1)}
# No prefix
true ->
{nil, symbol}
end
end
@doc false
# Finds first matching prefix from a list
defp find_matching_prefix(symbol, prefixes) do
Enum.find_value(prefixes, fn prefix ->
if String.starts_with?(symbol, prefix) do
{prefix, String.replace_prefix(symbol, prefix, "")}
end
end)
end
@doc """
Parses a unified symbol into extended components including derivative fields.
## Examples
CCXT.Symbol.parse_extended("BTC/USDT")
# => {:ok, %{base: "BTC", quote: "USDT", settle: nil, expiry: nil, strike: nil, option_type: nil}}
CCXT.Symbol.parse_extended("BTC/USDT:USDT-260327")
# => {:ok, %{base: "BTC", quote: "USDT", settle: "USDT", expiry: "260327", strike: nil, option_type: nil}}
CCXT.Symbol.parse_extended("BTC/USD:BTC-260112-84000-C")
# => {:ok, %{base: "BTC", quote: "USD", settle: "BTC", expiry: "260112", strike: "84000", option_type: "C"}}
"""
@spec parse_extended(String.t()) :: {:ok, parsed_extended()} | {:error, :invalid_format}
def parse_extended(symbol) when is_binary(symbol) do
# Split on colon first: "BTC/USDT:USDT-260327" -> ["BTC/USDT", "USDT-260327"]
case String.split(symbol, ":") do
[pair] ->
# Simple spot symbol
parse_extended_pair(pair, nil)
[pair, settle_and_rest] ->
# Has settle currency and possibly more
parse_extended_pair(pair, settle_and_rest)
_ ->
{:error, :invalid_format}
end
end
@doc """
Converts date between YYMMDD and DDMMMYY formats.
## Examples
CCXT.Symbol.convert_date("260327", :yymmdd, :ddmmmyy)
# => "27MAR26"
CCXT.Symbol.convert_date("27MAR26", :ddmmmyy, :yymmdd)
# => "260327"
CCXT.Symbol.convert_date("260109", :yymmdd, :ddmmmyy)
# => "9JAN26"
"""
@spec convert_date(String.t(), atom(), atom()) :: String.t()
def convert_date(date_str, from_format, to_format)
# Same format - no conversion needed
def convert_date(date_str, format, format), do: date_str
# YYMMDD -> DDMMMYY
def convert_date(date_str, :yymmdd, :ddmmmyy) do
<<yy::binary-2, mm::binary-2, dd::binary-2>> = date_str
month = String.to_integer(mm)
day = String.to_integer(dd)
month_abbrev = Map.fetch!(@month_abbrevs, month)
"#{day}#{month_abbrev}#{yy}"
end
# DDMMMYY -> YYMMDD
def convert_date(date_str, :ddmmmyy, :yymmdd) do
# Parse "27MAR26" or "9JAN26" (day can be 1 or 2 digits)
date_upper = String.upcase(date_str)
case Regex.run(~r/^(\d{1,2})([A-Z]{3})(\d{2})$/, date_upper) do
[_, day_str, month_str, year_str] ->
month = Map.fetch!(@month_numbers, month_str)
day = String.to_integer(day_str)
"#{year_str}#{pad_two(month)}#{pad_two(day)}"
_ ->
date_str
end
end
# YYYYMMDD -> YYMMDD (just drop century)
def convert_date(date_str, :yyyymmdd, :yymmdd) do
<<_century::binary-2, rest::binary>> = date_str
rest
end
# YYMMDD -> YYYYMMDD (add 20 as century)
def convert_date(date_str, :yymmdd, :yyyymmdd), do: "20#{date_str}"
# YYYYMMDD -> DDMMMYY
def convert_date(date_str, :yyyymmdd, :ddmmmyy) do
date_str
|> convert_date(:yyyymmdd, :yymmdd)
|> convert_date(:yymmdd, :ddmmmyy)
end
# DDMMMYY -> YYYYMMDD
def convert_date(date_str, :ddmmmyy, :yyyymmdd) do
date_str
|> convert_date(:ddmmmyy, :yymmdd)
|> convert_date(:yymmdd, :yyyymmdd)
end
# ============================================================================
# Private: Extended Parsing
# ============================================================================
@doc false
# Parses simple spot pair like "BTC/USDT" into extended components
defp parse_extended_pair(pair, nil) do
# Simple pair like "BTC/USDT"
case String.split(pair, "/") do
[base, quote_currency] when base != "" and quote_currency != "" ->
{:ok,
%{
base: base,
quote: quote_currency,
settle: nil,
expiry: nil,
strike: nil,
option_type: nil
}}
_ ->
{:error, :invalid_format}
end
end
@doc false
# Parses pair with derivative suffix like "BTC/USDT" + "USDT-260327"
defp parse_extended_pair(pair, settle_and_rest) do
# Parse pair first
case String.split(pair, "/") do
[base, quote_currency] when base != "" and quote_currency != "" ->
# Parse settle and derivative components
parse_derivative_suffix(base, quote_currency, settle_and_rest)
_ ->
{:error, :invalid_format}
end
end
@doc false
# Parses derivative suffix into settle, expiry, strike, and option_type components
defp parse_derivative_suffix(base, quote_currency, settle_and_rest) do
# Settle and rest: "USDT" or "USDT-260327" or "BTC-260112-84000-C"
parts = String.split(settle_and_rest, "-")
case parts do
[settle] ->
# Just settle, no expiry (swap/perpetual)
{:ok,
%{
base: base,
quote: quote_currency,
settle: settle,
expiry: nil,
strike: nil,
option_type: nil
}}
[settle, expiry] ->
# Settle + expiry (future)
{:ok,
%{
base: base,
quote: quote_currency,
settle: settle,
expiry: expiry,
strike: nil,
option_type: nil
}}
[settle, expiry, strike, option_type] ->
# Settle + expiry + strike + type (option)
{:ok,
%{
base: base,
quote: quote_currency,
settle: settle,
expiry: expiry,
strike: strike,
option_type: option_type
}}
_ ->
{:error, :invalid_format}
end
end
# ============================================================================
# Private: Market Type Detection
# ============================================================================
@doc false
# Detects market type from parsed symbol components (option > future > swap > spot)
defp detect_market_type(parsed) do
cond do
parsed.option_type != nil -> :option
parsed.expiry != nil -> :future
parsed.settle != nil -> :swap
true -> :spot
end
end
# ============================================================================
# Private: Pattern Config Retrieval
# ============================================================================
@doc false
# Retrieves pattern configuration for a market type from spec's symbol_patterns
defp get_pattern_config(spec, market_type) do
case Map.get(spec, :symbol_patterns) do
nil -> nil
patterns when is_map(patterns) -> Map.get(patterns, market_type)
end
end
# ============================================================================
# Private: Apply Pattern (unified -> exchange)
# ============================================================================
# Pattern categories for dispatch
# Naming convention: {separator}_{case} for spot, {type}_{suffix/format} for derivatives
# Example: no_separator_upper = no separator + uppercase
# Example: future_ddmmmyy = future with DDMMMYY date format
@spot_patterns ~w(no_separator_upper no_separator_lower no_separator_mixed
underscore_upper underscore_lower underscore_mixed
dash_upper dash_lower dash_mixed)a
@swap_patterns ~w(implicit suffix_perpetual suffix_swap suffix_perp)a
@future_patterns ~w(future_yymmdd future_ddmmmyy future_yyyymmdd future_unknown)a
@option_patterns ~w(option_ddmmmyy option_yymmdd option_with_settle option_unknown)a
@doc false
# Dispatches to market-type-specific pattern application.
defp apply_pattern(parsed, config, spec) do
aliases = Map.get(spec, :currency_aliases, %{})
base = apply_forward_alias(parsed.base, aliases)
pattern = config.pattern
cond do
pattern in @spot_patterns -> apply_spot_pattern(base, parsed, config)
pattern in @swap_patterns -> apply_swap_pattern(base, parsed, config)
pattern in @future_patterns -> apply_future_pattern(base, parsed, config)
pattern in @option_patterns -> apply_option_pattern(base, parsed, config)
true -> "#{base}#{config.separator}#{parsed.quote}"
end
end
@doc false
# Applies spot patterns using separator extraction from pattern name
defp apply_spot_pattern(base, parsed, config) do
separator = spot_separator(config.pattern)
result = "#{base}#{separator}#{parsed.quote}"
if String.ends_with?(Atom.to_string(config.pattern), "_mixed") do
result
else
apply_case(result, config.case)
end
end
@doc false
# Extracts separator from spot pattern name
defp spot_separator(pattern) do
pattern_str = Atom.to_string(pattern)
cond do
String.starts_with?(pattern_str, "no_separator") -> ""
String.starts_with?(pattern_str, "underscore") -> "_"
String.starts_with?(pattern_str, "dash") -> "-"
true -> ""
end
end
@doc false
# Applies swap/perpetual patterns: implicit, suffix_perpetual, suffix_swap, suffix_perp
defp apply_swap_pattern(base, parsed, config) do
case config.pattern do
:implicit ->
apply_case("#{base}#{config.separator}#{parsed.quote}", config.case)
:suffix_perpetual ->
apply_case("#{base}#{config.separator}#{parsed.quote}#{config.suffix}", config.case)
:suffix_swap ->
apply_case("#{base}#{config.separator}#{parsed.quote}#{config.suffix}", config.case)
:suffix_perp ->
apply_case("#{base}#{config.separator}#{parsed.quote}#{config.suffix}", config.case)
end
end
@doc false
# Applies future patterns: future_yymmdd, future_ddmmmyy, future_yyyymmdd, future_unknown
defp apply_future_pattern(base, parsed, config) do
case config.pattern do
:future_yymmdd ->
apply_case("#{base}#{config.separator}#{parsed.quote}#{config.separator}#{parsed.expiry}", config.case)
:future_ddmmmyy ->
apply_future_ddmmmyy(base, parsed, config)
:future_yyyymmdd ->
expiry = convert_date(parsed.expiry, :yymmdd, :yyyymmdd)
apply_case("#{base}#{config.separator}#{parsed.quote}#{config.separator}#{expiry}", config.case)
:future_unknown ->
apply_case("#{base}#{config.separator}#{parsed.quote}#{config.separator}#{parsed.expiry}", config.case)
end
end
@doc false
# Handles DDMMMYY future format with Deribit vs Bybit style detection
defp apply_future_ddmmmyy(base, parsed, config) do
expiry_converted = convert_date(parsed.expiry, :yymmdd, :ddmmmyy)
sep = config.separator
# Deribit style: BTC-16JAN26 (just base-date, no quote when USD)
# Bybit style: BTCUSDT-16JAN26 (base+quote-date)
if sep == "-" and parsed.quote == "USD" do
apply_case("#{base}-#{expiry_converted}", config.case)
else
apply_case("#{base}#{parsed.quote}-#{expiry_converted}", config.case)
end
end
@doc false
# Applies option patterns: option_ddmmmyy, option_yymmdd, option_with_settle, option_unknown
defp apply_option_pattern(base, parsed, config) do
case config.pattern do
:option_ddmmmyy ->
expiry = convert_date(parsed.expiry, :yymmdd, :ddmmmyy)
apply_case("#{base}-#{expiry}-#{parsed.strike}-#{parsed.option_type}", config.case)
:option_yymmdd ->
apply_case("#{base}-#{parsed.quote}-#{parsed.expiry}-#{parsed.strike}-#{parsed.option_type}", config.case)
:option_with_settle ->
expiry = convert_date(parsed.expiry, :yymmdd, :ddmmmyy)
apply_case("#{base}-#{expiry}-#{parsed.strike}-#{parsed.option_type}-#{parsed.settle}", config.case)
:option_unknown ->
apply_case("#{base}-#{parsed.expiry}-#{parsed.strike}-#{parsed.option_type}", config.case)
end
end
# ============================================================================
# Private: Reverse Pattern (exchange -> unified)
# ============================================================================
@doc false
# Main dispatcher for exchange ID → unified symbol conversion
defp reverse_pattern(exchange_id, config, market_type, spec) do
aliases = Map.get(spec, :currency_aliases, %{})
case market_type do
:spot -> reverse_spot(exchange_id, config, aliases)
:swap -> reverse_swap(exchange_id, config, aliases)
:future -> reverse_future(exchange_id, config, aliases)
:option -> reverse_option(exchange_id, config, aliases)
_ -> normalize(exchange_id, spec)
end
end
@doc false
# Converts spot exchange ID to unified: "BTCUSDT" → "BTC/USDT"
defp reverse_spot(exchange_id, config, aliases) do
# Normalize case for parsing
id = String.upcase(exchange_id)
sep = config.separator
{base, quote_currency} =
if sep == "" do
# No separator - need to find where base ends
split_no_separator(id)
else
case String.split(id, sep) do
[b, q] -> {b, q}
_ -> {id, ""}
end
end
base = apply_reverse_alias(base, aliases)
build(base, quote_currency)
end
@doc false
# Converts swap exchange ID to unified: "BTC_USD-PERPETUAL" → "BTC/USD:BTC"
defp reverse_swap(exchange_id, config, aliases) do
id = String.upcase(exchange_id)
# Remove suffix if present
id_without_suffix =
case config.suffix do
nil -> id
suffix -> String.replace_suffix(id, String.upcase(suffix), "")
end
# Split base/quote
sep = config.separator
{base, quote_currency} =
if sep == "" do
split_no_separator(id_without_suffix)
else
case String.split(id_without_suffix, sep) do
[b, q] -> {b, q}
[b] -> {b, "USD"}
_ -> {id_without_suffix, ""}
end
end
base = apply_reverse_alias(base, aliases)
# For swaps, settle usually equals quote (linear) or base (inverse)
# Use quote as default settle
settle = if quote_currency in ["USD", "USDC"], do: base, else: quote_currency
build(base, quote_currency, settle)
end
@doc false
# Converts future exchange ID to unified, dispatching by date format
defp reverse_future(exchange_id, config, aliases) do
id = String.upcase(exchange_id)
case config.date_format do
:ddmmmyy -> reverse_future_ddmmmyy(id, exchange_id, config, aliases)
:yymmdd -> reverse_future_yymmdd(id, exchange_id, config, aliases)
_ -> normalize(exchange_id, %{separator: config.separator, case: config.case})
end
end
@doc false
# Handles DDMMMYY future format: "BTC-16JAN26" (Deribit) or "BTCUSDT-16JAN26" (Bybit)
# Try Bybit first (more specific pattern with quote currency) before Deribit
defp reverse_future_ddmmmyy(id, exchange_id, config, aliases) do
bybit_result = parse_bybit_future(id, aliases)
deribit_result = parse_deribit_future(id, aliases)
cond do
bybit_result != nil -> bybit_result
deribit_result != nil -> deribit_result
true -> normalize(exchange_id, %{separator: config.separator, case: config.case})
end
end
@doc false
# Parses Deribit-style future: BTC-16JAN26
defp parse_deribit_future(id, aliases) do
case Regex.run(~r/^([A-Z]+)-(\d{1,2}[A-Z]{3}\d{2})$/, id) do
[_, base, date] ->
base = apply_reverse_alias(base, aliases)
expiry = convert_date(date, :ddmmmyy, :yymmdd)
build(base, "USD", "#{base}-#{expiry}")
_ ->
nil
end
end
@doc false
# Parses Bybit-style future: BTCUSDT-16JAN26
defp parse_bybit_future(id, aliases) do
case Regex.run(~r/^([A-Z]+)(USDT|USDC|USD)-(\d{1,2}[A-Z]{3}\d{2})$/, id) do
[_, base, quote_currency, date] ->
base = apply_reverse_alias(base, aliases)
expiry = convert_date(date, :ddmmmyy, :yymmdd)
build(base, quote_currency, "#{quote_currency}-#{expiry}")
_ ->
nil
end
end
@doc false
# Handles YYMMDD future format: "BTCUSDT_260327" (Binance) or "BTC-USD-260327" (OKX)
defp reverse_future_yymmdd(id, exchange_id, config, aliases) do
sep = config.separator
parts = String.split(id, sep)
case parts do
[pair, date] when sep == "_" ->
{base, quote_currency} = split_no_separator(pair)
base = apply_reverse_alias(base, aliases)
build(base, quote_currency, "#{quote_currency}-#{date}")
[base, quote_currency, date] ->
base = apply_reverse_alias(base, aliases)
settle = if quote_currency in ["USD"], do: base, else: quote_currency
build(base, quote_currency, "#{settle}-#{date}")
_ ->
normalize(exchange_id, %{separator: config.separator, case: config.case})
end
end
@doc false
# Converts option exchange ID to unified, dispatching by pattern type
defp reverse_option(exchange_id, config, aliases) do
id = String.upcase(exchange_id)
case config.pattern do
:option_ddmmmyy -> reverse_option_ddmmmyy(id, exchange_id, aliases)
:option_yymmdd -> reverse_option_yymmdd(id, exchange_id, aliases)
:option_with_settle -> reverse_option_with_settle(id, exchange_id, aliases)
_ -> exchange_id
end
end
@doc false
# Parses Deribit-style option: BTC-12JAN26-84000-C
defp reverse_option_ddmmmyy(id, exchange_id, aliases) do
case Regex.run(~r/^([A-Z]+)-(\d{1,2}[A-Z]{3}\d{2})-(\d+)-([CP])$/, id) do
[_, base, date, strike, opt_type] ->
base = apply_reverse_alias(base, aliases)
expiry = convert_date(date, :ddmmmyy, :yymmdd)
build(base, "USD", "#{base}-#{expiry}-#{strike}-#{opt_type}")
_ ->
exchange_id
end
end
@doc false
# Parses OKX-style option: BTC-USD-260112-80000-C
defp reverse_option_yymmdd(id, exchange_id, aliases) do
case Regex.run(~r/^([A-Z]+)-([A-Z]+)-(\d{6})-(\d+)-([CP])$/, id) do
[_, base, quote_currency, date, strike, opt_type] ->
base = apply_reverse_alias(base, aliases)
settle = if quote_currency in ["USD"], do: base, else: quote_currency
build(base, quote_currency, "#{settle}-#{date}-#{strike}-#{opt_type}")
_ ->
exchange_id
end
end
@doc false
# Parses Bybit-style option: BTC-25DEC26-105000-P-USDT
defp reverse_option_with_settle(id, exchange_id, aliases) do
case Regex.run(~r/^([A-Z]+)-(\d{1,2}[A-Z]{3}\d{2})-(\d+)-([CP])-([A-Z]+)$/, id) do
[_, base, date, strike, opt_type, settle] ->
base = apply_reverse_alias(base, aliases)
expiry = convert_date(date, :ddmmmyy, :yymmdd)
build(base, settle, "#{settle}-#{expiry}-#{strike}-#{opt_type}")
_ ->
exchange_id
end
end
# ============================================================================
# Private: Helpers
# ============================================================================
@doc false
# Applies case transformation (:upper, :lower, or pass-through for :mixed)
defp apply_case(str, :upper), do: String.upcase(str)
defp apply_case(str, :lower), do: String.downcase(str)
defp apply_case(str, _), do: str
@doc false
# Pads integer to 2 digits with leading zero (e.g., 9 → "09")
defp pad_two(n) when n < 10, do: "0#{n}"
defp pad_two(n), do: "#{n}"
@doc false
# Splits a no-separator symbol (e.g., "BTCUSDT") into {base, quote} tuple.
# Searches for known quote currencies at the end of the symbol, trying longest
# matches first (USDT before USD) to handle overlapping currencies correctly.
# Returns {symbol, ""} if no known quote currency is found.
defp split_no_separator(symbol) do
# Sort by length descending to match longest first (USDT before USD)
sorted_quotes = Enum.sort_by(@default_quote_currencies, &String.length/1, :desc)
case Enum.find(sorted_quotes, &String.ends_with?(symbol, &1)) do
nil -> {symbol, ""}
quote_currency -> {String.replace_suffix(symbol, quote_currency, ""), quote_currency}
end
end
@doc """
Gets quote currencies from spec if available, falling back to defaults.
Specs can provide `known_quote_currencies` to support exchange-specific
quote currencies that aren't in the default list.
"""
@spec get_quote_currencies(map()) :: [String.t()]
def get_quote_currencies(spec) do
case Map.get(spec, :known_quote_currencies) do
nil -> @default_quote_currencies
[] -> @default_quote_currencies
currencies when is_list(currencies) -> currencies
end
end
@doc false
# Applies currency alias for forward conversion (unified → exchange, e.g., BTC → XBT)
defp apply_forward_alias(currency, aliases) when map_size(aliases) == 0, do: currency
defp apply_forward_alias(currency, aliases) do
Map.get(aliases, currency, currency)
end
@doc false
# Applies currency alias for reverse conversion (exchange → unified, e.g., XBT → BTC)
defp apply_reverse_alias(currency, aliases) when map_size(aliases) == 0, do: currency
defp apply_reverse_alias(currency, aliases) do
# Reverse the aliases map
reverse = Map.new(aliases, fn {unified, exchange} -> {exchange, unified} end)
Map.get(reverse, currency, currency)
end
# ============================================================================
# KRAKEN-SPECIFIC HANDLING (R7)
# ============================================================================
@doc """
Normalizes a Kraken-style symbol with X/Z prefixes.
Kraken uses X prefix for crypto (XXBT for BTC) and Z for fiat (ZUSD for USD).
This function handles these prefixes during normalization.
## Examples
CCXT.Symbol.normalize_kraken("XXBTZUSD", kraken_spec)
# => "BTC/USD"
CCXT.Symbol.normalize_kraken("XETHZEUR", kraken_spec)
# => "ETH/EUR"
"""
@spec normalize_kraken(String.t(), map()) :: String.t()
def normalize_kraken(symbol, spec) do
aliases = Map.get(spec, :currency_aliases, %{})
# Strip X/Z prefixes from currencies
symbol_clean = strip_kraken_prefixes(symbol)
# Apply aliases (XBT → BTC)
reverse_aliases = Map.new(aliases, fn {unified, exchange} -> {exchange, unified} end)
symbol_aliased = apply_currency_aliases(symbol_clean, reverse_aliases)
# Now find and split
find_and_split(symbol_aliased)
end
@doc false
# Strips Kraken X/Z prefixes: XXBTZUSD → XBT/USD, XETHZEUR → ETH/EUR
# Kraken convention: X prefix for crypto (XETH, XXBT), Z prefix for fiat (ZUSD, ZEUR)
defp strip_kraken_prefixes(symbol) do
symbol
|> String.upcase()
|> split_on_z_fiat()
end
@doc false
# Splits on Z-prefixed fiat, then strips X prefix from base
# XETHZEUR → {XETH, EUR} → ETH/EUR
# XXBTZUSD → {XXBT, USD} → XBT/USD (double X becomes single X)
defp split_on_z_fiat(symbol) do
case Regex.run(~r/^(.+)Z(USD|EUR|GBP|JPY|CAD|AUD|CHF)$/, symbol) do
[_, base, fiat] ->
base_clean = strip_x_prefix(base)
"#{base_clean}/#{fiat}"
_ ->
symbol
end
end
@doc false
# Strips X prefix from crypto: XETH → ETH, XXBT → XBT (keep one X for XBT alias)
defp strip_x_prefix("XX" <> rest), do: "X" <> rest
defp strip_x_prefix("X" <> rest), do: rest
defp strip_x_prefix(other), do: other
end