Packages

Elixir client library for cryptocurrency exchanges — generated from CCXT specs via compile-time macros.

Current section

Files

Jump to
ccxt_client lib ccxt ws coverage.ex
Raw

lib/ccxt/ws/coverage.ex

defmodule CCXT.WS.Coverage do
@moduledoc """
Pure WS extraction coverage analysis.
Checks whether WS handler mappings have been extracted for each family
that CCXT declares as supported. This is extraction-quality checking only —
it does NOT check normalization or runtime behavior.
## Status Model
| Status | Meaning |
|--------|---------|
| `:supported` | `ws.has` declares family AND handler mapping extracted |
| `:no_handler` | `ws.has` declares family BUT handler missing |
| `:unsupported` | Exchange doesn't offer this family |
## Channel Quality Model
| Status | Meaning |
|--------|---------|
| `:ok` | Non-nil, non-empty channel_name string |
| `:url_routed` | Template has `url_routed: true` (uses topic_dict, nil is legitimate) |
| `:private_channel` | Private method (balance, orders, etc.) confirmed by ws.has |
| `:unresolved_var` | Channel name matches a known JS variable name (extraction bug) |
| `:unexpected_nil` | Nil channel_name with no structural justification |
## Dependencies
- `CCXT.Extract.WsHandlerMappings` — handler mapping data
- `CCXT.Spec` — exchange spec files (ws.has capabilities, channel templates)
"""
alias CCXT.Extract.WsHandlerMappings
@type status :: :supported | :no_handler | :unsupported
@type channel_quality ::
:ok | :url_routed | :private_channel | :unresolved_var | :unexpected_nil
@typedoc "Per-method channel quality classification"
@type channel_entry :: %{method: atom(), channel_name: String.t() | nil, quality: channel_quality()}
@typedoc "Per-exchange channel quality result"
@type channel_quality_result :: %{
entries: [channel_entry()],
counts: %{channel_quality() => non_neg_integer()}
}
@typedoc "Per-exchange coverage result"
@type exchange_result :: %{
statuses: %{atom() => status()},
supported_count: non_neg_integer(),
declared_count: non_neg_integer(),
coverage_pct: float(),
missing_families: [atom()],
missing_spec: boolean()
}
@typedoc "Matrix of exchange_id => exchange_result"
@type matrix :: %{String.t() => exchange_result()}
@typedoc "Aggregated summary across all exchanges"
@type summary :: %{
total_exchanges: non_neg_integer(),
exchanges_with_gaps: non_neg_integer(),
exchanges_fully_covered: non_neg_integer(),
missing_specs: non_neg_integer(),
total_supported: non_neg_integer(),
total_no_handler: non_neg_integer(),
total_declared: non_neg_integer()
}
@percent_max 100.0
@doc "Returns the 7 known WS families."
@spec families() :: [atom()]
def families, do: WsHandlerMappings.known_families()
@doc """
Pure core logic: computes per-family status from pre-loaded data.
`families_supported` is a list of family atoms the exchange has handlers for.
`ws_has` is the `spec.ws.has` map (or nil if no spec).
Returns a map of `%{family => status}` for all 7 families.
"""
@spec compute_exchange_status([atom()], map() | nil) :: %{atom() => status()}
def compute_exchange_status(families_supported, ws_has) do
supported_set = MapSet.new(families_supported)
Map.new(families(), fn family ->
{family, family_status(family, ws_has, supported_set)}
end)
end
@doc """
Computes a full result for a single exchange by loading real data.
Loads the spec file and handler mappings, then delegates to pure logic.
"""
@spec compute_result(String.t()) :: exchange_result()
def compute_result(exchange_id) do
case find_spec_path(exchange_id) do
nil ->
missing_spec_result()
spec_path ->
ws_has = load_ws_has(spec_path)
families_supported = WsHandlerMappings.families_supported(exchange_id)
statuses = compute_exchange_status(families_supported, ws_has)
build_result(statuses, false)
end
end
@doc """
Computes coverage matrix for multiple exchanges.
Returns `%{exchange_id => exchange_result}` sorted by exchange ID.
"""
@spec compute_matrix([String.t()]) :: matrix()
def compute_matrix(exchange_ids) do
exchange_ids
|> Enum.sort()
|> Map.new(fn id -> {id, compute_result(id)} end)
end
@doc """
Aggregates a matrix into a summary map.
"""
@spec summarize(matrix()) :: summary()
def summarize(matrix) do
results = Map.values(matrix)
%{
total_exchanges: length(results),
exchanges_with_gaps: Enum.count(results, &has_gaps?/1),
exchanges_fully_covered: Enum.count(results, &fully_covered?/1),
missing_specs: Enum.count(results, & &1.missing_spec),
total_supported: results |> Enum.map(& &1.supported_count) |> Enum.sum(),
total_no_handler: results |> Enum.map(&length(&1.missing_families)) |> Enum.sum(),
total_declared: results |> Enum.map(& &1.declared_count) |> Enum.sum()
}
end
# -- Channel Quality -------------------------------------------------------
# JS variable names that indicate unresolved extraction from CCXT source
@js_variable_names ~w(name channelName klineType topic stream channel)
# Private WS method name prefixes — methods matching these AND declared in
# ws.has are legitimately nil (they use auth tokens, not channel names)
@private_prefixes ~w(watch_balance watch_orders watch_my_ watch_positions watch_position)
@doc """
Analyzes channel_name quality for all channel templates in an exchange spec.
Loads the spec by exchange ID and classifies each template's channel_name.
"""
@spec analyze_channel_quality(String.t()) :: channel_quality_result() | nil
def analyze_channel_quality(exchange_id) do
case find_spec_path(exchange_id) do
nil ->
nil
spec_path ->
spec = CCXT.Spec.load!(spec_path)
ws_has = get_in(Map.from_struct(spec), [:ws, :has])
templates = get_in(Map.from_struct(spec), [:ws, :channel_templates]) || %{}
entries =
templates
|> Enum.map(fn {method, tmpl} ->
cn = Map.get(tmpl, :channel_name)
quality = classify_channel(method, tmpl, cn, ws_has)
%{method: method, channel_name: cn, quality: quality}
end)
|> Enum.sort_by(& &1.method)
counts = Enum.frequencies_by(entries, & &1.quality)
%{entries: entries, counts: counts}
end
end
@doc """
Analyzes channel quality across multiple exchanges.
Returns `{results, missing_specs}` where `results` maps exchange IDs to
their channel quality data and `missing_specs` lists IDs with no spec file.
"""
@spec channel_quality_summary([String.t()]) ::
{%{String.t() => channel_quality_result()}, [String.t()]}
def channel_quality_summary(exchange_ids) do
all = exchange_ids |> Enum.sort() |> Map.new(fn id -> {id, analyze_channel_quality(id)} end)
missing = all |> Enum.filter(fn {_id, result} -> result == nil end) |> Enum.map(&elem(&1, 0))
results = all |> Enum.reject(fn {_id, result} -> result == nil end) |> Map.new()
{results, missing}
end
# -- Private Helpers -------------------------------------------------------
@doc false
@spec family_status(atom(), map() | nil, MapSet.t()) :: status()
defp family_status(family, ws_has, supported_set) do
cond do
not family_declared?(family, ws_has) -> :unsupported
not MapSet.member?(supported_set, family) -> :no_handler
true -> :supported
end
end
@doc false
# Only `true` counts as declared. `false`, `nil`, `"emulated"` do not.
@spec family_declared?(atom(), map() | nil) :: boolean()
defp family_declared?(_family, nil), do: false
defp family_declared?(family, ws_has) do
Map.get(ws_has, family) == true
end
@doc false
@spec load_ws_has(String.t()) :: map() | nil
defp load_ws_has(spec_path) do
spec = CCXT.Spec.load!(spec_path)
get_in(Map.from_struct(spec), [:ws, :has])
end
@doc false
@spec find_spec_path(String.t()) :: String.t() | nil
defp find_spec_path(exchange_id) do
curated = Path.join([File.cwd!(), "priv", "specs", "curated", "#{exchange_id}.exs"])
extracted = Path.join([File.cwd!(), "priv", "specs", "extracted", "#{exchange_id}.exs"])
cond do
File.exists?(curated) -> curated
File.exists?(extracted) -> extracted
true -> nil
end
end
@doc false
@spec build_result(%{atom() => status()}, boolean()) :: exchange_result()
defp build_result(statuses, missing_spec) do
declared_count = Enum.count(statuses, fn {_f, s} -> s != :unsupported end)
supported_count = Enum.count(statuses, fn {_f, s} -> s == :supported end)
missing_families =
statuses
|> Enum.filter(fn {_f, s} -> s == :no_handler end)
|> Enum.map(fn {f, _s} -> f end)
|> Enum.sort()
coverage_pct =
if declared_count == 0 do
if missing_spec, do: 0.0, else: @percent_max
else
supported_count / declared_count * @percent_max
end
%{
statuses: statuses,
supported_count: supported_count,
declared_count: declared_count,
coverage_pct: coverage_pct,
missing_families: missing_families,
missing_spec: missing_spec
}
end
@doc false
@spec missing_spec_result() :: exchange_result()
defp missing_spec_result do
statuses = Map.new(families(), fn f -> {f, :unsupported} end)
build_result(statuses, true)
end
@doc false
@spec has_gaps?(exchange_result()) :: boolean()
defp has_gaps?(result) do
result.missing_spec or result.missing_families != []
end
@doc false
@spec fully_covered?(exchange_result()) :: boolean()
defp fully_covered?(result) do
not result.missing_spec and result.missing_families == []
end
# -- Channel Classification ------------------------------------------------
@doc false
# Classifies a single channel template's channel_name quality
@spec classify_channel(atom(), map(), String.t() | nil, map() | nil) :: channel_quality()
defp classify_channel(method, tmpl, channel_name, ws_has)
defp classify_channel(_method, _tmpl, cn, _ws_has) when is_binary(cn) and cn != "" do
if cn in @js_variable_names, do: :unresolved_var, else: :ok
end
defp classify_channel(_method, %{url_routed: true}, _cn, _ws_has), do: :url_routed
defp classify_channel(method, _tmpl, _cn, ws_has) do
if private_method?(method) and method_declared?(method, ws_has) do
:private_channel
else
:unexpected_nil
end
end
@doc false
@spec private_method?(atom()) :: boolean()
defp private_method?(method) do
method_str = Atom.to_string(method)
Enum.any?(@private_prefixes, &String.starts_with?(method_str, &1))
end
@doc false
# Check if ws.has declares this method (true means supported)
@spec method_declared?(atom(), map() | nil) :: boolean()
defp method_declared?(_method, nil), do: false
defp method_declared?(method, ws_has), do: Map.get(ws_has, method) == true
end