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(<>, :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/(? :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