defmodule CCXT.WS.Config do @moduledoc """ Per-exchange WebSocket configuration. T94 ships WS configuration for the priority-tier universe using the extended shape that routes WS subscription patterns (`CCXT.WS.Subscription`) and WS auth patterns (`CCXT.WS.Auth`). Replaces T92's 3-canary `subscribe_builder: {mod, fun}` with `subscription_pattern` + `auth_pattern` dispatch atoms. **Count:** 13 exchanges — every entry here has a matching `CCXT.Registry` module. T102 (2026-04-19) removed three orphan entries (`bingx`, `bitget`, `mexc`) whose `Registry` lookups raised because no spec file exists for them under the current tier1/tier2/dex scope. Add them back via `mix ccxt_extract.update --exchange ` + a re-register in Registry before reinstating their config here. WS URLs currently live in this consumer-side map because JSON specs don't yet surface a clean `urls.api.ws` field. A follow-up ccxt_extract task will emit `urls.api.ws.{public,private,sandbox_public,sandbox_private}` from the JS AST; when it lands, this module can be replaced by a one-line read from `exchange.spec["urls"]["api"]["ws"]`. (Prior to schema 3.0.0 the raw AST was available under `structure.ws_methods`; T117 dropped that payload to fit the Hex size cap — the upstream WS-URL task supersedes that path.) ## Entry shape %{ public_url: String.t() | nil, # may contain `{hostname}` placeholder; nil = requires REST pre-call public_url_sandbox: String.t() | nil, private_url: String.t() | nil, private_url_sandbox: String.t() | nil, heartbeat: %{type: :ping | :deribit | :custom, interval: pos_integer() | nil}, subscription_pattern: atom(), # routed via CCXT.WS.Subscription subscription_config: map(), auth_pattern: atom() | nil, # routed via CCXT.WS.Auth; nil = public-only wiring auth_config: map() } ## Known URL limitations (MVP — single URL per public/private slot) Several exchanges expose per-product URLs (spot vs linear/inverse swap vs option). The current shape captures one representative URL per `{section, sandbox}` slot; product routing will land in a follow-up task once the adapter layer needs it. - `bybit` — linear-only (`/v5/public/linear`). spot/inverse/option need routing. - `binance` — spot-only. USDⓈ-M futures (`fstream.binance.com`), COIN-M (`dstream.binance.com`), and portfolio margin (`/pm/ws`) need routing. - `htx` / `huobi` — spot-only (`api.huobi.pro/ws`). Contract/swap endpoints live on `api.hbdm.vn`. - `aster` — spot-only. Swap on `fstream.asterdex.com`. - `kucoin` — URLs are fully dynamic (REST `POST /api/v1/bullet-{public,private}` returns the WSS host + token). Recorded as `nil` until the adapter layer wires the pre-call. """ @configs %{ "binance" => %{ public_url: "wss://stream.binance.com:9443/ws", public_url_sandbox: "wss://stream.testnet.binance.vision/ws", private_url: "wss://stream.binance.com:9443/ws", private_url_sandbox: "wss://stream.testnet.binance.vision/ws", heartbeat: %{type: :ping, interval: 180_000}, subscription_pattern: :method_subscribe, subscription_config: %{separator: "@", market_id_format: :lowercase}, auth_pattern: :listen_key, auth_config: %{ pre_auth: %{ type: :listen_key, endpoints: %{ spot: "privatePostUserDataStream", linear: "fapiPrivatePostListenKey", inverse: "dapiPrivatePostListenKey" } } } }, "bybit" => %{ public_url: "wss://stream.{hostname}/v5/public/linear", public_url_sandbox: "wss://stream-testnet.{hostname}/v5/public/linear", private_url: "wss://stream.{hostname}/v5/private", private_url_sandbox: "wss://stream-testnet.{hostname}/v5/private", heartbeat: %{type: :ping, interval: 20_000}, subscription_pattern: :op_subscribe, subscription_config: %{separator: "."}, auth_pattern: :direct_hmac_expiry, auth_config: %{ op_field: "op", op_value: "auth", encoding: :hex, expires_offset_ms: 10_000 } }, "okx" => %{ public_url: "wss://ws.okx.com:8443/ws/v5/public", public_url_sandbox: "wss://wspap.okx.com:8443/ws/v5/public", private_url: "wss://ws.okx.com:8443/ws/v5/private", private_url_sandbox: "wss://wspap.okx.com:8443/ws/v5/private", heartbeat: %{type: :ping, interval: 25_000}, subscription_pattern: :op_subscribe_objects, subscription_config: %{}, auth_pattern: :iso_passphrase, auth_config: %{timestamp_unit: :seconds} }, "deribit" => %{ public_url: "wss://www.deribit.com/ws/api/v2", public_url_sandbox: "wss://test.deribit.com/ws/api/v2", private_url: "wss://www.deribit.com/ws/api/v2", private_url_sandbox: "wss://test.deribit.com/ws/api/v2", heartbeat: %{type: :deribit, interval: 30_000}, subscription_pattern: :jsonrpc_subscribe, subscription_config: %{method: "public/subscribe"}, auth_pattern: :jsonrpc_linebreak, auth_config: %{} }, "kraken" => %{ public_url: "wss://ws.kraken.com/v2", public_url_sandbox: "wss://beta-ws.kraken.com/v2", private_url: "wss://ws-auth.kraken.com/v2", private_url_sandbox: "wss://beta-ws-auth.kraken.com/v2", heartbeat: %{type: :ping, interval: 60_000}, subscription_pattern: :method_params_subscribe, subscription_config: %{}, auth_pattern: :rest_token, auth_config: %{ pre_auth: %{ type: :rest_token, endpoint: "privatePostGetWebSocketsToken" } } }, "kucoin" => %{ # URLs are acquired via REST POST /api/v1/bullet-{public,private}. # Left nil until the adapter layer wires the pre-call. public_url: nil, public_url_sandbox: nil, private_url: nil, private_url_sandbox: nil, heartbeat: %{type: :ping, interval: 18_000}, subscription_pattern: :type_subscribe, subscription_config: %{op_field: "type", args_field: "topic", args_format: :string}, auth_pattern: :iso_passphrase, auth_config: %{timestamp_unit: :milliseconds} }, "gate" => %{ public_url: "wss://api.gateio.ws/ws/v4/", public_url_sandbox: nil, private_url: "wss://api.gateio.ws/ws/v4/", private_url_sandbox: nil, heartbeat: %{type: :ping, interval: 30_000}, subscription_pattern: :event_subscribe, subscription_config: %{args_format: :object_list}, auth_pattern: :sha512_newline, auth_config: %{} }, "htx" => %{ public_url: "wss://api.huobi.pro/ws", public_url_sandbox: nil, private_url: "wss://api.huobi.pro/ws/v2", private_url_sandbox: nil, heartbeat: %{type: :custom, interval: 20_000}, subscription_pattern: :sub_subscribe, subscription_config: %{separator: "."}, auth_pattern: :direct_hmac_expiry, auth_config: %{ op_field: "op", op_value: "auth", encoding: :base64, expires_offset_ms: 10_000 } }, "huobi" => %{ public_url: "wss://api.huobi.pro/ws", public_url_sandbox: nil, private_url: "wss://api.huobi.pro/ws/v2", private_url_sandbox: nil, heartbeat: %{type: :custom, interval: 20_000}, subscription_pattern: :sub_subscribe, subscription_config: %{separator: "."}, auth_pattern: :direct_hmac_expiry, auth_config: %{ op_field: "op", op_value: "auth", encoding: :base64, expires_offset_ms: 10_000 } }, "bitmex" => %{ public_url: "wss://ws.bitmex.com/realtime", public_url_sandbox: "wss://ws.testnet.bitmex.com/realtime", private_url: "wss://ws.bitmex.com/realtime", private_url_sandbox: "wss://ws.testnet.bitmex.com/realtime", heartbeat: %{type: :ping, interval: 30_000}, subscription_pattern: :op_subscribe, subscription_config: %{separator: "."}, auth_pattern: :direct_hmac_expiry, auth_config: %{ op_field: "op", op_value: "authKeyExpires", encoding: :base64, expires_offset_ms: 10_000 } }, "bitfinex" => %{ public_url: "wss://api-pub.bitfinex.com/ws/2", public_url_sandbox: nil, private_url: "wss://api.bitfinex.com/ws/2", private_url_sandbox: nil, heartbeat: %{type: :ping, interval: 30_000}, subscription_pattern: :event_subscribe, subscription_config: %{op_field: "event", args_field: "channel", args_format: :string}, auth_pattern: :sha384_nonce, auth_config: %{} }, "coinbaseexchange" => %{ public_url: "wss://ws-feed.exchange.coinbase.com", public_url_sandbox: "wss://ws-feed-public.sandbox.exchange.coinbase.com", private_url: "wss://ws-feed.exchange.coinbase.com", private_url_sandbox: "wss://ws-feed-public.sandbox.exchange.coinbase.com", heartbeat: %{type: :ping, interval: 30_000}, subscription_pattern: :type_subscribe, subscription_config: %{ op_field: "type", args_field: "product_ids", args_format: :string_list, channels_field: "channels" }, auth_pattern: :inline_subscribe, auth_config: %{} }, "aster" => %{ public_url: "wss://sstream.asterdex.com/ws", public_url_sandbox: nil, private_url: "wss://sstream.asterdex.com/ws", private_url_sandbox: nil, heartbeat: %{type: :ping, interval: 180_000}, subscription_pattern: :method_subscribe, subscription_config: %{separator: "@", market_id_format: :lowercase}, auth_pattern: :listen_key, auth_config: %{ pre_auth: %{ type: :listen_key, endpoints: %{spot: "privatePostUserDataStream"} } } } } @doc "Returns the WS config map for the given exchange id, or nil if unsupported." @spec for_exchange(String.t()) :: map() | nil def for_exchange(id) when is_binary(id), do: Map.get(@configs, id) @doc "Returns true if this exchange has a WS config entry." @spec supported?(String.t()) :: boolean() def supported?(id) when is_binary(id), do: Map.has_key?(@configs, id) @doc "Returns all supported exchange ids." @spec supported_exchanges() :: [String.t()] def supported_exchanges, do: Map.keys(@configs) end