Bourse.Multi (bourse v0.1.0)

Copy Markdown View Source

Parallel fetch operations across multiple exchanges.

Enables fetching data from multiple exchanges concurrently with graceful handling of partial failures. Essential for dashboards, price comparison, and arbitrage detection.

Key Features

  • Partial failure handling: one exchange failing doesn't kill the whole request
  • Concurrent execution: uses Task.async_stream/3 for efficient parallel fetching
  • Configurable timeouts: per-exchange timeout with a sensible default
  • Per-exchange symbols: map inputs allow different symbols per exchange
  • Result helpers: easy extraction of successes and failures

Exchange struct, not module atoms

Unlike the original ccxt_client_bak port (which dispatched on generated exchange modules like Bourse.Bybit), this version operates on %Bourse.Exchange{} structs built via Bourse.exchange/2. Each parallel call routes through the unified API (Bourse.fetch_ticker/3, etc.) with the exchange struct as the first argument, and result maps are keyed by the %Bourse.Exchange{} struct.

Usage

{:ok, bybit} = Bourse.exchange("bybit")
{:ok, binance} = Bourse.exchange("binance")

# Uniform symbol across all exchanges
result = Bourse.Multi.fetch_tickers([bybit, binance], "BTC/USDT")
# => %{bybit => {:ok, %{...}}, binance => {:ok, %{...}}}

# Per-exchange symbols (exchanges use different symbol formats)
result = Bourse.Multi.fetch_tickers(%{bybit => "BTC/USDT:USDT", deribit => "BTC-PERPETUAL"})

# Get only successful results (unwrapped)
tickers = Bourse.Multi.successes(result)
# => %{bybit => %{...}, binance => %{...}}

# Check which exchanges failed (unwrapped reasons)
failures = Bourse.Multi.failures(result)
# => %{}  (empty if all succeeded)

# Generic parallel call for any unified function
result = Bourse.Multi.parallel_call([bybit, binance], :fetch_balance, [], timeout: 15_000)

Common Pitfalls

  • Always filter before consuming: Multi results mix {:ok, _} and {:error, _} tuples. Call successes/1 before passing to downstream consumers that expect unwrapped values.
  • Use map form for cross-exchange symbols: the same instrument has different symbol formats per exchange. The map form %{deribit => "BTC-PERPETUAL", bybit => "BTC/USDT:USDT"} handles this.

Notes

  • fetch_tickers/2,3 and fetch_order_books/2,3 are public endpoint calls (no authentication required).
  • For authenticated calls, use parallel_call/4 against exchanges built with credentials.
  • Timeout is per-exchange, not total (total time ≈ max(individual timeouts)).

Summary

Types

Map of exchange structs to a per-exchange first argument (e.g., symbol)

Result map keyed by exchange struct, with {:ok, value} | {:error, reason} values

Functions

Returns only failed results, discarding successes.

Fetches order books from multiple exchanges in parallel.

Map form with options, or list+symbol form (defaults opts to []). See fetch_order_books/1.

List form with a shared symbol and options. Accepts :timeout and :limit in opts. See fetch_order_books/1.

Fetches tickers from multiple exchanges in parallel.

Map form with options, or list+symbol form (defaults opts to []). See fetch_tickers/1.

List form with a shared symbol and options. Accepts :timeout in opts. See fetch_tickers/1.

Generic parallel call — invokes any unified Bourse function on multiple exchanges.

Returns only successful results, discarding errors.

Types

exchange_map()

@type exchange_map() :: %{required(Bourse.Exchange.t()) => term()}

Map of exchange structs to a per-exchange first argument (e.g., symbol)

result(t)

@type result(t) :: %{required(Bourse.Exchange.t()) => {:ok, t} | {:error, term()}}

Result map keyed by exchange struct, with {:ok, value} | {:error, reason} values

Functions

failures(results)

@spec failures(result(term())) :: %{required(Bourse.Exchange.t()) => term()}

Returns only failed results, discarding successes.

Unwraps {:error, reason} tuples to just reasons.

Examples

iex> results = %{a: {:ok, %{price: 100}}, b: {:error, :timeout}}
iex> Bourse.Multi.failures(results)
%{b: :timeout}

fetch_order_books(map)

@spec fetch_order_books(exchange_map()) :: result(map())

Fetches order books from multiple exchanges in parallel.

Accepts either a list of exchanges with a shared symbol, or a map of %{exchange => symbol} for per-exchange symbol formats.

Parameters

  • exchange_map%{%Exchange{} => symbol}
  • exchanges — list of %Exchange{}
  • symbol — unified symbol, used with list form
  • opts:

Examples

iex> Bourse.Multi.fetch_order_books([], "BTC/USDT")
%{}

iex> Bourse.Multi.fetch_order_books(%{})
%{}

fetch_order_books(map, opts)

@spec fetch_order_books(
  exchange_map(),
  keyword()
) :: result(map())
@spec fetch_order_books([Bourse.Exchange.t()], String.t()) :: result(map())

Map form with options, or list+symbol form (defaults opts to []). See fetch_order_books/1.

fetch_order_books(list, symbol, opts)

@spec fetch_order_books([Bourse.Exchange.t()], String.t(), keyword()) :: result(map())

List form with a shared symbol and options. Accepts :timeout and :limit in opts. See fetch_order_books/1.

fetch_tickers(map)

@spec fetch_tickers(exchange_map()) :: result(map())

Fetches tickers from multiple exchanges in parallel.

Returns partial results — one exchange failing doesn't kill the whole request.

Accepts either a list of exchanges with a shared symbol, or a map of %{exchange => symbol} for per-exchange symbol formats.

Parameters

  • exchange_map%{%Exchange{} => symbol} (per-exchange symbols)
  • exchanges — list of %Exchange{} (shared symbol)
  • symbol — unified symbol (e.g., "BTC/USDT"), used with list form
  • opts:
    • :timeout — per-exchange timeout in ms (default: 10000)
    • any other option is forwarded to Bourse.fetch_ticker/3

Examples

iex> Bourse.Multi.fetch_tickers([], "BTC/USDT")
%{}

iex> Bourse.Multi.fetch_tickers(%{})
%{}

fetch_tickers(map, opts)

@spec fetch_tickers(
  exchange_map(),
  keyword()
) :: result(map())
@spec fetch_tickers([Bourse.Exchange.t()], String.t()) :: result(map())

Map form with options, or list+symbol form (defaults opts to []). See fetch_tickers/1.

fetch_tickers(list, symbol, opts)

@spec fetch_tickers([Bourse.Exchange.t()], String.t(), keyword()) :: result(map())

List form with a shared symbol and options. Accepts :timeout in opts. See fetch_tickers/1.

parallel_call(exchange_or_map, function_name, args, opts \\ [])

@spec parallel_call(
  exchange_map() | [Bourse.Exchange.t()],
  atom(),
  [term()],
  keyword()
) ::
  result(term())

Generic parallel call — invokes any unified Bourse function on multiple exchanges.

Accepts either a list of %Exchange{} structs with shared args, or a map of %{exchange => value} where value is prepended as the first argument (after the exchange) to each call — enabling per-exchange symbols or other values.

Each call routes through Bourse.<function_name>(exchange, args...). This is the core function the specialized helpers build on.

Parameters

  • exchange_or_map — list of %Exchange{}, or %{%Exchange{} => first_arg}
  • function_name — unified function atom (e.g., :fetch_ticker, :fetch_balance)
  • args — argument list. List form: full args after the exchange. Map form: shared args appended after the per-exchange value.
  • opts:
    • :timeout — per-exchange timeout in ms (default: 10000)

Examples

iex> Bourse.Multi.parallel_call([], :fetch_ticker, ["BTC/USDT"])
%{}

iex> Bourse.Multi.parallel_call(%{}, :fetch_ticker, [])
%{}

successes(results)

@spec successes(result(t)) :: %{required(Bourse.Exchange.t()) => t} when t: var

Returns only successful results, discarding errors.

Unwraps {:ok, value} tuples to just values. The result map is keyed the same way as the input (by %Exchange{} for fetch_*/parallel_call output).

Examples

iex> results = %{a: {:ok, %{price: 100}}, b: {:error, :timeout}}
iex> Bourse.Multi.successes(results)
%{a: %{price: 100}}