Current section
Files
Jump to
Current section
Files
lib/ccxt/emulation.ex
defmodule CCXT.Emulation do
@moduledoc """
Runtime emulation dispatch for CCXT unified methods.
Uses extracted emulated method metadata to decide when a method should be
derived at runtime instead of issuing HTTP or WebSocket requests.
"""
alias CCXT.Error
alias CCXT.Extract.EmulatedMethods
alias CCXT.Spec
@type scope :: :rest | :ws
@type entry :: map()
@type dispatch_result :: :passthrough | {:ok, term()} | {:error, Error.t()}
@cache_key {__MODULE__, :index}
@default_currency_precision 1.0e-8
@zero 0
@doc "Returns true if a method is emulated for the exchange and scope."
@spec emulated?(Spec.t(), atom(), scope()) :: boolean()
def emulated?(%Spec{} = spec, method, scope) when is_atom(method) do
entry(spec, method, scope) != nil
end
@doc "Returns emulated metadata for a method (or nil if not emulated)."
@spec entry(Spec.t(), atom(), scope()) :: entry() | nil
def entry(%Spec{} = spec, method, scope) when is_atom(method) do
normalized_scope = normalize_scope(scope)
exchange_id = spec.id
load_index()
|> Map.get(exchange_id, %{})
|> Map.get(normalized_scope, %{})
|> Map.get(method)
end
@doc """
Dispatches emulated method calls.
Returns `:passthrough` when the method is not emulated for this exchange,
`{:ok, result}` when emulation succeeds, or `{:error, reason}` on failure.
"""
@spec dispatch(Spec.t(), atom(), scope(), map()) :: dispatch_result()
def dispatch(%Spec{} = spec, method, scope, context) when is_atom(method) and is_map(context) do
case entry(spec, method, scope) do
nil -> :passthrough
entry -> dispatch_entry(spec, method, entry, context)
end
end
@doc "Reloads emulation index (bypasses cache)."
@spec reload!() :: map()
def reload! do
index = build_index()
:persistent_term.put(@cache_key, index)
index
end
# ===========================================================================
# Index Building
# ===========================================================================
@doc false
# Loads the emulation index from persistent_term or builds it from JSON.
@spec load_index() :: map()
defp load_index do
case :persistent_term.get(@cache_key, :missing) do
:missing ->
index = build_index()
:persistent_term.put(@cache_key, index)
index
index ->
index
end
end
@doc false
# Builds a lookup table: exchange_id -> scope -> method_atom -> entry.
@spec build_index() :: map()
defp build_index do
EmulatedMethods.load()
|> Map.get("emulated_methods", %{})
|> Enum.reduce(%{}, fn {exchange_id, entries}, acc ->
Map.put(acc, exchange_id, build_scoped_index(entries))
end)
end
@doc false
# Groups emulated entries by scope and method.
@spec build_scoped_index([map()]) :: map()
defp build_scoped_index(entries) do
Enum.reduce(entries, %{rest: %{}, ws: %{}}, fn entry, acc ->
index_entry(acc, entry)
end)
end
@doc false
# Indexes a single emulated entry by scope and method.
@spec index_entry(map(), map()) :: map()
defp index_entry(acc, entry) do
scope = normalize_scope(Map.get(entry, "scope"))
method = to_method_atom(Map.get(entry, "name"))
if scope in [:rest, :ws] and is_atom(method) do
Map.update!(acc, scope, fn scope_map -> Map.put(scope_map, method, entry) end)
else
acc
end
end
# ===========================================================================
# Dispatch Routing
# ===========================================================================
@doc false
# Dispatches an emulated entry to its strategy handler - missing exchange_module case.
@spec dispatch_entry(Spec.t(), atom(), map(), map()) :: dispatch_result()
defp dispatch_entry(spec, _method, _entry, %{exchange_module: nil}) do
{:error,
Error.invalid_parameters(
message: "Emulation context missing exchange module",
exchange: exchange_atom(spec)
)}
end
defp dispatch_entry(spec, _method, _entry, context) when not is_map_key(context, :exchange_module) do
{:error,
Error.invalid_parameters(
message: "Emulation context missing exchange module",
exchange: exchange_atom(spec)
)}
end
defp dispatch_entry(spec, method, entry, context) do
%{exchange_module: exchange_module} = context
params = Map.get(context, :params, %{})
opts = Map.get(context, :opts, [])
credentials = Map.get(context, :credentials)
dispatch_method(spec, exchange_module, method, entry, params, opts, credentials)
end
# Dispatch map for routing methods to their handlers.
# Using a map lookup instead of a large case statement reduces cyclomatic complexity.
@dispatch_handlers %{
fetch_bids_asks: :handle_fetch_bids_asks,
fetch_ticker: :handle_fetch_ticker,
fetch_trading_fee: :handle_fetch_trading_fee,
fetch_transaction_fee: :handle_fetch_transaction_fee,
fetch_deposit_withdraw_fee: :handle_fetch_deposit_withdraw_fee,
fetch_deposit_address: :handle_fetch_deposit_address,
fetch_currencies: :handle_fetch_currencies,
fetch_open_orders: :handle_fetch_open_orders,
fetch_closed_orders: :handle_fetch_closed_orders,
fetch_canceled_orders: :handle_fetch_canceled_orders,
fetch_canceled_and_closed_orders: :handle_fetch_canceled_and_closed_orders,
fetch_order: :handle_fetch_order,
fetch_order_trades: :handle_fetch_order_trades,
fetch_my_trades: :handle_fetch_my_trades,
fetch_position: :handle_fetch_position,
fetch_position_history: :handle_fetch_position_history,
fetch_leverage: :handle_fetch_leverage,
fetch_margin_mode: :handle_fetch_margin_mode,
fetch_market_leverage_tiers: :handle_fetch_market_leverage_tiers,
fetch_funding_rate: :handle_fetch_funding_rate,
fetch_funding_interval: :handle_fetch_funding_interval,
fetch_isolated_borrow_rate: :handle_fetch_isolated_borrow_rate,
fetch_trading_limits: :handle_fetch_trading_limits,
fetch_transactions: :handle_fetch_transactions,
fetch_deposits_withdrawals: :handle_fetch_deposits_withdrawals
}
@doc "Returns the set of emulated methods with runtime handlers implemented."
@spec implemented_methods() :: MapSet.t(atom())
def implemented_methods do
MapSet.new(Map.keys(@dispatch_handlers))
end
@doc false
# Routes emulated methods to their concrete strategy implementations via dispatch map.
@spec dispatch_method(Spec.t(), module(), atom(), map(), map(), keyword(), term()) ::
dispatch_result()
defp dispatch_method(spec, exchange_module, method, entry, params, opts, credentials) do
case Map.fetch(@dispatch_handlers, method) do
{:ok, handler} ->
apply(__MODULE__, handler, [spec, exchange_module, params, opts, credentials])
:error ->
not_supported_entry(spec, method, entry)
end
end
@doc false
# Returns a standardized not_supported error for unhandled emulation entries.
@spec not_supported_entry(Spec.t(), atom(), map()) :: {:error, Error.t()}
defp not_supported_entry(spec, method, entry) do
exchange = exchange_atom(spec)
method_name = Atom.to_string(method)
reason_suffix = emulation_reason_suffix(entry)
{:error,
Error.not_supported(
message: "Emulated method not implemented: #{method_name}#{reason_suffix}",
exchange: exchange
)}
end
# ===========================================================================
# Strategy Handlers - Simple Delegations
# ===========================================================================
@doc false
# Emulates fetchBidsAsks by delegating to fetchTickers.
@spec handle_fetch_bids_asks(Spec.t(), module(), map(), keyword(), term()) :: dispatch_result()
def handle_fetch_bids_asks(spec, exchange_module, params, opts, credentials) do
symbols =
normalize_symbols(extract_param(params, :symbols) || extract_param(params, :symbol))
call_method(spec, exchange_module, :fetch_tickers, [symbols], opts, credentials)
end
@doc false
# Emulates fetchCurrencies by deriving currencies from fetchMarkets output.
@spec handle_fetch_currencies(Spec.t(), module(), map(), keyword(), term()) :: dispatch_result()
def handle_fetch_currencies(spec, exchange_module, _params, opts, credentials) do
with {:ok, markets} <- call_method(spec, exchange_module, :fetch_markets, [], opts, credentials) do
{:ok, derive_currencies_from_markets(markets)}
end
end
@doc false
# Emulates fetchTradingLimits by deriving limits from fetchMarkets output.
@spec handle_fetch_trading_limits(Spec.t(), module(), map(), keyword(), term()) ::
dispatch_result()
def handle_fetch_trading_limits(spec, exchange_module, params, opts, credentials) do
symbols = normalize_symbols(extract_param(params, :symbols))
with {:ok, markets} <- call_method(spec, exchange_module, :fetch_markets, [], opts, credentials) do
{:ok, derive_trading_limits(markets, symbols)}
end
end
@doc false
# Emulates fetchTransactions by delegating to fetchDepositsWithdrawals.
@spec handle_fetch_transactions(Spec.t(), module(), map(), keyword(), term()) ::
dispatch_result()
def handle_fetch_transactions(spec, exchange_module, params, opts, credentials) do
handle_fetch_deposits_withdrawals(spec, exchange_module, params, opts, credentials)
end
@doc false
# Emulates fetchTradingFee by selecting a symbol entry from fetchTradingFees.
@spec handle_fetch_trading_fee(Spec.t(), module(), map(), keyword(), term()) :: dispatch_result()
def handle_fetch_trading_fee(spec, exchange_module, params, opts, credentials) do
symbol = extract_param(params, :symbol)
with {:ok, fees} <- call_method(spec, exchange_module, :fetch_trading_fees, [], opts, credentials) do
{:ok, pick_symbol_entry(fees, symbol)}
end
end
@doc false
# Emulates fetchPosition by selecting a single position from fetchPositions.
@spec handle_fetch_position(Spec.t(), module(), map(), keyword(), term()) :: dispatch_result()
def handle_fetch_position(spec, exchange_module, params, opts, credentials) do
symbol = extract_param(params, :symbol)
symbols = normalize_symbols(symbol)
with {:ok, positions} <- call_method(spec, exchange_module, :fetch_positions, [symbols], opts, credentials) do
{:ok, pick_symbol_entry(positions, symbol)}
end
end
@doc false
# Emulates fetchPositionHistory by delegating to fetchPositionsHistory.
@spec handle_fetch_position_history(Spec.t(), module(), map(), keyword(), term()) ::
dispatch_result()
def handle_fetch_position_history(spec, exchange_module, params, opts, credentials) do
symbol = extract_param(params, :symbol)
since = extract_param(params, :since)
limit = extract_param(params, :limit)
symbols = normalize_symbols(symbol)
call_method(spec, exchange_module, :fetch_positions_history, [symbols, since, limit], opts, credentials)
end
# ===========================================================================
# Strategy Handlers - Require Symbol (early return pattern)
# ===========================================================================
@doc false
# Emulates fetchTicker by selecting a single ticker from fetchTickers output.
@spec handle_fetch_ticker(Spec.t(), module(), map(), keyword(), term()) :: dispatch_result()
def handle_fetch_ticker(spec, exchange_module, params, opts, credentials) do
symbol = extract_param(params, :symbol)
if is_nil(symbol) do
require_symbol_error(spec, "fetch_ticker")
else
with {:ok, tickers} <- call_method(spec, exchange_module, :fetch_tickers, [[symbol]], opts, credentials) do
require_symbol_result(tickers, symbol, spec, "fetchTickers() could not find a ticker for")
end
end
end
@doc false
# Emulates fetchTransactionFee by delegating to fetchTransactionFees.
@spec handle_fetch_transaction_fee(Spec.t(), module(), map(), keyword(), term()) ::
dispatch_result()
def handle_fetch_transaction_fee(spec, exchange_module, params, opts, credentials) do
code = extract_param(params, :code)
if is_nil(code) do
require_code_error(spec, "fetch_transaction_fee")
else
call_method(spec, exchange_module, :fetch_transaction_fees, [[code]], opts, credentials)
end
end
@doc false
# Emulates fetchDepositWithdrawFee by selecting a code entry from fetchDepositWithdrawFees.
@spec handle_fetch_deposit_withdraw_fee(Spec.t(), module(), map(), keyword(), term()) ::
dispatch_result()
def handle_fetch_deposit_withdraw_fee(spec, exchange_module, params, opts, credentials) do
code = extract_param(params, :code)
if is_nil(code) do
require_code_error(spec, "fetch_deposit_withdraw_fee")
else
with {:ok, fees} <-
call_method(
spec,
exchange_module,
:fetch_deposit_withdraw_fees,
[[code]],
opts,
credentials
) do
{:ok, pick_code_entry(fees, code)}
end
end
end
@doc false
# Emulates fetchDepositAddress using fetchDepositAddresses or fetchDepositAddressesByNetwork.
@spec handle_fetch_deposit_address(Spec.t(), module(), map(), keyword(), term()) ::
dispatch_result()
def handle_fetch_deposit_address(spec, exchange_module, params, opts, credentials) do
code = extract_param(params, :code)
if is_nil(code) do
require_code_error(spec, "fetch_deposit_address")
else
do_fetch_deposit_address(spec, exchange_module, code, params, opts, credentials)
end
end
@doc false
# Emulates fetchLeverage by selecting a single symbol from fetchLeverages.
@spec handle_fetch_leverage(Spec.t(), module(), map(), keyword(), term()) :: dispatch_result()
def handle_fetch_leverage(spec, exchange_module, params, opts, credentials) do
symbol = extract_param(params, :symbol)
if is_nil(symbol) do
require_symbol_error(spec, "fetch_leverage")
else
with {:ok, leverages} <-
call_method(
spec,
exchange_module,
:fetch_leverages,
[[symbol]],
opts,
credentials
) do
{:ok, pick_symbol_entry(leverages, symbol)}
end
end
end
@doc false
# Emulates fetchMarginMode by selecting a single symbol from fetchMarginModes.
@spec handle_fetch_margin_mode(Spec.t(), module(), map(), keyword(), term()) :: dispatch_result()
def handle_fetch_margin_mode(spec, exchange_module, params, opts, credentials) do
symbol = extract_param(params, :symbol)
if is_nil(symbol) do
require_symbol_error(spec, "fetch_margin_mode")
else
with {:ok, modes} <-
call_method(
spec,
exchange_module,
:fetch_margin_modes,
[[symbol]],
opts,
credentials
) do
{:ok, pick_symbol_entry(modes, symbol)}
end
end
end
@doc false
# Emulates fetchMarketLeverageTiers by selecting a single symbol from fetchLeverageTiers.
@spec handle_fetch_market_leverage_tiers(Spec.t(), module(), map(), keyword(), term()) ::
dispatch_result()
def handle_fetch_market_leverage_tiers(spec, exchange_module, params, opts, credentials) do
symbol = extract_param(params, :symbol)
if is_nil(symbol) do
require_symbol_error(spec, "fetch_market_leverage_tiers")
else
with {:ok, _market} <- ensure_contract_market(spec, exchange_module, symbol, opts, credentials),
{:ok, tiers} <-
call_method(
spec,
exchange_module,
:fetch_leverage_tiers,
[[symbol]],
opts,
credentials
) do
{:ok, pick_symbol_entry(tiers, symbol)}
end
end
end
@doc false
# Emulates fetchFundingRate by selecting a single symbol from fetchFundingRates.
@spec handle_fetch_funding_rate(Spec.t(), module(), map(), keyword(), term()) ::
dispatch_result()
def handle_fetch_funding_rate(spec, exchange_module, params, opts, credentials) do
symbol = extract_param(params, :symbol)
if is_nil(symbol) do
require_symbol_error(spec, "fetch_funding_rate")
else
with {:ok, _market} <- ensure_contract_market(spec, exchange_module, symbol, opts, credentials),
{:ok, rates} <-
call_method(
spec,
exchange_module,
:fetch_funding_rates,
[[symbol]],
opts,
credentials
) do
require_symbol_result(rates, symbol, spec, "fetchFundingRate() returned no data for")
end
end
end
@doc false
# Emulates fetchFundingInterval by selecting a single symbol from fetchFundingIntervals.
@spec handle_fetch_funding_interval(Spec.t(), module(), map(), keyword(), term()) ::
dispatch_result()
def handle_fetch_funding_interval(spec, exchange_module, params, opts, credentials) do
symbol = extract_param(params, :symbol)
if is_nil(symbol) do
require_symbol_error(spec, "fetch_funding_interval")
else
with {:ok, _market} <- ensure_contract_market(spec, exchange_module, symbol, opts, credentials),
{:ok, rates} <-
call_method(
spec,
exchange_module,
:fetch_funding_intervals,
[[symbol]],
opts,
credentials
) do
require_symbol_result(rates, symbol, spec, "fetchFundingInterval() returned no data for")
end
end
end
@doc false
# Emulates fetchIsolatedBorrowRate by selecting a symbol from fetchIsolatedBorrowRates.
@spec handle_fetch_isolated_borrow_rate(Spec.t(), module(), map(), keyword(), term()) ::
dispatch_result()
def handle_fetch_isolated_borrow_rate(spec, exchange_module, params, opts, credentials) do
symbol = extract_param(params, :symbol)
if is_nil(symbol) do
require_symbol_error(spec, "fetch_isolated_borrow_rate")
else
do_fetch_isolated_borrow_rate(spec, exchange_module, symbol, opts, credentials)
end
end
defp do_fetch_isolated_borrow_rate(spec, exchange_module, symbol, opts, credentials) do
with {:ok, rates} <-
call_method(spec, exchange_module, :fetch_isolated_borrow_rates, [], opts, credentials) do
require_borrow_rate_result(rates, symbol, spec)
end
end
defp require_borrow_rate_result(rates, symbol, spec) do
case pick_symbol_entry(rates, symbol) do
nil ->
{:error,
Error.exchange_error(
"fetchIsolatedBorrowRate() could not find borrow rate for #{symbol}",
exchange: exchange_atom(spec)
)}
rate ->
{:ok, rate}
end
end
# ===========================================================================
# Strategy Handlers - Order Filtering
# ===========================================================================
@doc false
# Emulates fetchOpenOrders by filtering fetchOrders.
@spec handle_fetch_open_orders(Spec.t(), module(), map(), keyword(), term()) :: dispatch_result()
def handle_fetch_open_orders(spec, exchange_module, params, opts, credentials) do
handle_fetch_filtered_orders(spec, exchange_module, params, opts, credentials, :open)
end
@doc false
# Emulates fetchClosedOrders by filtering fetchOrders.
@spec handle_fetch_closed_orders(Spec.t(), module(), map(), keyword(), term()) ::
dispatch_result()
def handle_fetch_closed_orders(spec, exchange_module, params, opts, credentials) do
handle_fetch_filtered_orders(spec, exchange_module, params, opts, credentials, :closed)
end
@doc false
# Emulates fetchCanceledOrders by filtering fetchOrders.
@spec handle_fetch_canceled_orders(Spec.t(), module(), map(), keyword(), term()) ::
dispatch_result()
def handle_fetch_canceled_orders(spec, exchange_module, params, opts, credentials) do
handle_fetch_filtered_orders(spec, exchange_module, params, opts, credentials, :canceled)
end
@doc false
# Common implementation for order status filtering.
@spec handle_fetch_filtered_orders(Spec.t(), module(), map(), keyword(), term(), atom()) ::
dispatch_result()
defp handle_fetch_filtered_orders(spec, exchange_module, params, opts, credentials, status) do
symbol = extract_param(params, :symbol)
since = extract_param(params, :since)
limit = extract_param(params, :limit)
args = [symbol, since, limit]
with {:ok, orders} <- call_method(spec, exchange_module, :fetch_orders, args, opts, credentials) do
filtered =
orders
|> filter_by_status(status)
|> filter_by_since_limit(since, limit, :timestamp)
{:ok, filtered}
end
end
@doc false
# Emulates fetchCanceledAndClosedOrders by merging filtered fetchOrders results.
@spec handle_fetch_canceled_and_closed_orders(Spec.t(), module(), map(), keyword(), term()) ::
dispatch_result()
def handle_fetch_canceled_and_closed_orders(spec, exchange_module, params, opts, credentials) do
symbol = extract_param(params, :symbol)
since = extract_param(params, :since)
limit = extract_param(params, :limit)
args = [symbol, since, limit]
with {:ok, orders} <- call_method(spec, exchange_module, :fetch_orders, args, opts, credentials) do
canceled = filter_by_status(orders, :canceled)
closed = filter_by_status(orders, :closed)
merged = canceled ++ closed
sorted = sort_by(merged, :timestamp, false)
{:ok, filter_by_since_limit(sorted, since, limit, :timestamp)}
end
end
@doc false
# Emulates fetchOrder by searching orders from supported order list methods.
@spec handle_fetch_order(Spec.t(), module(), map(), keyword(), term()) :: dispatch_result()
def handle_fetch_order(spec, exchange_module, params, opts, credentials) do
id = extract_param(params, :id)
if is_nil(id) do
require_id_error(spec, "fetch_order")
else
symbol = extract_param(params, :symbol)
since = extract_param(params, :since)
limit = extract_param(params, :limit)
do_fetch_order(spec, exchange_module, id, symbol, since, limit, opts, credentials)
end
end
defp do_fetch_order(spec, exchange_module, id, symbol, since, limit, opts, credentials) do
with {:ok, orders} <-
fetch_orders_for_lookup(spec, exchange_module, symbol, since, limit, opts, credentials) do
require_order_result(orders, id, spec)
end
end
defp require_order_result(orders, id, spec) do
case pick_order_by_id(orders, id) do
nil ->
{:error,
Error.order_not_found(
message: "No order found with id #{id}",
exchange: exchange_atom(spec)
)}
order ->
{:ok, order}
end
end
@doc false
# Emulates fetchOrderTrades by filtering fetchMyTrades output.
@spec handle_fetch_order_trades(Spec.t(), module(), map(), keyword(), term()) ::
dispatch_result()
def handle_fetch_order_trades(spec, exchange_module, params, opts, credentials) do
id = extract_param(params, :id)
if is_nil(id) do
require_id_error(spec, "fetch_order_trades")
else
do_fetch_order_trades(spec, exchange_module, params, opts, credentials, id)
end
end
defp do_fetch_order_trades(spec, exchange_module, params, opts, credentials, id) do
symbol = extract_param(params, :symbol)
since = extract_param(params, :since)
limit = extract_param(params, :limit)
case trades_from_params(params) do
{:trades, trades} ->
filter_trades_by_order(trades, id, since, limit)
{:trade_ids, trade_ids} ->
fetch_and_filter_by_trade_ids(
spec,
exchange_module,
symbol,
since,
limit,
opts,
credentials,
trade_ids
)
:no_trades_param ->
fetch_and_filter_by_order_id(
spec,
exchange_module,
symbol,
since,
limit,
opts,
credentials,
id
)
end
end
defp filter_trades_by_order(trades, id, since, limit) do
filtered =
trades
|> filter_by_order_id(id)
|> filter_by_since_limit(since, limit, :timestamp)
{:ok, filtered}
end
defp fetch_and_filter_by_trade_ids(spec, module, symbol, since, limit, opts, creds, trade_ids) do
args = [symbol, since, limit]
with {:ok, trades} <- call_method(spec, module, :fetch_my_trades, args, opts, creds) do
filtered =
trades
|> filter_by_trade_ids(trade_ids)
|> filter_by_since_limit(since, limit, :timestamp)
{:ok, filtered}
end
end
defp fetch_and_filter_by_order_id(spec, module, symbol, since, limit, opts, creds, id) do
args = [symbol, since, limit]
with {:ok, trades} <- call_method(spec, module, :fetch_my_trades, args, opts, creds) do
filtered =
trades
|> filter_by_order_id(id)
|> filter_by_since_limit(since, limit, :timestamp)
{:ok, filtered}
end
end
@doc false
# Emulates fetchMyTrades by extracting trades from fetchOrders output.
@spec handle_fetch_my_trades(Spec.t(), module(), map(), keyword(), term()) :: dispatch_result()
def handle_fetch_my_trades(spec, exchange_module, params, opts, credentials) do
symbol = extract_param(params, :symbol)
since = extract_param(params, :since)
limit = extract_param(params, :limit)
args = [symbol, since, limit]
with {:ok, orders} <- call_method(spec, exchange_module, :fetch_orders, args, opts, credentials) do
trades =
orders
|> orders_to_trades()
|> filter_by_symbol(symbol)
|> filter_by_since_limit(since, limit, :timestamp)
{:ok, trades}
end
end
# ===========================================================================
# Strategy Handlers - Deposits/Withdrawals
# ===========================================================================
@doc false
# Emulates fetchDepositsWithdrawals via deposits+withdrawals or ledger fallback.
@spec handle_fetch_deposits_withdrawals(Spec.t(), module(), map(), keyword(), term()) ::
dispatch_result()
def handle_fetch_deposits_withdrawals(spec, exchange_module, params, opts, credentials) do
code = extract_param(params, :code)
since = extract_param(params, :since)
limit = extract_param(params, :limit)
cond do
endpoint_available?(spec, :fetch_deposits) or endpoint_available?(spec, :fetch_withdrawals) ->
fetch_deposits_and_withdrawals(spec, exchange_module, code, since, limit, opts, credentials)
endpoint_available?(spec, :fetch_ledger) ->
fetch_transactions_from_ledger(spec, exchange_module, code, since, limit, opts, credentials)
true ->
{:error,
Error.not_supported(
message: "fetchDepositsWithdrawals() is not supported yet",
exchange: exchange_atom(spec)
)}
end
end
defp fetch_deposits_and_withdrawals(spec, module, code, since, limit, opts, creds) do
args = [code, since, limit]
with {:ok, deposits} <- call_optional_method(spec, module, :fetch_deposits, args, opts, creds),
{:ok, withdrawals} <- call_optional_method(spec, module, :fetch_withdrawals, args, opts, creds) do
combined = ensure_list(deposits) ++ ensure_list(withdrawals)
{:ok, filter_by_since_limit(combined, since, limit, :timestamp)}
end
end
defp fetch_transactions_from_ledger(spec, module, code, since, limit, opts, creds) do
args = [code, since, limit]
with {:ok, ledger} <- call_method(spec, module, :fetch_ledger, args, opts, creds) do
filtered =
ledger
|> ensure_list()
|> filter_ledger_transactions()
|> filter_by_since_limit(since, limit, :timestamp)
{:ok, filtered}
end
end
@doc false
# Performs fetchDepositAddress resolution based on available address methods.
@spec do_fetch_deposit_address(Spec.t(), module(), String.t(), map(), keyword(), term()) ::
dispatch_result()
defp do_fetch_deposit_address(spec, exchange_module, code, params, opts, credentials) do
cond do
endpoint_available?(spec, :fetch_deposit_addresses) ->
fetch_deposit_address_from_addresses(spec, exchange_module, code, opts, credentials)
endpoint_available?(spec, :fetch_deposit_addresses_by_network) ->
network = extract_param(params, :network)
fetch_deposit_address_by_network(spec, exchange_module, code, network, opts, credentials)
true ->
{:error,
Error.not_supported(
message: "fetchDepositAddress() is not supported yet",
exchange: exchange_atom(spec)
)}
end
end
defp fetch_deposit_address_from_addresses(spec, module, code, opts, creds) do
with {:ok, addresses} <- call_method(spec, module, :fetch_deposit_addresses, [[code]], opts, creds) do
case pick_code_entry(addresses, code) do
nil -> deposit_address_not_found_error(spec, code)
address -> {:ok, address}
end
end
end
defp fetch_deposit_address_by_network(spec, module, code, network, opts, creds) do
cleaned_opts = drop_params(opts, [:network])
with {:ok, address_map} <-
call_method(
spec,
module,
:fetch_deposit_addresses_by_network,
[code],
cleaned_opts,
creds
) do
case resolve_network_address(address_map, network) do
nil -> deposit_address_not_found_error(spec, code)
address -> {:ok, address}
end
end
end
defp deposit_address_not_found_error(spec, code) do
{:error,
Error.invalid_parameters(
message: "fetchDepositAddress() could not find a deposit address for #{code}",
exchange: exchange_atom(spec)
)}
end
# ===========================================================================
# Error Helpers (reduces nesting in handlers)
# ===========================================================================
defp require_symbol_error(spec, method_name) do
{:error,
Error.invalid_parameters(
message: "#{method_name} requires a symbol argument",
exchange: exchange_atom(spec)
)}
end
defp require_code_error(spec, method_name) do
{:error,
Error.invalid_parameters(
message: "#{method_name} requires a code argument",
exchange: exchange_atom(spec)
)}
end
defp require_id_error(spec, method_name) do
{:error,
Error.invalid_parameters(
message: "#{method_name} requires an id argument",
exchange: exchange_atom(spec)
)}
end
defp require_symbol_result(entries, symbol, spec, error_prefix) do
case pick_symbol_entry(entries, symbol) do
nil ->
{:error, Error.exchange_error("#{error_prefix} #{symbol}", exchange: exchange_atom(spec))}
result ->
{:ok, result}
end
end
# ===========================================================================
# Contract Market Validation
# ===========================================================================
@doc false
# Ensures a market is a contract market before returning it.
@spec ensure_contract_market(Spec.t(), module(), String.t() | nil, keyword(), term()) ::
{:ok, map()} | {:error, Error.t()}
defp ensure_contract_market(spec, _module, nil, _opts, _creds) do
{:error,
Error.invalid_parameters(
message: "Method requires a symbol argument",
exchange: exchange_atom(spec)
)}
end
defp ensure_contract_market(spec, exchange_module, symbol, opts, credentials) do
with {:ok, market} <- fetch_market(spec, exchange_module, symbol, opts, credentials),
:ok <- validate_contract_market(market, spec) do
{:ok, market}
end
end
defp validate_contract_market(market, spec) do
if truthy?(get_field(market, :contract)) do
:ok
else
{:error,
Error.invalid_parameters(
message: "Method supports contract markets only",
exchange: exchange_atom(spec)
)}
end
end
@doc false
# Fetches a single market by symbol using fetchMarkets.
@spec fetch_market(Spec.t(), module(), String.t(), keyword(), term()) ::
{:ok, map()} | {:error, Error.t()}
defp fetch_market(spec, exchange_module, symbol, opts, credentials) do
with {:ok, markets} <- call_method(spec, exchange_module, :fetch_markets, [], opts, credentials) do
case pick_symbol_entry(markets, symbol) do
nil ->
{:error,
Error.invalid_parameters(
message: "Unknown market symbol #{symbol}",
exchange: exchange_atom(spec)
)}
market ->
{:ok, market}
end
end
end
# ===========================================================================
# Method Calling Infrastructure
# ===========================================================================
@doc false
# Calls a unified method, injecting credentials when required by spec endpoints.
@spec call_method(Spec.t(), module(), atom(), list(), keyword(), term()) :: dispatch_result()
defp call_method(spec, exchange_module, method, args, opts, credentials) do
with :ok <- validate_endpoint_available(spec, method),
:ok <- validate_credentials_if_required(spec, method, credentials) do
execute_method(exchange_module, method, args, opts, credentials, auth_required?(spec, method))
end
end
defp validate_endpoint_available(spec, method) do
if endpoint_available?(spec, method) do
:ok
else
{:error,
Error.not_supported(
message: "#{method} is not supported by this exchange",
exchange: exchange_atom(spec)
)}
end
end
defp validate_credentials_if_required(spec, method, credentials) do
if auth_required?(spec, method) and is_nil(credentials) do
{:error,
Error.invalid_credentials(
message: "Credentials required for #{method}",
exchange: exchange_atom(spec)
)}
else
:ok
end
end
defp execute_method(module, method, args, opts, credentials, true = _auth_required) do
apply(module, method, [credentials | args] ++ [opts])
end
defp execute_method(module, method, args, opts, _credentials, false = _auth_required) do
apply(module, method, args ++ [opts])
end
@doc false
# Calls a method if available, otherwise returns {:ok, []}.
@spec call_optional_method(Spec.t(), module(), atom(), list(), keyword(), term()) ::
{:ok, list()} | {:error, Error.t()}
defp call_optional_method(spec, exchange_module, method, args, opts, credentials) do
if endpoint_available?(spec, method) do
case call_method(spec, exchange_module, method, args, opts, credentials) do
{:ok, result} -> {:ok, ensure_list(result)}
{:error, _} = error -> error
end
else
{:ok, []}
end
end
@doc false
# Checks whether an endpoint exists for the method in the spec.
@spec endpoint_available?(Spec.t(), atom()) :: boolean()
defp endpoint_available?(%Spec{endpoints: endpoints}, method) do
Enum.any?(endpoints, fn endpoint -> endpoint[:name] == method end)
end
@doc false
# Determines whether an endpoint requires authentication.
@spec auth_required?(Spec.t(), atom()) :: boolean()
defp auth_required?(%Spec{endpoints: endpoints}, method) do
case Enum.find(endpoints, fn endpoint -> endpoint[:name] == method end) do
%{auth: true} -> true
_ -> false
end
end
@doc false
# Fetches orders for lookup, preferring fetchOrders if available.
@spec fetch_orders_for_lookup(Spec.t(), module(), String.t() | nil, term(), term(), keyword(), term()) ::
{:ok, list()} | {:error, Error.t()}
defp fetch_orders_for_lookup(spec, module, symbol, since, limit, opts, creds) do
args = [symbol, since, limit]
cond do
endpoint_available?(spec, :fetch_orders) ->
call_method(spec, module, :fetch_orders, args, opts, creds)
has_any_order_endpoint?(spec) ->
combine_order_endpoints(spec, module, args, opts, creds)
true ->
{:error,
Error.not_supported(
message: "fetchOrder() is not supported yet",
exchange: exchange_atom(spec)
)}
end
end
defp has_any_order_endpoint?(spec) do
endpoint_available?(spec, :fetch_open_orders) or
endpoint_available?(spec, :fetch_closed_orders) or
endpoint_available?(spec, :fetch_canceled_orders)
end
defp combine_order_endpoints(spec, module, args, opts, creds) do
with {:ok, open} <- call_optional_method(spec, module, :fetch_open_orders, args, opts, creds),
{:ok, closed} <- call_optional_method(spec, module, :fetch_closed_orders, args, opts, creds),
{:ok, canceled} <- call_optional_method(spec, module, :fetch_canceled_orders, args, opts, creds) do
{:ok, open ++ closed ++ canceled}
end
end
# ===========================================================================
# Trade Extraction Helpers
# ===========================================================================
@doc false
# Extracts trades from params or returns trade IDs when provided.
@spec trades_from_params(map()) :: {:trades, list()} | {:trade_ids, list()} | :no_trades_param
defp trades_from_params(params) do
trades = extract_param(params, :trades)
extract_trades_or_ids(trades, params)
end
defp extract_trades_or_ids(trades, _params) when is_list(trades) do
if Enum.all?(trades, &is_map/1) do
{:trades, trades}
else
{:trade_ids, extract_trade_ids(trades)}
end
end
defp extract_trades_or_ids(_trades, params) do
order = extract_param(params, :order)
extract_trades_from_order(order)
end
defp extract_trades_from_order(order) when is_map(order) do
case get_field(order, :trades) do
order_trades when is_list(order_trades) -> {:trades, order_trades}
_ -> :no_trades_param
end
end
defp extract_trades_from_order(_order), do: :no_trades_param
@doc false
# Extracts trade IDs from trade maps or strings.
@spec extract_trade_ids(list()) :: list()
defp extract_trade_ids(trades) do
trades
|> Enum.map(&extract_single_trade_id/1)
|> Enum.reject(&is_nil/1)
end
defp extract_single_trade_id(trade) when is_binary(trade), do: trade
defp extract_single_trade_id(trade) when is_map(trade), do: get_field(trade, :id)
defp extract_single_trade_id(_trade), do: nil
@doc false
# Filters trades by order id.
@spec filter_by_order_id(list(), String.t()) :: list()
defp filter_by_order_id(trades, id) do
trades
|> ensure_list()
|> Enum.filter(fn trade ->
get_field(trade, :order_id) == id or get_field(trade, :order) == id
end)
end
@doc false
# Filters trades by trade IDs list.
@dialyzer {:nowarn_function, filter_by_trade_ids: 2}
@spec filter_by_trade_ids(list(), list()) :: list()
defp filter_by_trade_ids(trades, trade_ids) do
trade_id_set = MapSet.new(trade_ids)
trades
|> ensure_list()
|> Enum.filter(fn trade ->
trade_id = get_field(trade, :id)
MapSet.member?(trade_id_set, trade_id)
end)
end
@doc false
# Extracts trades from orders, flattening order trade lists.
@spec orders_to_trades(list()) :: list()
defp orders_to_trades(orders) do
orders
|> ensure_list()
|> Enum.flat_map(&extract_order_trades/1)
end
defp extract_order_trades(order) do
case get_field(order, :trades) do
trades when is_list(trades) -> trades
_ -> []
end
end
# ===========================================================================
# Filtering Helpers
# ===========================================================================
@doc false
# Filters entries by symbol if symbol is present.
@spec filter_by_symbol(list(), String.t() | nil) :: list()
defp filter_by_symbol(entries, nil), do: ensure_list(entries)
defp filter_by_symbol(entries, symbol) do
entries
|> ensure_list()
|> Enum.filter(fn entry -> get_field(entry, :symbol) == symbol end)
end
@doc false
# Filters orders by normalized status.
@spec filter_by_status(list(), atom()) :: list()
defp filter_by_status(orders, status) do
target = normalize_status(status)
orders
|> ensure_list()
|> Enum.filter(fn order ->
normalize_status(get_field(order, :status)) == target
end)
end
@doc false
# Filters ledger entries to deposit/withdrawal transactions.
@spec filter_ledger_transactions(list()) :: list()
defp filter_ledger_transactions(ledger) do
ledger
|> ensure_list()
|> Enum.filter(&deposit_or_withdrawal?/1)
end
defp deposit_or_withdrawal?(entry) do
type = entry |> extract_transaction_type() |> normalize_status()
type in ["deposit", "withdrawal"]
end
defp extract_transaction_type(entry) do
case get_field(entry, :type) do
nil -> extract_transact_type(entry)
value -> value
end
end
defp extract_transact_type(entry) do
case get_field(entry, :transact_type) do
nil -> Map.get(entry, "transactType")
value -> value
end
end
@doc false
# Applies CCXT-style since/limit filtering.
@spec filter_by_since_limit(list(), term(), term(), atom()) :: list()
defp filter_by_since_limit(entries, since, limit, key) do
filtered = filter_by_since(entries, since, key)
apply_limit(filtered, since, limit, key)
end
defp filter_by_since(entries, nil, _key), do: ensure_list(entries)
defp filter_by_since(entries, since, key) do
entries
|> ensure_list()
|> Enum.filter(fn entry ->
case get_field(entry, key) do
nil -> false
value -> value >= since
end
end)
end
defp apply_limit(filtered, since, limit, key) do
from_start = not is_nil(since)
filter_by_limit(filtered, limit, key, from_start)
end
@doc false
# Limits a list while respecting sort order of the key field.
@spec filter_by_limit(list(), term(), atom(), boolean()) :: list()
defp filter_by_limit(entries, nil, _key, _from_start), do: entries
defp filter_by_limit([], _limit, _key, _from_start), do: []
defp filter_by_limit(entries, limit, key, from_start) do
ascending = infer_ascending(entries, key)
take_with_direction(entries, limit, from_start, ascending)
end
defp take_with_direction(entries, limit, true, true), do: Enum.take(entries, limit)
defp take_with_direction(entries, limit, true, false), do: Enum.take(entries, -limit)
defp take_with_direction(entries, limit, false, true), do: Enum.take(entries, -limit)
defp take_with_direction(entries, limit, false, false), do: Enum.take(entries, limit)
@doc false
# Infers whether a list is sorted ascending by the given key.
@spec infer_ascending(list(), atom()) :: boolean()
defp infer_ascending(entries, key) do
first = entries |> List.first() |> get_field(key)
last = entries |> List.last() |> get_field(key)
is_nil(first) or is_nil(last) or first <= last
end
@doc false
# Sorts entries by a key, defaulting missing values to zero.
@spec sort_by(list(), atom(), boolean()) :: list()
defp sort_by(entries, key, descending) do
direction = if descending, do: :desc, else: :asc
Enum.sort_by(entries, fn entry -> get_field(entry, key) || @zero end, direction)
end
# ===========================================================================
# Entry Picking Helpers
# ===========================================================================
@doc false
# Returns the matching entry for a symbol from a list or map.
@spec pick_symbol_entry(term(), String.t() | nil) :: term()
defp pick_symbol_entry(_entries, nil), do: nil
defp pick_symbol_entry(entries, symbol) when is_map(entries), do: Map.get(entries, symbol)
defp pick_symbol_entry(entries, symbol) when is_list(entries) do
Enum.find(entries, fn entry -> get_field(entry, :symbol) == symbol end)
end
defp pick_symbol_entry(_entries, _symbol), do: nil
@doc false
# Returns the matching entry for a currency code from a map.
@spec pick_code_entry(term(), String.t() | nil) :: term()
defp pick_code_entry(_entries, nil), do: nil
defp pick_code_entry(entries, code) when is_map(entries), do: Map.get(entries, code)
defp pick_code_entry(_entries, _code), do: nil
@doc false
# Finds an order by id within a list of orders.
@spec pick_order_by_id(list(), String.t()) :: term()
defp pick_order_by_id(orders, id) do
orders
|> ensure_list()
|> Enum.find(fn order -> get_field(order, :id) == id end)
end
# ===========================================================================
# Normalization Helpers
# ===========================================================================
@doc false
# Normalizes symbols input to a list.
@spec normalize_symbols(term()) :: list() | nil
defp normalize_symbols(nil), do: nil
defp normalize_symbols(symbols) when is_list(symbols), do: symbols
defp normalize_symbols(symbol) when is_binary(symbol), do: [symbol]
defp normalize_symbols(_), do: nil
@doc false
# Normalizes status values to lowercase strings.
@spec normalize_status(term()) :: String.t() | nil
defp normalize_status(status) when is_atom(status) do
status |> Atom.to_string() |> String.downcase()
end
defp normalize_status(status) when is_binary(status), do: String.downcase(status)
defp normalize_status(_), do: nil
# ===========================================================================
# Field Access Helpers
# ===========================================================================
@doc false
# Extracts a field from maps or structs, supporting string keys.
@spec get_field(term(), atom()) :: term()
defp get_field(%{} = data, key) do
Map.get(data, key) || Map.get(data, Atom.to_string(key)) || Map.get(data, camelize_key(key))
end
defp get_field(_data, _key), do: nil
@doc false
# Converts snake_case atom keys to camelCase strings for lookup.
@spec camelize_key(atom()) :: String.t()
defp camelize_key(key) when is_atom(key) do
key
|> Atom.to_string()
|> String.split("_")
|> Enum.with_index()
|> Enum.map_join(fn
{segment, 0} -> segment
{segment, _} -> String.capitalize(segment)
end)
end
@doc false
# Extracts a param from map, supporting string keys.
@spec extract_param(map(), atom()) :: term()
defp extract_param(params, key) when is_map(params) do
Map.get(params, key) || Map.get(params, Atom.to_string(key))
end
@doc false
# Drops param keys from opts[:params] if present.
@spec drop_params(keyword(), [atom()]) :: keyword()
defp drop_params(opts, keys) do
params = opts |> Keyword.get(:params, %{}) |> Map.new()
pruned =
Enum.reduce(keys, params, fn key, acc ->
acc
|> Map.delete(key)
|> Map.delete(Atom.to_string(key))
end)
Keyword.put(opts, :params, pruned)
end
@doc false
# Resolves network-specific deposit addresses.
@spec resolve_network_address(term(), String.t() | nil) :: term()
defp resolve_network_address(address_map, nil) when is_map(address_map) do
address_map |> Map.values() |> List.first()
end
defp resolve_network_address(address_map, network) when is_map(address_map) do
Map.get(address_map, network) || Map.get(address_map, String.downcase(to_string(network)))
end
defp resolve_network_address(_address_map, _network), do: nil
@doc false
# Ensures a value is returned as a list.
@spec ensure_list(term()) :: list()
defp ensure_list(list) when is_list(list), do: list
defp ensure_list(nil), do: []
defp ensure_list(value), do: [value]
@doc false
# Returns true for truthy values (non-nil and not false).
@spec truthy?(term()) :: boolean()
defp truthy?(value), do: value not in [nil, false]
# ===========================================================================
# Currency Derivation
# ===========================================================================
@doc false
# Derives trading limits from markets data.
@spec derive_trading_limits(list(), list() | nil) :: map()
defp derive_trading_limits(markets, symbols) do
markets
|> ensure_list()
|> Enum.reduce(%{}, fn market, acc ->
symbol = get_field(market, :symbol)
if symbol_filter_match?(symbol, symbols) do
limits = get_nested_field(market, [:limits, :amount])
Map.put(acc, symbol, limits)
else
acc
end
end)
end
@doc false
# Derives currencies map from market list (base + quote currencies).
@spec derive_currencies_from_markets(list()) :: map()
defp derive_currencies_from_markets(markets) do
currencies =
markets
|> ensure_list()
|> Enum.flat_map(fn market ->
[
currency_from_market(market, :base),
currency_from_market(market, :quote)
]
end)
|> Enum.reject(&is_nil/1)
currencies
|> Enum.group_by(&get_field(&1, :code))
|> Enum.map(fn {_code, entries} -> pick_highest_precision(entries) end)
|> Enum.reject(&is_nil/1)
|> Map.new(fn currency -> {get_field(currency, :code), currency} end)
end
@doc false
# Builds a currency map from a market entry.
@spec currency_from_market(map(), :base | :quote) :: map() | nil
defp currency_from_market(market, side) do
{code_key, id_key, numeric_id_key, precision_keys} = currency_keys_for_side(side)
code = get_field(market, code_key)
if is_nil(code) do
nil
else
precision =
precision_keys
|> Enum.find_value(fn key -> get_nested_field(market, [:precision, key]) end)
|> fallback_precision()
%{
id: get_field(market, id_key),
code: code,
numeric_id: get_field(market, numeric_id_key),
precision: precision,
networks: %{},
raw: nil
}
end
end
defp currency_keys_for_side(:base), do: {:base, :base_id, :base_numeric_id, [:base, :amount]}
defp currency_keys_for_side(:quote), do: {:quote, :quote_id, :quote_numeric_id, [:quote, :price]}
@doc false
# Picks the currency entry with the highest precision value.
@spec pick_highest_precision(list()) :: map() | nil
defp pick_highest_precision([]), do: nil
defp pick_highest_precision(entries) do
Enum.max_by(entries, fn entry -> precision_value(get_field(entry, :precision)) end, fn -> nil end)
end
@doc false
# Returns numeric precision value for comparisons.
@spec precision_value(term()) :: number()
defp precision_value(value) when is_number(value), do: value
defp precision_value(_), do: @zero
@doc false
# Returns fallback precision when market precision is missing.
@spec fallback_precision(term()) :: number()
defp fallback_precision(nil), do: @default_currency_precision
defp fallback_precision(value), do: value
@doc false
# Matches symbol filter if symbols list is provided.
@spec symbol_filter_match?(String.t() | nil, list() | nil) :: boolean()
defp symbol_filter_match?(_symbol, nil), do: true
defp symbol_filter_match?(nil, _symbols), do: false
defp symbol_filter_match?(symbol, symbols), do: symbol in symbols
@doc false
# Retrieves nested values with atom/string keys.
@spec get_nested_field(term(), list()) :: term()
defp get_nested_field(value, []), do: value
defp get_nested_field(%{} = map, [key | rest]) do
result = Map.get(map, key) || Map.get(map, Atom.to_string(key))
get_nested_field(result, rest)
end
defp get_nested_field(_value, _rest), do: nil
# ===========================================================================
# Scope and Method Name Helpers
# ===========================================================================
@doc false
# Normalizes scope from string to atom for indexing.
@spec normalize_scope(atom() | String.t()) :: atom()
defp normalize_scope(:rest), do: :rest
defp normalize_scope(:ws), do: :ws
defp normalize_scope("rest"), do: :rest
defp normalize_scope("ws"), do: :ws
defp normalize_scope(_), do: :unknown
@doc false
# Converts a camelCase method name to snake_case atom.
@spec to_method_atom(String.t() | nil) :: atom() | nil
# sobelow_skip ["DOS.StringToAtom"]
defp to_method_atom(name) when is_binary(name) do
name
|> Macro.underscore()
|> String.to_atom()
end
defp to_method_atom(_), do: nil
@doc false
# Formats reasons as a message suffix.
@spec emulation_reason_suffix(map()) :: String.t()
defp emulation_reason_suffix(entry) do
case Map.get(entry, "reasons", []) do
[] -> ""
reasons -> " (#{Enum.join(reasons, ", ")})"
end
end
@doc false
# Resolves exchange atom for error reporting.
# sobelow_skip ["DOS.StringToAtom"]
# Safe: spec.id comes from trusted extraction data, not user input
@spec exchange_atom(Spec.t()) :: atom()
defp exchange_atom(%Spec{exchange_id: exchange_id, id: id}) do
exchange_id || String.to_atom(id)
end
end