Bourse.Exchange (bourse v0.1.0)

Copy Markdown View Source

Exchange configuration struct and constructor.

Holds everything needed to make API calls for a specific exchange instance: resolved base URLs, rate limits, credentials, capabilities, and lean spec data.

This is a pure data struct — no process. Rate limiting, HTTP execution, and signing are handled by other modules that receive %Exchange{} as input.

Loaded market metadata lives on the struct as :markets (nil until Bourse.load_markets/1 or put_markets/2). Production caches contain %Bourse.Market{} structs; static replay caches retain raw Bourse maps so their oracle inputs stay byte-faithful. Callers thread the enriched value; there is no hidden global cache.

Examples

# Public-only (no credentials)
{:ok, exchange} = Bourse.Exchange.new("bybit")
exchange.base_urls
#=> %{"public" => "https://api.bybit.com", ...}

# With credentials
{:ok, exchange} = Bourse.Exchange.new("bybit", api_key: "abc", secret: "xyz")
exchange.credentials
#=> %Bourse.Credentials{api_key: "abc", secret: "xyz", ...}

# Sandbox mode (uses testnet URLs)
{:ok, exchange} = Bourse.Exchange.new("bybit", sandbox: true)

# Cache markets for symbol→market_id resolution (loadMarkets equivalent)
{:ok, exchange} = Bourse.load_markets(exchange)

Summary

Functions

Generates introspection functions and endpoint wrappers from a spec ID.

Macro entry point: use Bourse.Exchange, spec: "bybit" generates an exchange module.

Pre-computes a flat list of endpoint configs from the nested API tree.

Builds quoted endpoint wrapper functions from a list of endpoint configs.

Builds @moduledoc text for a generated exchange module from spec metadata.

Builds the quoted module body from prepared generate data.

Builds quoted parse_<slot>/2 wrapper functions from the spec's normalization field maps.

Builds unified method mapping from spec data and pre-computed endpoint configs.

Returns the derived config section — deterministic describe() metadata (credentials, limits, status, routing, rate-limit meta, flags).

Returns compile-time currency metadata for code from the spec's markets.currencies catalog (Task 97), or nil when absent.

Returns per-network metadata for currency_code + network_code, or nil.

Returns the documentation URL doc-set (logo, www, doc, fees, api_management) folded from the derived urls section, or %{}.

Returns exact error-code mappings for scope, with scoped entries taking precedence.

Returns the authored exception scope for a request base URL.

Returns the static default fee schedule from the derived fees spec section.

Returns the config.flags map (e.g. dex), or %{}.

Checks if the exchange supports a given capability.

Returns the global default config.limits map (amount/cost/leverage/price), or %{}.

Returns the caller-threaded markets cache, or nil when not loaded.

Creates an exchange configuration from an exchange ID and options.

Creates an exchange configuration, raising on error.

Prepares all compile-time data for the generator macro.

Returns a copy of exchange with the markets cache set to markets.

Returns the config.routing map (accountsByType / networks / timeInForce), or %{}.

Reads the explicit signing executor and configuration from an owned runtime spec.

Returns the config.status map (status/eta/url/info/updated), or %{}.

Returns the unified-to-native OHLCV timeframe map from capabilities.timeframes.

Returns a copy whose active error maps are selected for scope.

Types

config()

@type config() :: %{optional(String.t()) => term()}

doc_urls()

@type doc_urls() :: %{optional(String.t()) => String.t() | [String.t()]}

error_body_check()

@type error_body_check() :: %{
  field: String.t() | nil,
  field2: String.t() | nil,
  roles: [error_body_role()],
  sentinel_values: [sentinel_value()]
}

error_body_role()

@type error_body_role() :: :error_code | :status_sentinel

error_handler_check()

@type error_handler_check() :: %{
  status_guard: error_status_guard(),
  body_contains: [String.t()],
  error_type: Bourse.Error.error_type()
}

error_scope()

@type error_scope() :: String.t() | atom() | nil

Authored exception scope selected by the request's market family.

error_status_guard()

@type error_status_guard() :: {:gte, non_neg_integer()} | {:in, [non_neg_integer()]}

fees()

@type fees() :: map() | nil

market_cache()

@type market_cache() :: [Bourse.Market.t() | raw_market()] | nil

raw_market()

@type raw_market() :: %{optional(String.t()) => term()}

request_contract()

@type request_contract() :: %{
  optional(:method) => atom(),
  optional(:path) => String.t(),
  optional(:path_params) => [String.t()],
  optional(:body_encoding) => String.t(),
  optional(:content_type) => String.t() | nil,
  optional(:timestamp_recipe) => map(),
  optional(:weight) => number(),
  optional(:rate_limit) => map()
}

request_contract_key()

@type request_contract_key() :: {[String.t()], atom(), String.t()}

sentinel_operator()

@type sentinel_operator() :: String.t()

sentinel_value()

@type sentinel_value() :: %{operator: sentinel_operator(), value: String.t()}

t()

@type t() :: %Bourse.Exchange{
  base_urls: map(),
  broad_error_patterns: %{required(String.t()) => Bourse.Error.error_type()},
  common_currencies: %{required(String.t()) => String.t()},
  config: config(),
  credentials: Bourse.Credentials.t() | nil,
  currencies: %{required(String.t()) => map()},
  default_family: String.t() | nil,
  doc_urls: doc_urls(),
  endpoint_selection: %{required(String.t()) => map()},
  error_body_checks: [error_body_check()],
  error_class_ancestors: %{required(String.t()) => [String.t()]},
  error_code_fields: [String.t()],
  error_codes: %{required(String.t()) => Bourse.Error.error_type()},
  error_handler_checks: [error_handler_check()],
  features: %{required(String.t()) => map()} | nil,
  fees: fees(),
  has: %{required(String.t()) => boolean() | String.t()},
  hostname: String.t() | nil,
  http_exceptions: %{required(String.t()) => Bourse.Error.error_type()},
  id: String.t(),
  markets: market_cache(),
  module: module() | nil,
  name: String.t(),
  network_options: map(),
  options: map(),
  outbound_aliases: %{required(String.t()) => String.t()},
  rate_limit_ms: number(),
  request_contracts: %{required(request_contract_key()) => request_contract()},
  request_defaults: %{required(String.t()) => %{required(String.t()) => term()}},
  request_param_shape: %{
    required(String.t()) => %{required(String.t()) => map()}
  },
  required_credentials: %{required(String.t()) => boolean()},
  retry_classification: %{required(String.t()) => Bourse.Error.retry_class()},
  sandbox: boolean(),
  sandbox_headers: %{optional(String.t()) => String.t()},
  signing_config: map(),
  signing_pattern: Bourse.Signing.pattern() | nil,
  spec: map(),
  status_map: %{required(String.t()) => Bourse.Error.error_type()},
  symbol_patterns: %{required(atom()) => Bourse.Symbol.pattern_config()},
  timeframes: %{required(String.t()) => String.t()}
}

trading_fee_schedule()

@type trading_fee_schedule() :: %{
  maker: number() | nil,
  taker: number() | nil,
  percentage: boolean() | nil,
  tier_based: boolean() | nil,
  fee_side: String.t() | nil,
  tiers: map() | nil,
  fee: Bourse.TradingFee.t(),
  info: map()
}

Functions

__generate__(spec_id)

(macro)

Generates introspection functions and endpoint wrappers from a spec ID.

__using__(opts)

(macro)

Macro entry point: use Bourse.Exchange, spec: "bybit" generates an exchange module.

build_endpoint_configs(api_tree, url_prefixes \\ %{}, authenticated_sections \\ [])

@spec build_endpoint_configs(map(), map(), [String.t()]) :: [map()]

Pre-computes a flat list of endpoint configs from the nested API tree.

Called at compile time by __generate__/1. Recursively traverses the spec's API tree until it finds HTTP method keys (get, post, etc.), then extracts endpoint configs from the level below.

Handles three spec patterns:

  • Standard: %{visibility => %{method => %{path => weight}}}
  • Deep nesting: %{api_type => %{version => %{visibility => %{method => ...}}}}
  • Array endpoints: %{... => %{method => [list_of_paths]}}

All intermediate keys above the HTTP method become the :sections list.

Examples

Bourse.Exchange.build_endpoint_configs(%{
  "public" => %{"get" => %{"v5/market/tickers" => 5}},
  "private" => %{"post" => %{"v5/order/create" => 2.5}}
})
#=> [
#=>   %{name: :public_get_v5_market_tickers, method: :get,
#=>     path: "v5/market/tickers", sections: ["public"], weight: 5},
#=>   %{name: :private_post_v5_order_create, method: :post,
#=>     path: "v5/order/create", sections: ["private"], weight: 2.5}
#=> ]

build_endpoint_functions(endpoint_configs)

@spec build_endpoint_functions([map()]) :: [Macro.t()]

Builds quoted endpoint wrapper functions from a list of endpoint configs.

Called at compile time by __generate__/1. Each generated function embeds its endpoint config as a literal and delegates to Bourse.Dispatch.call/4.

Example

For a config %{name: :public_get_v5_market_tickers, ...}, generates:

def public_get_v5_market_tickers(exchange, params \\ %{}, opts \\ [])
def public_get_v5_market_tickers(%Bourse.Exchange{} = exchange, params, opts) do
  Bourse.Dispatch.call(exchange, %{...}, params, opts)
end

build_exchange_moduledoc(data)

@spec build_exchange_moduledoc(map()) :: String.t()

Builds @moduledoc text for a generated exchange module from spec metadata.

Used by Bourse.Exchanges at compile time. The returned string is injected into each generated module (e.g. Bourse.Bybit) for ExDoc and IDE discovery.

build_module_body(data, opts \\ [])

@spec build_module_body(
  map(),
  keyword()
) :: Macro.t()

Builds the quoted module body from prepared generate data.

Called by both __generate__/1 (macro path) and Bourse.Exchanges (Module.create path) to ensure a single source of truth.

Options

  • :moduledoc — optional @moduledoc string to inject. The macro path leaves this to the caller; Bourse.Exchanges provides one per exchange.

build_parse_functions(field_maps)

@spec build_parse_functions(map()) :: [Macro.t()]

Builds quoted parse_<slot>/2 wrapper functions from the spec's normalization field maps.

One function per @parse_slots entry (parse_ticker/2, parse_trade/2, …). Each embeds its slot mapping as a literal and delegates to Bourse.Parser.parse/4, which applies the Honesty Rule (nil slot → {:error, :no_field_map}, non-nil _unresolved_reason{:error, {:unresolved, reason}}).

build_unified_method_mapping(spec, endpoint_configs)

@spec build_unified_method_mapping(map(), [map()]) :: %{required(atom()) => [map()]}

Builds unified method mapping from spec data and pre-computed endpoint configs.

config(exchange)

@spec config(t()) :: config()

Returns the derived config section — deterministic describe() metadata (credentials, limits, status, routing, rate-limit meta, flags).

Empty when an authored spec has no matching configuration. Individual sub-sections are also available via limits/1, status/1, routing/1, and flags/1.

currency(exchange, code)

@spec currency(t(), String.t()) :: map() | nil

Returns compile-time currency metadata for code from the spec's markets.currencies catalog (Task 97), or nil when absent.

Records include networks when the exchange surfaces per-network deposit/ withdraw metadata via loadMarkets().

Network coverage is declared by each supported venue's owned runtime spec.

currency_network(exchange, currency_code, network_code)

@spec currency_network(t(), String.t(), String.t()) :: map() | nil

Returns per-network metadata for currency_code + network_code, or nil.

Returns nil when the currency is absent, the exchange spec has empty networks maps (see currency/2), or the requested network code is missing.

doc_urls(exchange)

@spec doc_urls(t()) :: doc_urls()

Returns the documentation URL doc-set (logo, www, doc, fees, api_management) folded from the derived urls section, or %{}.

Call URLs live on base_urls; this surface is metadata for introspection.

error_codes_for(exchange, scope)

@spec error_codes_for(t(), error_scope()) :: %{
  required(String.t()) => Bourse.Error.error_type()
}

Returns exact error-code mappings for scope, with scoped entries taking precedence.

error_scope(exchange, base_url)

@spec error_scope(t(), String.t() | nil) :: String.t() | nil

Returns the authored exception scope for a request base URL.

Scope is never inferred from URL path segments or host labels. Construction projects the venue's authored errors.handle_errors.exception_scopes (API-section → scope) onto production and sandbox base URLs into exchange.spec["error_scopes"]; this lookup is a pure map read against that projection. Venues that declare no scopes return nil for every URL.

fees(exchange)

@spec fees(t()) :: fees()

Returns the static default fee schedule from the derived fees spec section.

This is the exchange-level describe().fees default, not live per-market loadMarkets() fees and not dynamic fetch_trading_fees endpoint data.

flags(exchange)

@spec flags(t()) :: map()

Returns the config.flags map (e.g. dex), or %{}.

has?(exchange, capability)

@spec has?(t(), String.t()) :: boolean()

Checks if the exchange supports a given capability.

Returns true for capabilities marked true or "emulated" in the derived capabilities.has map. Returns false for false, "__undefined", or missing capabilities.

Capability names use camelCase strings matching the Bourse spec (e.g., "fetchTicker", "createOrder").

Examples

Bourse.Exchange.has?(exchange, "fetchTicker")
#=> true

Bourse.Exchange.has?(exchange, "fetchFundingRateHistory")
#=> false

limits(exchange)

@spec limits(t()) :: map()

Returns the global default config.limits map (amount/cost/leverage/price), or %{}.

markets(exchange)

@spec markets(t()) :: market_cache()

Returns the caller-threaded markets cache, or nil when not loaded.

Bourse.load_markets/1 and put_markets/2 store %Bourse.Market{} structs. Static response replay stores raw Bourse string-keyed maps to preserve its oracle input. Pure data — no process or global store. Reload by calling Bourse.load_markets/1 again and threading the returned struct.

new(exchange_id, opts \\ [])

@spec new(
  String.t() | atom(),
  keyword()
) :: {:ok, t()} | {:error, term()}

Creates an exchange configuration from an exchange ID and options.

Loads the spec, resolves base URLs (with hostname interpolation and sandbox/testnet switching), and optionally builds credentials.

Options

  • :api_key - API key string (builds credentials automatically)
  • :secret - API secret string (builds credentials automatically)
  • :password - API password (OKX, KuCoin)
  • :uid - User ID
  • :credentials - Pre-built %Bourse.Credentials{} (overrides key/secret opts)
  • :sandbox - Use testnet URLs (default: false; OKX defaults to www.okx.com)
  • :hostname - Override the default hostname
  • :options - Exchange-specific options map

Examples

{:ok, exchange} = Bourse.Exchange.new("bybit")
{:ok, exchange} = Bourse.Exchange.new("okx", api_key: "k", secret: "s", password: "p")
{:error, :missing_secret} = Bourse.Exchange.new("bybit", api_key: "k")

new!(exchange_id, opts \\ [])

@spec new!(
  String.t() | atom(),
  keyword()
) :: t()

Creates an exchange configuration, raising on error.

Examples

exchange = Bourse.Exchange.new!("bybit")
exchange = Bourse.Exchange.new!("bybit", api_key: "abc", secret: "xyz")

prepare_generate_data(spec_id)

@spec prepare_generate_data(String.t()) :: map()

Prepares all compile-time data for the generator macro.

put_markets(exchange, markets)

@spec put_markets(t(), [Bourse.Market.t()]) :: t()

Returns a copy of exchange with the markets cache set to markets.

Use when you already hold a fetch_markets result and want subsequent market-metadata consumers (e.g. symbol→market_id resolution) to reuse it without another network round-trip. Prefer Bourse.load_markets/1 for the usual fetch-and-attach path.

routing(exchange)

@spec routing(t()) :: map()

Returns the config.routing map (accountsByType / networks / timeInForce), or %{}.

signing_from_spec(spec)

@spec signing_from_spec(map()) :: {Bourse.Signing.pattern(), map()}

Reads the explicit signing executor and configuration from an owned runtime spec.

status(exchange)

@spec status(t()) :: map()

Returns the config.status map (status/eta/url/info/updated), or %{}.

timeframes(exchange)

@spec timeframes(t()) :: %{required(String.t()) => String.t()}

Returns the unified-to-native OHLCV timeframe map from capabilities.timeframes.

Keys are Bourse unified labels (e.g. "1h", "15m"); values are exchange-native labels (e.g. "60" on Bybit, "1h" on Binance). Empty when upstream omitted the map.

with_error_scope(exchange, scope)

@spec with_error_scope(t(), error_scope()) :: t()

Returns a copy whose active error maps are selected for scope.