Bourse.Testnet (bourse v0.7.0)

Copy Markdown View Source

ETS-backed credential registry for integration testing.

Supports multiple credential sets per exchange for multi-API exchanges (e.g., Binance spot vs futures testnets require separate credentials).

Credentials are registered once at test startup (in test_helper.exs), then retrieved by generated tests at runtime. ETS with read_concurrency: true allows lock-free reads from any process; the table is :protected, so writes serialize through the owning GenServer (no foreign process can mutate or overwrite registered credentials).

Not an application child: only test and recording harnesses use it, so callers start it explicitly rather than every consumer paying for it at boot.

When the registry is not running

Because nothing in the library starts it, every function here has to answer for the case where the caller forgot to. Application.ensure_all_started(:bourse) does not help — the application starts fine, this process simply is not in its tree. Ask started?/0 when in doubt. The two halves of the API answer differently, by shape:

Usage

# In test_helper.exs - start the registry, then register each sandbox
{:ok, _} = Bourse.Testnet.start_link([])

Bourse.Testnet.register_all_from_env([
  {:bybit, testnet: true},
  {:binance, testnet: true},
  {:binance, :futures, testnet: true}
])

# In tests - retrieve credentials for specific sandbox
creds = Bourse.Testnet.creds(:bybit)             # default sandbox
creds = Bourse.Testnet.creds(:binance, :futures) # futures sandbox

Sandbox Keys

Multi-API exchanges have different testnets per API section:

Sandbox KeyEnv Var InfixExample Hostname
:default(none)testnet.binance.vision
:futures_FUTURESdemo-fapi.binance.com
:coinm_COINMdemo-dapi.binance.com

Env Var Convention

  • {EXCHANGE}[_{SANDBOX}]_TESTNET_API_KEY
  • {EXCHANGE}[_{SANDBOX}]_TESTNET_API_SECRET
  • {EXCHANGE}_PASSPHRASE (if passphrase: true)

OKX is an explicit exception: integration tests use the international demo credentials OKX_INTL_API_KEY, OKX_INTL_API_SECRET, and OKX_INTL_PASSPHRASE against www.okx.com.

When :testnet is true, the _TEST_ infix is accepted as a silent fallback (e.g. BINANCE_FUTURES_TEST_API_KEY is treated as an alias for BINANCE_FUTURES_TESTNET_API_KEY). The canonical _TESTNET_ name remains preferred and is the one surfaced in creds!/2 error messages.

Summary

Types

Config entry for register_all_from_env/1

Returned by every write when the registry process is not running

Options for register_from_env/2,3

Functions

Returns a specification to start this module under a supervisor.

Clears all registered credentials (for test isolation).

Get credentials for an exchange/sandbox. Returns nil if unregistered.

Get credentials or raise ArgumentError with a helpful message.

Env var prefix for a given exchange + sandbox combination.

List unique exchange atoms with any registered credentials.

Register credentials for multiple exchanges from environment variables.

Register credentials from environment variables.

Returns true when credentials are registered for the given exchange/sandbox.

List all registered {exchange, sandbox_key} tuples.

Derive sandbox_key from a sandbox URL's hostname/path.

Starts the Testnet registry GenServer (owns the ETS table).

Returns true when the registry process is running.

Removes the credentials registered for a single {exchange, sandbox_key}.

Types

config_entry()

@type config_entry() :: {atom(), register_opts()} | {atom(), atom(), register_opts()}

Config entry for register_all_from_env/1

not_started()

@type not_started() :: {:error, :not_started}

Returned by every write when the registry process is not running

register_opts()

@type register_opts() :: [
  testnet: boolean(),
  passphrase: boolean(),
  sandbox: boolean(),
  secret_suffix: String.t()
]

Options for register_from_env/2,3

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

clear()

@spec clear() :: :ok | not_started()

Clears all registered credentials (for test isolation).

Returns {:error, :not_started} if the registry is not running.

creds(exchange, sandbox_key \\ :default)

@spec creds(atom(), atom()) :: Bourse.Credentials.t() | nil

Get credentials for an exchange/sandbox. Returns nil if unregistered.

Raises ArgumentError if the registry is not running — nil there would mean "not registered", which is a claim this function cannot make without a table.

creds!(exchange, sandbox_key \\ :default)

@spec creds!(atom(), atom()) :: Bourse.Credentials.t()

Get credentials or raise ArgumentError with a helpful message.

env_var_prefix(exchange, sandbox_key)

@spec env_var_prefix(atom(), atom()) :: String.t()

Env var prefix for a given exchange + sandbox combination.

Examples

iex> Bourse.Testnet.env_var_prefix(:binance, :default)
"BINANCE_TESTNET"

iex> Bourse.Testnet.env_var_prefix(:binance, :futures)
"BINANCE_FUTURES_TESTNET"

exchanges_with_creds()

@spec exchanges_with_creds() :: [atom()]

List unique exchange atoms with any registered credentials.

Raises ArgumentError if the registry is not running.

register(exchange, sandbox_key_or_opts, opts \\ [])

@spec register(atom(), atom() | keyword(), keyword()) ::
  :ok | :skipped | not_started()

Register credentials directly.

Returns :ok if credentials validate, :skipped if required fields are missing, and {:error, :not_started} if the registry is not running.

register_all_from_env(configs)

@spec register_all_from_env([config_entry()]) :: [{atom(), atom()}] | not_started()

Register credentials for multiple exchanges from environment variables.

Returns the list of successfully registered {exchange, sandbox_key} tuples, or {:error, :not_started} if the registry is not running — an empty list means every entry was skipped for absent env vars, which is a different condition and stays distinguishable.

register_from_env(exchange, sandbox_key_or_opts \\ :default, opts \\ [])

@spec register_from_env(atom(), atom() | register_opts(), register_opts()) ::
  :ok | :skipped | not_started()

Register credentials from environment variables.

Env var pattern: {EXCHANGE}[_{SANDBOX}][_TESTNET]_API_KEY|_API_SECRET.

Options

  • :testnet - Include _TESTNET in env var names (default: false)
  • :passphrase - Also load passphrase from {EXCHANGE}_PASSPHRASE (default: false)
  • :sandbox - Value for credentials.sandbox (default: value of :testnet)
  • :secret_suffix - Override secret env var suffix (default: "API_SECRET")

Returns :ok on success, :skipped when required env vars are absent, and {:error, :not_started} when there are credentials to register but no registry running to hold them.

registered?(exchange, sandbox_key \\ :default)

@spec registered?(atom(), atom()) :: boolean()

Returns true when credentials are registered for the given exchange/sandbox.

Raises ArgumentError if the registry is not running — false there would claim the credentials are absent when the registry is.

registered_exchanges()

@spec registered_exchanges() :: [{atom(), atom()}]

List all registered {exchange, sandbox_key} tuples.

Raises ArgumentError if the registry is not running — an empty list there would read as "nothing registered yet".

sandbox_key_from_url(url)

@spec sandbox_key_from_url(String.t() | nil) :: atom()

Derive sandbox_key from a sandbox URL's hostname/path.

Examples

iex> Bourse.Testnet.sandbox_key_from_url("https://testnet.binance.vision/api/v3")
:default

iex> Bourse.Testnet.sandbox_key_from_url("https://demo-fapi.binance.com/fapi/v1")
:futures

iex> Bourse.Testnet.sandbox_key_from_url("https://demo-dapi.binance.com/dapi/v1")
:coinm

start_link(opts \\ [])

@spec start_link(keyword()) :: GenServer.on_start()

Starts the Testnet registry GenServer (owns the ETS table).

started?()

@spec started?() :: boolean()

Returns true when the registry process is running.

The registry is not an application child, so a consumer that has not started it will find every read raising and every write answering {:error, :not_started}.

unregister(exchange, sandbox_key \\ :default)

@spec unregister(atom(), atom()) :: :ok | not_started()

Removes the credentials registered for a single {exchange, sandbox_key}.

Returns :ok whether or not an entry existed. Unlike direct :ets.delete/2, this serializes through the owning GenServer, so it is the correct way to clean up a single registration from a foreign process (e.g. an ExUnit on_exit callback, which runs outside the test process). Use this instead of clear/0 when concurrent (async: true) tests share the registry and a full wipe would clobber their registrations.

Returns {:error, :not_started} if the registry is not running.