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, uppercase)
- Coinbase: "BTC-USD" (dash separator)
- Gate.io: "BTC_USDT" (underscore separator)
- Bitstamp: "btcusd" (lowercase, no separator)
- Derivatives: "BTC/USDT:USDT" (with settle currency)
- Kraken: "XXBTZUSD" (X/Z prefixes for currencies)
- KrakenFutures: "PI_XBTUSD" (contract type prefixes)
## Format-Based Conversion
Use with a format map for simple normalization:
format = %{separator: "-", case: :upper}
CCXT.Symbol.normalize("BTC-USD", format)
#=> "BTC/USD"
CCXT.Symbol.denormalize("BTC/USD", format)
#=> "BTC-USD"
## Parsing and Building
Parse unified symbols into components:
CCXT.Symbol.parse("BTC/USDT:USDT")
#=> {:ok, %{base: "BTC", quote: "USDT", settle: "USDT"}}
CCXT.Symbol.build("BTC", "USDT", "USDT")
#=> "BTC/USDT:USDT"
## Currency Aliases
Apply exchange-specific currency code mappings:
aliases = %{"XBT" => "BTC", "XXRP" => "XRP"}
CCXT.Symbol.apply_alias("XBT", aliases)
#=> "BTC"
"""
alias CCXT.Symbol.Error, as: SymbolError
# Default quote currencies for no-separator splitting (longest-match-first)
@default_quote_currencies ~w(USDT USDC USD EUR GBP JPY BTC ETH BUSD TUSD DAI USDD FDUSD)
@sorted_quote_currencies Enum.sort_by(@default_quote_currencies, &String.length/1, :desc)
# KrakenFutures contract prefixes
@known_contract_prefixes ["PI_", "PF_", "FI_", "FF_", "PV_"]
# Month abbreviations for date conversion
@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 symbol_format :: %{separator: String.t(), case: :upper | :lower | :mixed}
@type parsed_symbol :: %{base: String.t(), quote: String.t(), settle: String.t() | nil}
@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 ws_symbol_format ::
:dash_separated | :lowercase_no_slash | :uppercase_no_slash | :slash | :unknown
@type pattern_config :: %{
pattern: atom(),
separator: String.t(),
case: :upper | :lower | :mixed,
date_format: :yymmdd | :ddmmmyy | :yyyymmdd | nil,
suffix: String.t() | nil,
prefix: String.t() | nil
}
# Pattern categories for dispatch
@spot_patterns ~w(no_separator_upper no_separator_lower no_separator_mixed
underscore_upper underscore_lower underscore_mixed
dash_upper dash_lower dash_mixed
colon_upper colon_lower colon_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
# ===========================================================================
# Normalization (Exchange → Unified)
# ===========================================================================
@doc """
Converts an exchange-specific symbol to unified format.
Takes a format map with `:separator` and `:case` keys. Optionally applies
currency aliases (from `commonCurrencies` spec data) to map exchange-specific
codes to unified codes.
## Parameters
- `symbol` - The exchange-specific symbol (e.g., "BTCUSDT")
- `format` - Map with `:separator` and `:case` keys
- `opts` - Keyword options:
- `:aliases` - Currency alias map (e.g., `%{"XBT" => "BTC"}`)
- `:quote_currencies` - Custom list of known quote currencies for no-separator splitting
## Returns
The unified symbol (e.g., "BTC/USDT"), or original if cannot parse.
"""
@spec normalize(String.t(), symbol_format(), keyword()) :: String.t()
def normalize(symbol, format, opts \\ [])
def normalize(symbol, %{separator: sep, case: sym_case}, opts) when is_binary(symbol) do
aliases = Keyword.get(opts, :aliases, %{})
quote_currencies = Keyword.get(opts, :quote_currencies)
# Apply case normalization to uppercase
symbol = if sym_case == :lower, do: String.upcase(symbol), else: symbol
# Apply currency aliases (exchange code → unified code)
symbol = apply_currency_aliases(symbol, aliases)
# Split by separator
case sep do
"" -> find_and_split(symbol, quote_currencies)
"/" -> symbol
_ -> String.replace(symbol, sep, "/")
end
end
# ===========================================================================
# Denormalization (Unified → Exchange)
# ===========================================================================
@doc """
Converts a unified symbol to exchange-specific format.
Strips settle currency (colon suffix) before conversion, then applies
separator replacement and case transformation.
## Parameters
- `symbol` - The unified symbol (e.g., "BTC/USDT" or "BTC/USDT:USDT")
- `format` - Map with `:separator` and `:case` keys
"""
@spec denormalize(String.t(), symbol_format()) :: String.t()
def denormalize(symbol, %{separator: sep, case: sym_case}) when is_binary(symbol) do
# Strip settle currency (e.g., "BTC/USDT:USDT" → "BTC/USDT")
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)
_mixed -> result
end
end
# ===========================================================================
# WebSocket Symbol Denormalization
# ===========================================================================
@doc """
Converts a unified symbol to WebSocket channel format.
WebSocket channels often use different symbol formats than REST APIs.
"""
@spec denormalize_ws(String.t(), ws_symbol_format()) :: String.t()
def denormalize_ws(symbol, :dash_separated), do: String.replace(symbol, "/", "-")
def denormalize_ws(symbol, :lowercase_no_slash), do: symbol |> String.replace("/", "") |> String.downcase()
def denormalize_ws(symbol, :uppercase_no_slash), do: String.replace(symbol, "/", "")
def denormalize_ws(symbol, :slash), do: symbol
def denormalize_ws(symbol, _unknown), do: String.replace(symbol, "/", "")
# ===========================================================================
# Parsing
# ===========================================================================
@doc """
Parses a unified symbol into its components.
## Returns
`{:ok, %{base, quote, settle}}` or `{:error, :invalid_format}`.
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
@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 SymbolError.invalid_format(symbol)
end
end
@doc """
Parses a unified symbol into extended components including derivative fields.
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
case String.split(symbol, ":") do
[pair] -> parse_extended_pair(pair, nil)
[pair, settle_and_rest] -> parse_extended_pair(pair, settle_and_rest)
_ -> {:error, :invalid_format}
end
end
# ===========================================================================
# Building
# ===========================================================================
@doc """
Builds a unified symbol from components.
CCXT.Symbol.build("BTC", "USDT")
#=> "BTC/USDT"
CCXT.Symbol.build("BTC", "USD", "BTC")
#=> "BTC/USD:BTC"
"""
@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}"
# ===========================================================================
# Currency Aliases
# ===========================================================================
@doc """
Applies a currency alias mapping to a single currency code.
Used with `commonCurrencies` data from exchange specs.
CCXT.Symbol.apply_alias("XBT", %{"XBT" => "BTC"})
#=> "BTC"
CCXT.Symbol.apply_alias("ETH", %{"XBT" => "BTC"})
#=> "ETH"
"""
@spec apply_alias(String.t(), map()) :: String.t()
def apply_alias(currency, aliases) when is_binary(currency) and is_map(aliases) do
Map.get(aliases, currency, currency)
end
@doc """
Inverts an alias map for reverse lookups (unified → exchange code).
CCXT.Symbol.reverse_aliases(%{"XBT" => "BTC", "ZEUR" => "EUR"})
#=> %{"BTC" => "XBT", "EUR" => "ZEUR"}
"""
@spec reverse_aliases(map()) :: map()
def reverse_aliases(aliases) when is_map(aliases) do
Map.new(aliases, fn {k, v} -> {v, k} end)
end
# ===========================================================================
# Prefix Handling
# ===========================================================================
@doc """
Strips known exchange prefixes from a symbol.
Handles KrakenFutures contract prefixes (PI_, PF_, FI_, FF_, PV_) and
Kraken currency prefixes (X for crypto, Z for fiat).
CCXT.Symbol.strip_prefix("PI_XBTUSD")
#=> {"PI_", "XBTUSD"}
CCXT.Symbol.strip_prefix("XXBT")
#=> {"X", "XBT"}
CCXT.Symbol.strip_prefix("ZUSD")
#=> {"Z", "USD"}
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
case find_matching_prefix(symbol, @known_contract_prefixes) do
{_prefix, _rest} = result -> result
nil -> strip_currency_prefix(symbol)
end
end
# ===========================================================================
# Quote Currency Detection
# ===========================================================================
@doc """
Returns the list of known quote currencies, optionally extended with
exchange-specific currencies.
Currencies are sorted by length descending for longest-match-first splitting.
CCXT.Symbol.get_quote_currencies()
#=> ["FDUSD", "USDD", "USDT", "USDC", "BUSD", "TUSD", ...]
CCXT.Symbol.get_quote_currencies(["TRY", "BRL"])
#=> ["FDUSD", "USDD", "USDT", ..., "TRY", "BRL"]
"""
@spec get_quote_currencies(list(String.t()) | nil) :: list(String.t())
def get_quote_currencies(extra \\ nil)
def get_quote_currencies(nil), do: @sorted_quote_currencies
def get_quote_currencies(extra) when is_list(extra) do
(@default_quote_currencies ++ extra)
|> Enum.uniq()
|> Enum.sort_by(&String.length/1, :desc)
end
# ===========================================================================
# Date Conversion
# ===========================================================================
@doc """
Converts dates between derivative symbol formats.
Supported formats: `:yymmdd`, `:ddmmmyy`, `:yyyymmdd`.
CCXT.Symbol.convert_date("260327", :yymmdd, :ddmmmyy)
#=> "27MAR26"
CCXT.Symbol.convert_date("27MAR26", :ddmmmyy, :yymmdd)
#=> "260327"
CCXT.Symbol.convert_date("260327", :yymmdd, :yyyymmdd)
#=> "20260327"
"""
@spec convert_date(String.t(), atom(), atom()) :: String.t()
def convert_date(date_str, format, format), do: date_str
def convert_date(<<yy::binary-2, mm::binary-2, dd::binary-2>>, :yymmdd, :ddmmmyy) do
month = String.to_integer(mm)
day = String.to_integer(dd)
"#{day}#{Map.fetch!(@month_abbrevs, month)}#{yy}"
end
def convert_date(date_str, :ddmmmyy, :yymmdd) do
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
def convert_date(<<_century::binary-2, rest::binary>>, :yyyymmdd, :yymmdd), do: rest
def convert_date(date_str, :yymmdd, :yyyymmdd), do: "20#{date_str}"
def convert_date(date_str, :yyyymmdd, :ddmmmyy) do
date_str |> convert_date(:yyyymmdd, :yymmdd) |> convert_date(:yymmdd, :ddmmmyy)
end
def convert_date(date_str, :ddmmmyy, :yyyymmdd) do
date_str |> convert_date(:ddmmmyy, :yymmdd) |> convert_date(:yymmdd, :yyyymmdd)
end
# ===========================================================================
# Market Type Detection
# ===========================================================================
@doc """
Detects market type from parsed extended symbol components.
Priority: option > future > swap > spot.
"""
@spec detect_market_type(parsed_extended()) :: :spot | :swap | :future | :option
def 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: Pair Parsing
# ===========================================================================
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
# ===========================================================================
# Private: Extended Parsing
# ===========================================================================
# Simple pair like "BTC/USDT" (no derivative suffix)
defp parse_extended_pair(pair, nil) do
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
# Pair with derivative suffix like "BTC/USDT" + "USDT-260327"
defp parse_extended_pair(pair, settle_and_rest) do
case String.split(pair, "/") do
[base, quote_currency] when base != "" and quote_currency != "" ->
parse_derivative_suffix(base, quote_currency, settle_and_rest)
_ ->
{:error, :invalid_format}
end
end
# Parses "USDT" or "USDT-260327" or "BTC-260112-84000-C"
defp parse_derivative_suffix(base, quote_currency, settle_and_rest) do
parts = String.split(settle_and_rest, "-")
case parts do
[settle] ->
{:ok, %{base: base, quote: quote_currency, settle: settle, expiry: nil, strike: nil, option_type: nil}}
[settle, expiry] ->
{:ok, %{base: base, quote: quote_currency, settle: settle, expiry: expiry, strike: nil, option_type: nil}}
[settle, expiry, strike, option_type] ->
{:ok,
%{base: base, quote: quote_currency, settle: settle, expiry: expiry, strike: strike, option_type: option_type}}
_ ->
{:error, :invalid_format}
end
end
# ===========================================================================
# Private: Currency Alias Application
# ===========================================================================
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
# ===========================================================================
# Private: No-Separator Splitting
# ===========================================================================
# Splits "BTCUSDT" into "BTC/USDT" by finding known quote currency at end
defp find_and_split(symbol, custom_currencies) when is_binary(symbol) do
currencies = if custom_currencies, do: get_quote_currencies(custom_currencies), else: @sorted_quote_currencies
if String.contains?(symbol, "/"), do: symbol, else: split_by_quote(symbol, currencies)
end
defp split_by_quote(symbol, currencies) do
case Enum.find(currencies, &String.ends_with?(symbol, &1)) do
nil -> symbol
quote_currency -> build_split(symbol, quote_currency)
end
end
defp build_split(symbol, quote_currency) do
base = String.replace_suffix(symbol, quote_currency, "")
if base == "", do: symbol, else: "#{base}/#{quote_currency}"
end
# ===========================================================================
# Private: Prefix Handling
# ===========================================================================
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
# Kraken X/Z currency prefixes
defp strip_currency_prefix(symbol) do
cond do
# XXBT → X + XBT (doubled X prefix for crypto)
String.starts_with?(symbol, "XX") ->
{"X", String.slice(symbol, 1..-1//1)}
# ZUSD → Z + USD (fiat prefix, only for 4-char codes)
String.starts_with?(symbol, "Z") and String.length(symbol) == 4 ->
{"Z", String.slice(symbol, 1..-1//1)}
true ->
{nil, symbol}
end
end
# ===========================================================================
# Pattern Classification (spec JSON → pattern atoms)
# ===========================================================================
@doc false
@spec classify_pattern(atom(), map()) :: map() | nil
def classify_pattern(_market_type, nil), do: nil
# TODO: Opaque patterns (e.g., dYdX "MOG-USD" → "MOG/USDC:USDC") require a
# lookup table from loadMarkets(), not mechanical conversion. Return nil so
# callers know conversion isn't available for this market type.
def classify_pattern(_market_type, %{"id_structure" => "opaque"}), do: nil
def classify_pattern(:spot, entry) do
sep = entry["separator"] || ""
sym_case = parse_case(entry["case"])
pattern = spot_pattern_atom(sep, sym_case)
%{
pattern: pattern,
separator: sep,
case: sym_case,
date_format: nil,
suffix: nil,
prefix: entry["prefix"]
}
end
def classify_pattern(:swap, entry) do
sep = entry["separator"] || ""
sym_case = parse_case(entry["case"])
suffix = entry["suffix"]
pattern = swap_pattern_atom(suffix)
%{
pattern: pattern,
separator: sep,
case: sym_case,
date_format: nil,
suffix: suffix,
prefix: entry["prefix"]
}
end
def classify_pattern(:future, entry) do
sep = entry["separator"] || ""
sym_case = parse_case(entry["case"])
date_format = detect_date_format_from_examples(entry["examples"])
pattern = future_pattern_atom(date_format)
%{
pattern: pattern,
separator: sep,
case: sym_case,
date_format: date_format,
suffix: entry["suffix"],
prefix: entry["prefix"]
}
end
def classify_pattern(:option, entry) do
sep = entry["separator"] || ""
sym_case = parse_case(entry["case"])
{pattern, date_format} = detect_option_pattern(entry["examples"])
%{
pattern: pattern,
separator: sep,
case: sym_case,
date_format: date_format,
suffix: entry["suffix"],
prefix: entry["prefix"]
}
end
def classify_pattern(_unknown, _entry), do: nil
# ===========================================================================
# Exchange ID Conversion (unified ↔ exchange)
# ===========================================================================
@doc """
Converts a unified symbol to exchange-specific ID.
Uses pattern configs from the exchange's `symbol_patterns` to handle
spot, swap, future, and option symbols. Falls back to `denormalize/2`
when no pattern config exists for the detected market type.
Outbound currency aliases come from `exchange.outbound_aliases` (default
`%{}`), populated only for exchanges that accept the alias on input
(e.g. Kraken: `BTC` → `XBT`). Inbound aliasing (`from_exchange_id`) uses
`common_currencies` directly — that direction is universal.
## Parameters
- `unified_symbol` - The unified symbol (e.g., "BTC/USDT:USDT-260327")
- `exchange` - A `%CCXT.Exchange{}` struct with `symbol_patterns` populated
## Examples
CCXT.Symbol.to_exchange_id("BTC/USDT", binance_exchange)
#=> "BTCUSDT"
CCXT.Symbol.to_exchange_id("BTC/USD:BTC-260112-84000-C", deribit_exchange)
#=> "BTC-12JAN26-84000-C"
"""
@spec to_exchange_id(String.t(), CCXT.Exchange.t()) :: String.t()
def to_exchange_id(unified_symbol, %CCXT.Exchange{} = exchange) when is_binary(unified_symbol) do
case parse_extended(unified_symbol) do
{:ok, parsed} ->
market_type = detect_market_type(parsed)
config = Map.get(exchange.symbol_patterns, market_type)
if config do
apply_pattern(parsed, config, exchange.outbound_aliases)
else
# No pattern config for this market type — return unchanged rather than
# applying lossy spot conversion that strips derivative suffixes
unified_symbol
end
{:error, _} ->
unified_symbol
end
end
@doc """
Converts a unified symbol to exchange-specific ID, raising on failure.
"""
@spec to_exchange_id!(String.t(), CCXT.Exchange.t()) :: String.t()
def to_exchange_id!(unified_symbol, %CCXT.Exchange{} = exchange) when is_binary(unified_symbol) do
case parse_extended(unified_symbol) do
{:ok, parsed} ->
market_type = detect_market_type(parsed)
config = Map.get(exchange.symbol_patterns, market_type)
if config do
apply_pattern(parsed, config, exchange.outbound_aliases)
else
raise SymbolError.pattern_not_found(unified_symbol, market_type, exchange.id)
end
{:error, _} ->
raise SymbolError.invalid_format(unified_symbol)
end
end
@doc """
Converts an exchange-specific ID to unified symbol format.
Requires `market_type` since exchange IDs are ambiguous without context.
## Parameters
- `exchange_id` - The exchange-specific ID (e.g., "BTCUSDT_260327")
- `exchange` - A `%CCXT.Exchange{}` struct with `symbol_patterns` populated
- `market_type` - The market type (`:spot`, `:swap`, `:future`, `:option`)
## Examples
CCXT.Symbol.from_exchange_id("BTCUSDT", binance_exchange, :spot)
#=> "BTC/USDT"
CCXT.Symbol.from_exchange_id("BTC-PERPETUAL", deribit_exchange, :swap)
#=> "BTC/USD:BTC"
CCXT.Symbol.from_exchange_id("BTC-12JAN26-84000-C", deribit_exchange, :option)
#=> "BTC/USD:BTC-260112-84000-C"
"""
@spec from_exchange_id(String.t(), CCXT.Exchange.t(), atom()) :: String.t()
def from_exchange_id(exchange_id, %CCXT.Exchange{} = exchange, market_type)
when is_binary(exchange_id) and is_atom(market_type) do
config = Map.get(exchange.symbol_patterns, market_type)
if config do
reverse_pattern(exchange_id, config, market_type, exchange.common_currencies)
else
# No pattern config for this market type — return unchanged rather than
# applying lossy spot conversion that strips derivative context
exchange_id
end
end
@doc """
Converts an exchange-specific ID to unified symbol, raising on failure.
"""
@spec from_exchange_id!(String.t(), CCXT.Exchange.t(), atom()) :: String.t()
def from_exchange_id!(exchange_id, %CCXT.Exchange{} = exchange, market_type)
when is_binary(exchange_id) and is_atom(market_type) do
config = Map.get(exchange.symbol_patterns, market_type)
if config do
reverse_pattern(exchange_id, config, market_type, exchange.common_currencies)
else
raise SymbolError.pattern_not_found(exchange_id, market_type, exchange.id)
end
end
# ===========================================================================
# Private: Pattern Classification Helpers
# ===========================================================================
defp parse_case("upper"), do: :upper
defp parse_case("lower"), do: :lower
defp parse_case("mixed"), do: :mixed
defp parse_case(_), do: :upper
# Spot: separator + case → pattern atom
defp spot_pattern_atom("", :upper), do: :no_separator_upper
defp spot_pattern_atom("", :lower), do: :no_separator_lower
defp spot_pattern_atom("", :mixed), do: :no_separator_mixed
defp spot_pattern_atom("-", :upper), do: :dash_upper
defp spot_pattern_atom("-", :lower), do: :dash_lower
defp spot_pattern_atom("-", :mixed), do: :dash_mixed
defp spot_pattern_atom("_", :upper), do: :underscore_upper
defp spot_pattern_atom("_", :lower), do: :underscore_lower
defp spot_pattern_atom("_", :mixed), do: :underscore_mixed
defp spot_pattern_atom(":", :upper), do: :colon_upper
defp spot_pattern_atom(":", :lower), do: :colon_lower
defp spot_pattern_atom(":", :mixed), do: :colon_mixed
defp spot_pattern_atom(_sep, :upper), do: :no_separator_upper
defp spot_pattern_atom(_sep, :lower), do: :no_separator_lower
defp spot_pattern_atom(_sep, _mixed), do: :no_separator_mixed
# Swap: suffix → pattern atom
defp swap_pattern_atom(nil), do: :implicit
defp swap_pattern_atom(suffix) when is_binary(suffix) do
suffix_up = String.upcase(suffix)
cond do
String.contains?(suffix_up, "PERPETUAL") -> :suffix_perpetual
String.contains?(suffix_up, "SWAP") -> :suffix_swap
String.contains?(suffix_up, "PERP") -> :suffix_perp
true -> :implicit
end
end
# Future: date format → pattern atom
defp future_pattern_atom(:yymmdd), do: :future_yymmdd
defp future_pattern_atom(:ddmmmyy), do: :future_ddmmmyy
defp future_pattern_atom(:yyyymmdd), do: :future_yyyymmdd
defp future_pattern_atom(_), do: :future_unknown
# Detect date format from example exchange IDs
defp detect_date_format_from_examples(nil), do: nil
defp detect_date_format_from_examples([]), do: nil
defp detect_date_format_from_examples([example | _]) do
id = example["id"] || ""
detect_date_in_id(id)
end
# Look for date patterns in exchange ID
defp detect_date_in_id(id) do
cond do
# DDMMMYY: 16JAN26, 9MAR26 (1-2 digit day + 3-char month + 2-digit year)
Regex.match?(~r/\d{1,2}[A-Z]{3}\d{2}/, id) -> :ddmmmyy
# YYYYMMDD: 20260327 (8 digits starting with 20)
Regex.match?(~r/20\d{6}/, id) -> :yyyymmdd
# YYMMDD: 260327 (6 digits, but check it's not part of a longer number)
Regex.match?(~r/(?<!\d)\d{6}(?!\d)/, id) -> :yymmdd
true -> nil
end
end
# Detect option pattern from examples
defp detect_option_pattern(nil), do: {:option_unknown, nil}
defp detect_option_pattern([]), do: {:option_unknown, nil}
defp detect_option_pattern([example | _]) do
id = example["id"] || ""
cond do
# Bybit: BTC-25DEC26-105000-P-USDT (5 dash-separated parts, settle at end)
match?([_, _, _, _, _], String.split(id, "-")) and
Regex.match?(~r/^[A-Z]+-\d{1,2}[A-Z]{3}\d{2}-\d+-[CP]-[A-Z]+$/, id) ->
{:option_with_settle, :ddmmmyy}
# Deribit: BTC-12JAN26-84000-C (4 parts with DDMMMYY date)
Regex.match?(~r/^[A-Z]+-\d{1,2}[A-Z]{3}\d{2}-\d+-[CP]$/, id) ->
{:option_ddmmmyy, :ddmmmyy}
# OKX: BTC-USD-260112-80000-C (5 parts with YYMMDD date)
Regex.match?(~r/^[A-Z]+-[A-Z]+-\d{6}-\d+-[CP]$/, id) ->
{:option_yymmdd, :yymmdd}
true ->
{:option_unknown, nil}
end
end
# ===========================================================================
# Private: Apply Pattern (unified → exchange)
# ===========================================================================
# Dispatches to market-type-specific pattern application
defp apply_pattern(parsed, config, forward_aliases) do
base = apply_forward_alias(parsed.base, forward_aliases)
quote_aliased = apply_forward_alias(parsed.quote, forward_aliases)
parsed = %{parsed | base: base, quote: quote_aliased}
pattern = config.pattern
cond do
pattern in @spot_patterns -> apply_spot_pattern(parsed, config)
pattern in @swap_patterns -> apply_swap_pattern(parsed, config)
pattern in @future_patterns -> apply_future_pattern(parsed, config)
pattern in @option_patterns -> apply_option_pattern(parsed, config)
true -> "#{base}#{config.separator}#{parsed.quote}"
end
end
# Spot: separator + case + optional prefix
defp apply_spot_pattern(parsed, config) do
result = "#{parsed.base}#{config.separator}#{parsed.quote}"
result =
if config.pattern in [:no_separator_mixed, :underscore_mixed, :dash_mixed, :colon_mixed] do
result
else
apply_case(result, config.case)
end
apply_prefix(result, config.prefix)
end
# Swap: suffix handling (implicit, perpetual, swap, perp)
# Deribit-style: omit quote when sep is "-" and quote is "USD" (BTC-PERPETUAL, not BTC-USD-PERPETUAL)
defp apply_swap_pattern(parsed, config) do
result =
case config.pattern do
:implicit ->
apply_case("#{parsed.base}#{config.separator}#{parsed.quote}", config.case)
:suffix_perpetual when config.separator == "-" and parsed.quote == "USD" ->
apply_case("#{parsed.base}#{config.suffix}", config.case)
_suffix_pattern ->
apply_case("#{parsed.base}#{config.separator}#{parsed.quote}#{config.suffix}", config.case)
end
apply_prefix(result, config.prefix)
end
# Future: date format dispatch
# Note: for YYMMDD/YYYYMMDD, pair separator may differ from date separator.
# Binance: BTCUSDT_260327 (pair has no sep, date uses "_")
# OKX: BTC-USD-260327 (all parts use "-")
defp apply_future_pattern(parsed, config) do
result =
case config.pattern do
:future_yymmdd ->
{pair_sep, date_sep} = future_separators(config.separator)
apply_case(
"#{parsed.base}#{pair_sep}#{parsed.quote}#{date_sep}#{parsed.expiry}",
config.case
)
:future_ddmmmyy ->
apply_future_ddmmmyy(parsed, config)
:future_yyyymmdd ->
{pair_sep, date_sep} = future_separators(config.separator)
expiry = convert_date(parsed.expiry, :yymmdd, :yyyymmdd)
apply_case(
"#{parsed.base}#{pair_sep}#{parsed.quote}#{date_sep}#{expiry}",
config.case
)
:future_unknown ->
apply_case(
"#{parsed.base}#{config.separator}#{parsed.quote}#{config.separator}#{parsed.expiry}",
config.case
)
end
apply_prefix(result, config.prefix)
end
# Binance-style: "_" separates pair from date, but pair itself has no separator
# OKX-style: "-" separates all components uniformly
defp future_separators("_"), do: {"", "_"}
defp future_separators(sep), do: {sep, sep}
# DDMMMYY future: Deribit style (BTC-16JAN26) vs Bybit style (BTCUSDT-16JAN26)
defp apply_future_ddmmmyy(parsed, config) do
expiry_converted = convert_date(parsed.expiry, :yymmdd, :ddmmmyy)
sep = config.separator
# Deribit: base-date only when separator is "-" and quote is "USD"
# Bybit: base+quote-date otherwise
if sep == "-" and parsed.quote == "USD" do
apply_case("#{parsed.base}-#{expiry_converted}", config.case)
else
apply_case("#{parsed.base}#{parsed.quote}-#{expiry_converted}", config.case)
end
end
# Option: Deribit/OKX/Bybit formatting
defp apply_option_pattern(parsed, config) do
result =
case config.pattern do
:option_ddmmmyy ->
expiry = convert_date(parsed.expiry, :yymmdd, :ddmmmyy)
apply_case("#{parsed.base}-#{expiry}-#{parsed.strike}-#{parsed.option_type}", config.case)
:option_yymmdd ->
apply_case(
"#{parsed.base}-#{parsed.quote}-#{parsed.expiry}-#{parsed.strike}-#{parsed.option_type}",
config.case
)
:option_with_settle ->
expiry = convert_date(parsed.expiry, :yymmdd, :ddmmmyy)
apply_case(
"#{parsed.base}-#{expiry}-#{parsed.strike}-#{parsed.option_type}-#{parsed.settle}",
config.case
)
:option_unknown ->
apply_case(
"#{parsed.base}-#{parsed.expiry}-#{parsed.strike}-#{parsed.option_type}",
config.case
)
end
apply_prefix(result, config.prefix)
end
# ===========================================================================
# Private: Reverse Pattern (exchange → unified)
# ===========================================================================
# Main dispatcher for exchange ID → unified symbol conversion
defp reverse_pattern(exchange_id, config, market_type, aliases) do
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)
_ -> exchange_id
end
end
# Spot: "BTCUSDT" → "BTC/USDT"
defp reverse_spot(exchange_id, config, aliases) do
id = exchange_id |> strip_pattern_prefix(config) |> String.upcase()
sep = config.separator
{base, quote_currency} =
if sep == "" do
split_no_separator(id, aliases)
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
# Swap: "BTC-PERPETUAL" → "BTC/USD:BTC"
defp reverse_swap(exchange_id, config, aliases) do
id = exchange_id |> strip_pattern_prefix(config) |> String.upcase()
# Remove suffix if present
id_without_suffix =
case config.suffix do
nil -> id
suffix -> String.replace_suffix(id, String.upcase(suffix), "")
end
sep = config.separator
{base, quote_currency} =
if sep == "" do
split_no_separator(id_without_suffix, aliases)
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)
# Inverse perps (USD/USDC quoted) settle in base; linear settle in quote
settle = if quote_currency in ["USD", "USDC"], do: base, else: quote_currency
build(base, quote_currency, settle)
end
# Future: dispatch by date format
defp reverse_future(exchange_id, config, aliases) do
id = exchange_id |> strip_pattern_prefix(config) |> String.upcase()
case config.date_format do
:ddmmmyy -> reverse_future_ddmmmyy(id, config, aliases)
:yymmdd -> reverse_future_yymmdd(id, config, aliases)
:yyyymmdd -> reverse_future_yyyymmdd(id, config, aliases)
_ -> exchange_id
end
end
# DDMMMYY future: try Bybit (BTCUSDT-16JAN26) then Deribit (BTC-16JAN26)
defp reverse_future_ddmmmyy(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 -> id
end
end
# Deribit: BTC-16JAN26 → BTC/USD:BTC-260116
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
# Bybit: BTCUSDT-16JAN26 → BTC/USDT:USDT-260116
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
# YYMMDD future: Binance (BTCUSDT_260327) or OKX (BTC-USD-260327)
defp reverse_future_yymmdd(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, aliases)
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", "USDC"], do: base, else: quote_currency
build(base, quote_currency, "#{settle}-#{date}")
_ ->
id
end
end
# YYYYMMDD future: dates are 8 digits (e.g., "20260327"). The unified format
# uses YYMMDD ("260327"), so convert the trailing date via convert_date/3
# and split identically to the YYMMDD handler.
defp reverse_future_yyyymmdd(id, config, aliases) do
sep = config.separator
parts = String.split(id, sep)
case parts do
[pair, date] when byte_size(date) == 8 ->
yymmdd_date = convert_date(date, :yyyymmdd, :yymmdd)
{base, quote_currency} = split_no_separator(pair, aliases)
base = apply_reverse_alias(base, aliases)
build(base, quote_currency, "#{quote_currency}-#{yymmdd_date}")
[base, quote_currency, date] when byte_size(date) == 8 ->
yymmdd_date = convert_date(date, :yyyymmdd, :yymmdd)
base = apply_reverse_alias(base, aliases)
settle = if quote_currency in ["USD", "USDC"], do: base, else: quote_currency
build(base, quote_currency, "#{settle}-#{yymmdd_date}")
_ ->
id
end
end
# Option: dispatch by pattern type
defp reverse_option(exchange_id, config, aliases) do
id = exchange_id |> strip_pattern_prefix(config) |> String.upcase()
case config.pattern do
:option_ddmmmyy -> reverse_option_ddmmmyy(id, aliases)
:option_yymmdd -> reverse_option_yymmdd(id, aliases)
:option_with_settle -> reverse_option_with_settle(id, aliases)
_ -> exchange_id
end
end
# Deribit: BTC-12JAN26-84000-C → BTC/USD:BTC-260112-84000-C
defp reverse_option_ddmmmyy(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}")
_ ->
id
end
end
# OKX: BTC-USD-260112-80000-C → BTC/USD:BTC-260112-80000-C
defp reverse_option_yymmdd(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}")
_ ->
id
end
end
# Bybit: BTC-25DEC26-105000-P-USDT → BTC/USDT:USDT-261225-105000-P
defp reverse_option_with_settle(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}")
_ ->
id
end
end
# ===========================================================================
# Private: Conversion Helpers
# ===========================================================================
# Case transformation
defp apply_case(str, :upper), do: String.upcase(str)
defp apply_case(str, :lower), do: String.downcase(str)
defp apply_case(str, _), do: str
# Prefix handling
defp apply_prefix(result, nil), do: result
defp apply_prefix(result, prefix), do: prefix <> result
# Strip prefix from exchange ID (case-insensitive match)
defp strip_pattern_prefix(id, config) do
case config[:prefix] do
nil ->
id
prefix ->
if String.starts_with?(String.downcase(id), String.downcase(prefix)) do
String.slice(id, String.length(prefix)..-1//1)
else
id
end
end
end
# Forward alias: unified → exchange (e.g., BTC → XBT)
defp apply_forward_alias(currency, forward) when map_size(forward) == 0, do: currency
defp apply_forward_alias(currency, forward), do: Map.get(forward, currency, currency)
# Reverse alias: 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: Map.get(aliases, currency, currency)
# No-separator splitting with alias-aware best-match selection
defp split_no_separator(symbol, aliases) do
all_matches =
for q <- @sorted_quote_currencies, String.ends_with?(symbol, q) do
{String.replace_suffix(symbol, q, ""), q}
end
case all_matches do
[] -> {symbol, ""}
[single] -> single
multiple -> pick_best_split(multiple, aliases)
end
end
# Prefer the split whose base is a known exchange currency (key in aliases)
defp pick_best_split(matches, aliases) when map_size(aliases) == 0, do: hd(matches)
defp pick_best_split(matches, aliases) do
Enum.find(matches, hd(matches), fn {base, _quote} -> Map.has_key?(aliases, base) end)
end
# ===========================================================================
# Private: Helpers
# ===========================================================================
defp pad_two(n) when n < 10, do: "0#{n}"
defp pad_two(n), do: "#{n}"
end