Current section
Files
Jump to
Current section
Files
lib/ccxt/ws/generator/functions.ex
defmodule CCXT.WS.Generator.Functions do
@moduledoc """
Generates WebSocket subscription functions at compile time.
This module creates `watch_*_subscription/2` functions for each
watch method defined in the exchange's WS spec.
## Generated Functions
For each watch method in the spec, generates a subscription builder:
- `watch_ticker_subscription/2` - Single symbol ticker
- `watch_tickers_subscription/2` - Multiple symbols
- `watch_order_book_subscription/3` - Order book with optional limit
- `watch_ohlcv_subscription/3` - OHLCV with timeframe
- `watch_balance_subscription/1` - Account balance (auth required)
- etc.
## Return Format
All generated functions return:
{:ok, %{
channel: String.t() | [String.t()],
message: map(),
method: atom(),
auth_required: boolean()
}}
## The `opts` Parameter
All generated functions accept an `opts` keyword list as the last parameter.
This is reserved for future extensibility (e.g., custom separators, market
type hints, or authentication options). Currently unused but included for
API stability.
"""
# Credo: nested module references inside quote blocks are intentional -
# they become part of the generated module code, not this module.
# credo:disable-for-this-file Credo.Check.Design.AliasUsage
require Logger
# Watch methods that require authentication
# Primary auth mechanism is per-channel extraction (Task 191).
# This list is a defensive fallback for exchanges where extraction misses auth.
@private_watch_methods ~w(
watch_balance watch_orders watch_my_trades watch_positions
watch_orders_for_symbols watch_my_trades_for_symbols
watch_my_liquidations watch_my_liquidations_for_symbols
)a
# Parameter specifications for each watch method
# Format: {[params], "doc_string"}
@method_param_specs %{
watch_balance: {[], ""},
watch_heartbeat: {[], ""},
watch_private: {[], ""},
watch_ticker: {[:symbol], "symbol"},
watch_tickers: {[:symbols], "symbols"},
watch_order_book: {[:symbol, :limit], "symbol, limit"},
watch_order_book_snapshot: {[:symbol, :limit], "symbol, limit"},
watch_order_book_for_symbols: {[:symbols], "symbols"},
watch_trades: {[:symbol], "symbol"},
watch_trades_for_symbols: {[:symbols], "symbols"},
watch_ohlcv: {[:symbol, :timeframe], "symbol, timeframe"},
watch_ohlcv_for_symbols: {[:symbols], "symbols"},
watch_orders: {[:symbol], "symbol"},
watch_orders_for_symbols: {[:symbols], "symbols"},
watch_my_trades: {[:symbol], "symbol"},
watch_my_trades_for_symbols: {[:symbols], "symbols"},
watch_positions: {[:symbols], "symbols"},
watch_bids_asks: {[:symbols], "symbols"},
watch_liquidations: {[:symbol], "symbol"},
watch_liquidations_for_symbols: {[:symbols], "symbols"},
watch_my_liquidations: {[:symbol], "symbol"},
watch_my_liquidations_for_symbols: {[:symbols], "symbols"},
watch_funding_rate: {[:symbol], "symbol"},
watch_funding_rates: {[:symbols], "symbols"},
watch_mark_price: {[:symbol], "symbol"},
watch_mark_prices: {[:symbols], "symbols"},
watch_topics: {[:symbol], "symbol"}
}
@doc """
Generates module documentation for the WS module.
Creates a comprehensive moduledoc including:
- Exchange name and subscription pattern
- Usage examples
- Links to available methods
"""
@spec generate_moduledoc(map()) :: String.t()
def generate_moduledoc(spec) do
# Use Map.get for struct access compatibility
exchange_name = Map.get(spec, :name) || Map.get(spec, :id) || "Exchange"
ws_config = Map.get(spec, :ws) || %{}
pattern = Map.get(ws_config, :subscription_pattern) || :unknown
"""
WebSocket subscription builders for #{exchange_name}.
This module provides pure functions that build subscription messages
for WebSocket channels. Use these with a WebSocket client (like ZenWebsocket)
to subscribe to real-time data feeds.
## Subscription Pattern
This exchange uses the `#{inspect(pattern)}` subscription pattern.
## Usage
# Build a ticker subscription
{:ok, sub} = #{exchange_name}.WS.watch_ticker_subscription("BTC/USDT")
# sub contains:
# %{
# channel: "...", # Channel name for message routing
# message: %{...}, # JSON message to send
# method: :watch_ticker, # Original method
# auth_required: false # Whether credentials needed
# }
# Send via WebSocket client
ZenWebsocket.Client.send_message(client, Jason.encode!(sub.message))
## Available Methods
See `__ccxt_ws_channels__/0` for the list of available watch methods.
"""
end
@doc """
Generates introspection functions for the WS module.
"""
@spec generate_introspection(map()) :: Macro.t()
def generate_introspection(ws_config) do
pattern = ws_config[:subscription_pattern]
channels = ws_config[:channel_templates] || %{}
quote do
@doc "Returns the WS spec configuration."
@spec __ccxt_ws_spec__() :: map()
def __ccxt_ws_spec__, do: @ws_spec
@doc "Returns the subscription pattern atom."
@spec __ccxt_ws_pattern__() :: atom()
def __ccxt_ws_pattern__, do: unquote(pattern)
@doc "Returns the channel templates map."
@spec __ccxt_ws_channels__() :: map()
def __ccxt_ws_channels__, do: unquote(Macro.escape(channels))
end
end
@doc """
Generates watch_*_subscription functions for all available watch methods.
"""
@spec generate_watch_functions(map()) :: Macro.t()
def generate_watch_functions(ws_config) do
channel_templates = ws_config[:channel_templates] || %{}
# Generate a function for each channel template
functions =
Enum.map(channel_templates, fn {method, template} ->
generate_watch_function(method, template, ws_config)
end)
quote do
(unquote_splicing(functions))
end
end
@doc false
# For dual-field patterns (Coinbase), injects the template's channel_name into
# subscription_config so that subscribe/2 can populate the channels field.
# No-op when channels_field is absent (most exchanges).
@spec inject_channel_name(map(), map()) :: map()
defp inject_channel_name(ws_config, template) do
sub_config = ws_config[:subscription_config] || %{}
channel_name = template[:channel_name]
if sub_config[:channels_field] && channel_name do
put_in(ws_config, [:subscription_config, :channel_name], channel_name)
else
ws_config
end
end
@doc false
# Generates a single watch_*_subscription function based on method type.
# Dispatches to specialized generators (no-param, symbol, symbols, ohlcv, orderbook).
# For URL-routed channels, generates functions that require URL parameter.
# sobelow_skip ["DOS.BinToAtom"]
@spec generate_watch_function(atom(), map(), map()) :: Macro.t()
defp generate_watch_function(method, template, ws_config) do
# Convert :watch_ticker to :watch_ticker_subscription
# Safe: method comes from CCXT spec channel_templates, not user input
func_name = :"#{method}_subscription"
# Determine if auth is required (hardcoded private methods OR per-channel extraction)
auth_required = method in @private_watch_methods or template[:auth_required] == true
# Determine parameters based on method name
{params, param_doc} = method_params(method)
# For dual-field patterns (Coinbase): inject template's channel_name
# into subscription_config so subscribe/2 can populate the channels field
ws_config = inject_channel_name(ws_config, template)
# Check if this is a URL-routed channel (Bybit-style)
url_routed = template[:url_routed] == true
# Generate function documentation
doc =
if url_routed do
generate_url_routed_doc(method, param_doc, auth_required, template[:topic_dict])
else
generate_function_doc(method, param_doc, auth_required)
end
# Generate the function body
if url_routed do
# URL-routed channels need URL parameter to resolve topic
generate_url_routed_function(func_name, method, template, ws_config, auth_required, doc, params)
else
case params do
[] ->
# No symbol parameter (e.g., watch_balance)
generate_no_param_function(func_name, method, template, ws_config, auth_required, doc)
[:symbol] ->
# Single symbol parameter
generate_symbol_function(func_name, method, template, ws_config, auth_required, doc)
[:symbols] ->
# Multiple symbols parameter
generate_symbols_function(func_name, method, template, ws_config, auth_required, doc)
[:symbol, :timeframe] ->
# Symbol + timeframe (OHLCV)
generate_ohlcv_function(func_name, method, template, ws_config, auth_required, doc)
[:symbol, :limit] ->
# Symbol + limit (orderbook)
generate_orderbook_function(func_name, method, template, ws_config, auth_required, doc)
end
end
end
@doc false
# Determines what parameters a watch method requires.
# Uses @method_param_specs map lookup for reduced complexity.
# Falls back to single symbol param for unknown methods with warning.
@spec method_params(atom()) :: {[atom()], String.t()}
defp method_params(method) do
case Map.fetch(@method_param_specs, method) do
{:ok, spec} ->
spec
:error ->
Logger.warning("Unknown watch method #{inspect(method)}, defaulting to single symbol param")
{[:symbol], "symbol"}
end
end
@doc false
# Builds the @doc string for a generated watch_*_subscription function.
@spec generate_function_doc(atom(), String.t(), boolean()) :: String.t()
defp generate_function_doc(method, param_doc, auth_required) do
auth_note = if auth_required, do: "\n\n Requires authentication.", else: ""
params_note = if param_doc == "", do: "", else: "\n - `#{param_doc}` - Trading pair or list"
"""
Builds subscription for #{method |> Atom.to_string() |> String.replace("_", " ")}.
#{params_note}
- `opts` - Optional parameters#{auth_note}
Returns `{:ok, %{channel: ..., message: ..., method: ..., auth_required: ...}}`
"""
end
@doc false
# Builds the @doc string for URL-routed watch_*_subscription functions.
# These require a URL parameter because the channel varies by connection type.
@spec generate_url_routed_doc(atom(), String.t(), boolean(), map() | nil) :: String.t()
defp generate_url_routed_doc(method, param_doc, auth_required, topic_dict) do
auth_note = if auth_required, do: "\n\n Requires authentication.", else: ""
params_note = if param_doc == "", do: "", else: "\n - `#{param_doc}` - Trading pair or list"
topic_examples =
if is_map(topic_dict) and map_size(topic_dict) > 0 do
examples =
Enum.map_join(topic_dict, "\n", fn {type, topic} -> " - #{type} → #{inspect(topic)}" end)
"\n\n Channel varies by URL type:\n#{examples}"
else
""
end
"""
Builds subscription for #{method |> Atom.to_string() |> String.replace("_", " ")}.
**URL-routed channel** - requires WebSocket URL to determine the correct topic.
#{params_note}
- `url` - WebSocket URL (determines account type)
- `opts` - Optional parameters#{topic_examples}#{auth_note}
Returns `{:ok, %{channel: ..., message: ..., method: ..., auth_required: ...}}`
or `{:error, reason}` if URL doesn't match any known pattern.
"""
end
@doc false
# Generates a subscription function that takes no parameters (e.g., watch_balance).
@spec generate_no_param_function(atom(), atom(), map(), map(), boolean(), String.t()) :: Macro.t()
defp generate_no_param_function(func_name, method, template, ws_config, auth_required, doc) do
quote do
@doc unquote(doc)
@spec unquote(func_name)(keyword()) ::
{:ok, %{channel: String.t() | map(), message: map(), method: atom(), auth_required: boolean()}}
def unquote(func_name)(opts \\ []) do
template = unquote(Macro.escape(template))
config = unquote(Macro.escape(ws_config))
channel = CCXT.WS.Subscription.format_channel(template, %{}, config)
message = CCXT.WS.Subscription.build_subscribe([channel], config)
{:ok,
%{
channel: channel,
message: message,
method: unquote(method),
auth_required: unquote(auth_required)
}}
end
end
end
@doc false
# Generates a subscription function that takes a single symbol parameter.
@spec generate_symbol_function(atom(), atom(), map(), map(), boolean(), String.t()) :: Macro.t()
defp generate_symbol_function(func_name, method, template, ws_config, auth_required, doc) do
quote do
@doc unquote(doc)
@spec unquote(func_name)(String.t(), keyword()) ::
{:ok, %{channel: String.t() | map(), message: map(), method: atom(), auth_required: boolean()}}
def unquote(func_name)(symbol, opts \\ []) do
template = unquote(Macro.escape(template))
config = unquote(Macro.escape(ws_config))
channel = CCXT.WS.Subscription.format_channel(template, %{symbol: symbol}, config)
message = CCXT.WS.Subscription.build_subscribe([channel], config)
{:ok,
%{
channel: channel,
message: message,
method: unquote(method),
auth_required: unquote(auth_required)
}}
end
end
end
@doc false
# Generates a subscription function that takes multiple symbols parameter.
@spec generate_symbols_function(atom(), atom(), map(), map(), boolean(), String.t()) :: Macro.t()
defp generate_symbols_function(func_name, method, template, ws_config, auth_required, doc) do
quote do
@doc unquote(doc)
@spec unquote(func_name)([String.t()], keyword()) ::
{:ok, %{channel: [String.t() | map()], message: map(), method: atom(), auth_required: boolean()}}
def unquote(func_name)(symbols, opts \\ []) when is_list(symbols) do
template = unquote(Macro.escape(template))
config = unquote(Macro.escape(ws_config))
channels =
Enum.map(symbols, fn symbol ->
CCXT.WS.Subscription.format_channel(template, %{symbol: symbol}, config)
end)
message = CCXT.WS.Subscription.build_subscribe(channels, config)
{:ok,
%{
channel: channels,
message: message,
method: unquote(method),
auth_required: unquote(auth_required)
}}
end
end
end
@doc false
# Generates a subscription function that takes symbol and timeframe parameters.
@spec generate_ohlcv_function(atom(), atom(), map(), map(), boolean(), String.t()) :: Macro.t()
defp generate_ohlcv_function(func_name, method, template, ws_config, auth_required, doc) do
quote do
@doc unquote(doc)
@spec unquote(func_name)(String.t(), String.t(), keyword()) ::
{:ok, %{channel: String.t() | map(), message: map(), method: atom(), auth_required: boolean()}}
def unquote(func_name)(symbol, timeframe \\ "1m", opts \\ []) do
template = unquote(Macro.escape(template))
config = unquote(Macro.escape(ws_config))
channel =
CCXT.WS.Subscription.format_channel(
template,
%{symbol: symbol, timeframe: timeframe},
config
)
message = CCXT.WS.Subscription.build_subscribe([channel], config)
{:ok,
%{
channel: channel,
message: message,
method: unquote(method),
auth_required: unquote(auth_required)
}}
end
end
end
@doc false
# Generates a subscription function that takes symbol and limit parameters.
@spec generate_orderbook_function(atom(), atom(), map(), map(), boolean(), String.t()) :: Macro.t()
defp generate_orderbook_function(func_name, method, template, ws_config, auth_required, doc) do
quote do
@doc unquote(doc)
@spec unquote(func_name)(String.t(), non_neg_integer() | nil, keyword()) ::
{:ok, %{channel: String.t() | map(), message: map(), method: atom(), auth_required: boolean()}}
def unquote(func_name)(symbol, limit \\ nil, opts \\ []) do
template = unquote(Macro.escape(template))
config = unquote(Macro.escape(ws_config))
channel =
CCXT.WS.Subscription.format_channel(
template,
%{symbol: symbol, limit: limit},
config
)
message = CCXT.WS.Subscription.build_subscribe([channel], config)
{:ok,
%{
channel: channel,
message: message,
method: unquote(method),
auth_required: unquote(auth_required)
}}
end
end
end
@doc false
# Generates a subscription function for URL-routed channels.
# These channels require the WebSocket URL to determine the correct topic.
@spec generate_url_routed_function(atom(), atom(), map(), map(), boolean(), String.t(), [atom()]) ::
Macro.t()
defp generate_url_routed_function(func_name, method, template, ws_config, auth_required, doc, params) do
url_patterns = template[:url_patterns] || []
topic_dict = template[:topic_dict] || %{}
case params do
[] ->
# No additional params (e.g., watch_balance)
generate_url_routed_no_param(func_name, method, url_patterns, topic_dict, ws_config, auth_required, doc)
[:symbol] ->
# Single symbol parameter (e.g., watch_my_trades, watch_orders)
generate_url_routed_symbol(func_name, method, url_patterns, topic_dict, ws_config, auth_required, doc)
[:symbols] ->
# Multiple symbols parameter (e.g., watch_positions)
generate_url_routed_symbols(func_name, method, url_patterns, topic_dict, ws_config, auth_required, doc)
_ ->
# Default to no-param for unsupported combinations
generate_url_routed_no_param(func_name, method, url_patterns, topic_dict, ws_config, auth_required, doc)
end
end
@doc false
# Generates URL-routed function with no additional parameters.
@spec generate_url_routed_no_param(atom(), atom(), list(), map(), map(), boolean(), String.t()) ::
Macro.t()
defp generate_url_routed_no_param(func_name, method, url_patterns, topic_dict, ws_config, auth_required, doc) do
quote do
@doc unquote(doc)
@spec unquote(func_name)(String.t(), keyword()) ::
{:ok, %{channel: String.t() | [String.t()], message: map(), method: atom(), auth_required: boolean()}}
| {:error, term()}
def unquote(func_name)(url, opts \\ []) do
url_patterns = unquote(Macro.escape(url_patterns))
topic_dict = unquote(Macro.escape(topic_dict))
config = unquote(Macro.escape(ws_config))
case CCXT.WS.UrlRouting.resolve_topic(url, url_patterns, topic_dict) do
{:ok, topic} ->
channels = if is_list(topic), do: topic, else: [topic]
message = CCXT.WS.Subscription.build_subscribe(channels, config)
{:ok,
%{
channel: topic,
message: message,
method: unquote(method),
auth_required: unquote(auth_required)
}}
{:error, _reason} = error ->
error
end
end
end
end
@doc false
# Generates URL-routed function with single symbol parameter.
@spec generate_url_routed_symbol(atom(), atom(), list(), map(), map(), boolean(), String.t()) ::
Macro.t()
defp generate_url_routed_symbol(func_name, method, url_patterns, topic_dict, ws_config, auth_required, doc) do
quote do
@doc unquote(doc)
@spec unquote(func_name)(String.t(), String.t(), keyword()) ::
{:ok, %{channel: String.t() | [String.t()], message: map(), method: atom(), auth_required: boolean()}}
| {:error, term()}
def unquote(func_name)(url, symbol, opts \\ []) do
url_patterns = unquote(Macro.escape(url_patterns))
topic_dict = unquote(Macro.escape(topic_dict))
config = unquote(Macro.escape(ws_config))
case CCXT.WS.UrlRouting.resolve_topic(url, url_patterns, topic_dict) do
{:ok, topic} ->
# For symbol-based subscriptions, the topic may need symbol interpolation
channels = if is_list(topic), do: topic, else: [topic]
message = CCXT.WS.Subscription.build_subscribe(channels, config)
{:ok,
%{
channel: topic,
message: message,
method: unquote(method),
auth_required: unquote(auth_required),
symbol: symbol
}}
{:error, _reason} = error ->
error
end
end
end
end
@doc false
# Generates URL-routed function with multiple symbols parameter.
@spec generate_url_routed_symbols(atom(), atom(), list(), map(), map(), boolean(), String.t()) ::
Macro.t()
defp generate_url_routed_symbols(func_name, method, url_patterns, topic_dict, ws_config, auth_required, doc) do
quote do
@doc unquote(doc)
@spec unquote(func_name)(String.t(), [String.t()], keyword()) ::
{:ok, %{channel: String.t() | [String.t()], message: map(), method: atom(), auth_required: boolean()}}
| {:error, term()}
def unquote(func_name)(url, symbols, opts \\ []) when is_list(symbols) do
url_patterns = unquote(Macro.escape(url_patterns))
topic_dict = unquote(Macro.escape(topic_dict))
config = unquote(Macro.escape(ws_config))
case CCXT.WS.UrlRouting.resolve_topic(url, url_patterns, topic_dict) do
{:ok, topic} ->
channels = if is_list(topic), do: topic, else: [topic]
message = CCXT.WS.Subscription.build_subscribe(channels, config)
{:ok,
%{
channel: topic,
message: message,
method: unquote(method),
auth_required: unquote(auth_required),
symbols: symbols
}}
{:error, _reason} = error ->
error
end
end
end
end
end