DpExchange.Core.Venue behaviour (DpExchangeCore v0.3.2)

Copy Markdown View Source

The facade. The single module a consumer touches, identical on every venue.

defmodule DpExchange.Coinbase do
  @behaviour DpExchange.Core.Venue
end

Nothing else in a venue package is public API. A venue's transport, rate limiting, signing, session handling and supervision are implementation detail behind this, and a consumer cannot reach them because there is nothing here that returns them.

The one rule everything else follows from

The facade is one fixed set of functions, never extended per venue and never omitted. What differs between venues is which of them are active, and that is declared — once, in capabilities/0 — rather than expressed by a venue defining extra functions or leaving some out.

This is what dissolves the "all the same, but some have extras" tension. A venue does not add functions; it declares which ones it answers. There is no second mechanism and no escape hatch, because an escape hatch is a way for a caller to need to know which venue it is holding, and that is the one thing this contract exists to prevent.

What crosses, in full

Anything not on this list is a design error, not an omission.

In: credentials, symbols, order requests, options. That is the entire inbound surface. No modules, no functions, no callbacks, no sink — a consumer never hands a package a piece of itself.

Out: {:ok, value} / {:error, reason} / {:refused, reason} from the pull endpoints; DpExchange.Core.Types.* structs pushed to subscribers; DpExchange.Core.Notice structs on the notices channel; telemetry under [:dp_exchange, …].

Never out: socket handles, connection pools, transport library state, MQTT sessions, rate-limit buckets, signing keys, retry timers, supervisor pids.

Both endpoints always exist

Every venue can be pulled and can be subscribed. There is no flag for it and nothing to branch on. A venue with no streaming API implements subscribe/2 over its own polling and pushes the results; a consumer calling subscribe/2 gets a stream whether the mechanism beneath it is a socket, an MQTT session or a loop.

That is not a convenience. The moment a consumer can ask how the data arrives, it can branch on the answer, and every consumer above the package forks on transport.

Maturity is per endpoint, and it is bidirectional

capabilities/0 reports :proven | :experimental | :unsupported for each function. The declaration and the behaviour may not disagree, in either direction:

  • :proven or :experimental means the function works. Neither may answer {:error, :not_supported} — maturity says how well a thing is known, never whether it runs.
  • :unsupported means the function exists and returns {:error, :not_supported}. It may not raise, may not be undefined, and may not quietly succeed with degraded data.

Both halves are asserted by the conformance suite. Checking only the first lets a venue under-declare and hide working functionality; checking only the second lets it over-declare and fail in a caller's hands. Holding both is what lets a consumer branch on capabilities/0 instead of on venue identity.

{:error, :not_supported} is the atom. The source this contract was extracted from returned the string "not_supported" in some places and the atom in others — in one case both within a single module — so a caller matching the atom silently missed the string and treated a refusal as an unrecognised error.

A library does not start itself

child_spec/1 exists so you supervise the venue. This package ships no aggregate supervisor and no start-everything entry point: it would have to know which venues exist, and it would take a decision that is yours — which venues run, under what restart strategy, with what names. A consumer that has not asked for a venue never finds a socket open.

Summary

Types

Opaque to this contract. A venue package documents its own shape.

Whether the venue is trading right now. Crypto venues answer :open always.

The three-state maturity of one endpoint.

A refusal is not an error.

How a symbol's data is reaching the caller right now, as observed.

Canonical BASE-QUOTE for a pair; a bare symbol on an equity venue.

Callbacks

Registers a funding source — typically a bank account.

Which asset classes this venue trades — a subset of [:crypto, :equity, :option, :future, :event_contract].

Cancels open orders in bulk, at a scope the caller must state.

The activation map: which functions are answerable, and how well each is known.

Starts the venue's whole tree — connections, limiter, session refresh, whatever it needs.

Closes an open position on symbol by placing the order that flattens it.

Commits a previously quoted conversion. This moves funds.

Converts amount of from into to in one call, with no quote to accept first.

What is observed arriving, by which route — never what was subscribed.

What is arriving, per symbol, split by which kind of data it is.

Creates an account or sub-account at the venue.

Creates a watchlist at the venue.

Deletes a watchlist at the venue.

Estimates the fee to withdraw amount of asset over network.

Accounts visible to the credential.

The order imbalance published ahead of an opening or closing auction.

Balances for the credentialed account.

Risk statistics for a derivative — mark, index and open interest.

A conversion's current state, quoted or committed.

Dividends, earnings dates and splits. Each date is carried under its own name.

A deposit address for asset on network.

The fee schedule that applies to this credential.

Regulatory filings the venue indexes. This interface points at them; it never fetches one.

Financial statements for an issuer.

Funding for a perpetual — settled, projected, and when the next one lands.

A foreign-exchange reference rate for pair at at.

Historical candles for symbol at timeframe.

A bulk snapshot across the venue's symbols, where it offers one.

News the venue publishes or relays, with its own tagging.

Every balance the account holds, each also valued in one notional currency.

The option chain for an underlying — expiry × strike, both sides.

The expiries listed on an underlying, without the strikes.

Greeks and implied volatility for one contract.

One order's current state.

The order book for symbol, best price first on both sides.

Orders visible to the credential.

One funding source by the venue's own identifier.

Open positions — exposure, not holdings.

The current price for symbol.

What the venue's ceiling currently is and how much of it is left.

The roles this credential holds, as the venue defines them.

A venue screener, mover list or ranking, by the venue's own identifier for it.

Staked positions, one per asset, with their liquidity states kept apart.

Movements in and out of staked positions, redemptions included with their progress.

Staking rates on offer, per asset and provider.

Rewards accrued over a period. The period is part of the value, not a filter.

Every symbol the venue lists.

Best bid and ask for symbol — the top of the book, not a traded price.

Past fills for the credential.

The credential's own traded volume, as the venue aggregates it.

Recent public trades for symbol — the tape.

The account's transaction history — everything that moved, not only trades.

Deposit and withdrawal history — needed to compute cost basis for transferred-in assets.

Traded volume split by price and by side, one entry per interval.

One watchlist including its membership.

Addresses on the withdrawal allow-list, with whether each is usable yet.

The fees a venue charged for holding assets, as opposed to trading them.

Symbols currently carrying a promotional fee.

The venue's listings with the fields get_symbols/1 discards — base, quote, instrument type, trading status.

The blockchain networks an asset can move over, or the assets a network carries.

The funding sources this account can move fiat through.

The portfolios this credential can address.

Watchlists held at the venue. The venue's list, which may differ from the host's.

Whether the venue is trading right now.

Places an order. Irreplaceable by definition: this is the act.

Places several orders in one request. This moves funds.

Validates an order without placing it, returning the venue's own estimate of what it would cost.

Validates a change to an open order without making it, returning the venue's estimate of the amended order.

The venue's display name.

Rounds a price and quantity to what the venue will actually accept.

Quotes a conversion of amount from one asset to another. Nothing moves.

Removes address from the withdrawal allowlist for network.

Renames an existing account at the venue.

Replaces an open order atomically.

Asks the venue to add address to the withdrawal allowlist for network.

The venue's stable identifier, matching its package's namespace segment.

Stakes amount of asset.

Starts the venue directly. See child_spec/1 — a venue with no processes returns :ignore.

Subscribes the caller to symbols.

Subscribes the caller to the package's notices — what it is doing and what is going wrong with it.

Whether the venue is reachable and the credential, if given, is accepted.

Moves amount of asset between two accounts held at the same venue.

Redeems amount of a staked asset.

Stops delivery for symbols.

Changes a live subscription's symbol set without tearing the venue's connections down.

Replaces a watchlist's name or membership.

Withdraws amount of asset over network to address.

Functions

The endpoints a package must prove before it can stop being EXPERIMENTAL.

The refusal every unsupported endpoint returns.

Every notice kind a venue may emit. See DpExchange.Core.Notice.

The endpoints outside the core set, each with the test it fails.

Every callback a venue package must implement, as {name, arity}.

Types

credentials()

@type credentials() :: map()

Opaque to this contract. A venue package documents its own shape.

market_status()

@type market_status() :: :open | :closed | :pre | :post

Whether the venue is trading right now. Crypto venues answer :open always.

maturity()

@type maturity() :: :proven | :experimental | :unsupported

The three-state maturity of one endpoint.

:experimental is the default and the only honest starting state. :proven is earned per endpoint by production use, never by careful implementation.

result(value)

@type result(value) :: {:ok, value} | {:error, term()} | {:refused, term()}

A refusal is not an error.

{:refused, reason} is the venue stating it does not carry this symbol at all — a permanent answer. {:error, reason} may be transient and is worth retrying. Collapsing the two makes a delisted symbol look like a network blip forever.

route()

@type route() :: :stream | :internal_poll | :not_covered

How a symbol's data is reaching the caller right now, as observed.

Never what was subscribed. A venue that cannot observe delivery answers :not_covered rather than assuming its subscription worked.

:stream deliberately does not say socket. Whether a pushed route is a WebSocket, an MQTT session or long-polling is package-internal, and a consumer branching on it would be branching on mechanism. What a consumer legitimately needs is whether the data is pushed or fetched, because only the second scales with catalogue size.

Where this type came from

Carried here from Core.FeedBehaviour, deleted in 0.3.0 as a contract with no adopters — its history is the reason this type exists and is worth more than the module was.

Orchestration had accreted in a shared collection layer one venue at a time, until that layer held Webull's session count, Gemini's ten-pairs-per-socket limit, Coinbase's channel ordering, and a case provider do for which module speaks which protocol. Every one of those is knowledge only the venue has, and the cost was not tidiness — shared code was making transport decisions with information it could not have:

  • the poll set had to guess which pairs a subscription covered, because the only honest answer lives inside the venue;
  • Webull's documented ceiling of "3 messages per second per connection" was rationed by a module that could not see it, so 151 subscribed pairs delivered nothing and it read as a quiet market;
  • a venue with no socket at all was reported to the user in socket terms, because the layer describing it was inferring transport rather than being told.

That third one is why :stream names a push and not a socket, and why :internal_poll exists as a first-class answer rather than an absence. A venue with no streaming API is still a feed: dp_exchange_robinhood polls REST internally and emits through the same path as a socket venue, so nothing above the facade branches on transport.

symbol()

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

Canonical BASE-QUOTE for a pair; a bare symbol on an equity venue.

Callbacks

add_payment_method(map, keyword)

@callback add_payment_method(
  map(),
  keyword()
) :: result(map())

Registers a funding source — typically a bank account.

Optional, and this is a write that a person usually has to complete: venues verify a new bank account out of band, by micro-deposit or an open-banking flow, and the API call only starts that. A caller treating a successful response as a usable method will find the first transfer refused.

details is the venue's own shape. Bank details differ by country — a US routing number, a Canadian transit and institution number, an IBAN — and a normalised struct would be wrong for every country but one.

asset_classes()

@callback asset_classes() :: [atom()]

Which asset classes this venue trades — a subset of [:crypto, :equity, :option, :future, :event_contract].

A statement about the package today, never a permanent scope boundary. It widens as endpoints land, and a class a venue serves but does not declare is a class the host cannot route to.

cancel_all_orders(credentials, keyword)

@callback cancel_all_orders(
  credentials(),
  keyword()
) :: result(%{cancelled: [String.t()], rejected: [String.t()]})

Cancels open orders in bulk, at a scope the caller must state.

Optional. opts[:scope] is required and has no default:session cancels what this credential's session opened, :account cancels everything the account has open including orders placed by another key or by a person at the venue's own web interface.

The two are not interchangeable and the wider one is destructive in a way a caller may not expect, so a missing scope is an error rather than a choice made here. Gemini's own documentation recommends the narrow one; that is guidance for the caller, not licence for this contract to pick.

This is not get_orders/2 followed by cancel_order/3 in a loop. That is N requests with N partial outcomes, and it cannot cancel an order that appeared between the listing and the cancels. Only the venue closes the set it holds.

Returns %{cancelled: [id], rejected: [id]}. A non-empty rejected is not a failed call — the venue answered and some orders were already gone. Returning an error there would tell a caller nothing was cancelled when most of it was.

cancel_order(credentials, t, keyword)

@callback cancel_order(credentials(), String.t(), keyword()) ::
  result(DpExchange.Core.Types.Order.t())

Cancels an order.

capabilities()

@callback capabilities() :: DpExchange.Core.Capabilities.t()

The activation map: which functions are answerable, and how well each is known.

Static and safe at boot. This is what a consumer branches on instead of venue identity.

child_spec(keyword)

@callback child_spec(keyword()) :: Supervisor.child_spec()

Starts the venue's whole tree — connections, limiter, session refresh, whatever it needs.

A venue with no processes returns :ignore. You put this in your supervision tree and choose its restart strategy, shutdown order and name.

close_position(credentials, symbol, keyword)

@callback close_position(credentials(), symbol(), keyword()) ::
  result(DpExchange.Core.Types.Order.t())

Closes an open position on symbol by placing the order that flattens it.

Optional. This is an order, not a query — the venue works out the side and the size from the position it holds and then places the order itself, which is why it returns an Order like place_order/3 does.

That is also why it is not replaceable by get_positions/1 plus place_order/3: the size a caller computes is the size as of the caller's last read, and the venue's is the size now. On a position that moved in between, the caller's arithmetic leaves a residue or overshoots into a position the other way. Only the venue can flatten to exactly zero.

A venue that does not carry positions has nothing to close, and says so through capabilities/0 rather than through this returning an empty success.

commit_conversion(t, keyword)

@callback commit_conversion(
  String.t(),
  keyword()
) :: result(DpExchange.Core.Types.Conversion.t())

Commits a previously quoted conversion. This moves funds.

A quote past its window may be refused, or filled at the current rate rather than the quoted one — which is the outcome to guard against, because it looks like success. D2 puts the decision to call this with the host.

convert(t, t, t, keyword)

Converts amount of from into to in one call, with no quote to accept first.

Optional, and deliberately separate from quote_conversion/4 plus commit_conversion/2 rather than a shorthand for them.

The difference is who carries the price risk, and it is not a detail. The two-step form shows a rate and holds it: the caller sees the number before anything moves, and a stale quote is refused. This form executes at whatever the venue's price is when it arrives, and the caller learns the rate from the result. A venue offering only one of the two cannot be made to offer the other by a package wrapping it — quoting a rate this package computed and calling it held would be a promise the venue never made.

So a venue declares each independently, and a caller that must see a price first uses quote_conversion/4 or does without.

Returns a Conversion already :settled — it has happened.

coverage(keyword)

@callback coverage(keyword()) :: %{required(symbol()) => route()}

What is observed arriving, by which route — never what was subscribed.

This is the strongest guarantee in the contract, and it exists because intent standing in for evidence is how a venue reported 325 symbols subscribed and confirmed while 174 were delivering. A venue that cannot observe delivery answers :not_covered rather than reporting success it cannot see.

Symbols absent from the map are :not_covered.

Observation is scoped to the current transport session

Evidence from a connection that has since dropped is not evidence about now. On :link_down, a venue narrows coverage/1 by the symbols that link was carrying, exactly the way it already narrows on the same socket's process death and on unsubscribe/2. They come back as frames arrive after the resubscribe.

This was not always so, and the reason it is written here is that all four streaming venues got it wrong in the same way at once. Every one of their sockets returns {:reconnect, state} from handle_disconnect/2 — the socket process survives a transport drop, so no EXIT fires, so none of the crash-keyed reset paths ran. Between a drop and a successful resubscribe, coverage/1 answered :stream for symbols arriving from nowhere; and where the reconnect restored the socket but the venue silently failed to restore some symbols — the 325-subscribed/174-delivering shape exactly — those symbols answered :stream indefinitely, on the strength of frames seen before the disconnect. Stale evidence standing in for current evidence is the same substitution this callback exists to prevent, one level down.

A consumer therefore sees a brief, truthful dip across a reconnect, bracketed by the :link_down / :link_up notice pair that exists for exactly that. Under-reporting for a few seconds is the direction the paragraph above already asks for; over-reporting indefinitely is the failure it was written against.

A route may narrow further, and must say which

Core.PollingFeed applies a staleness window — a symbol whose last successful poll is older than interval_ms * @coverage_grace drops out — because a poll that did not answer within its own interval is genuinely not delivering, and that is a bounded statement a polling route can make.

A streaming route deliberately does not. An illiquid pair may honestly not print for hours, and on dp_exchange_schwab silence overnight and all weekend is the correct state, not a fault. A window there would report :not_covered for a healthy quiet market, which is a false alarm and its own harm. So on a stream, coverage means observed at least once since this connection came up — never "recently", and never "how stale".

coverage_by_kind(keyword)

(optional)
@callback coverage_by_kind(keyword()) :: %{
  required(DpExchange.Core.Capabilities.data_kind()) => %{
    required(symbol()) => route()
  }
}

What is arriving, per symbol, split by which kind of data it is.

Optional — see @optional_callbacks below for why this is not required. It exists because coverage/1 is truthful and was not enough.

The incident this closes

DpCryptoManagement's issue #22: Coinbase's level2 channel delivered over 11,000 frames for 406 symbols while ticker was dark for all but 5, and coverage/1 answered :stream for all 406 — correctly, because it counts any payload for a symbol, a Types.OrderBook exactly as much as a Types.Quote. Verified by running it: coverage after ONLY an OrderBook (no ticker quote): %{"XLM-USD" => :stream}. The defect had stayed invisible across two issues because coverage/1 collapses every data kind into one boolean, so "ticker dark, book healthy" is indistinguishable from "everything healthy". This callback exists to make that distinguishable.

Capabilities.data_kind() is the existing vocabulary — the same one streamable already declares in. This does not invent a parallel one.

What this is NOT

  • Not a replacement for coverage/1. A caller asking "is anything arriving for this symbol" still gets a straight answer without knowing a venue's channel vocabulary.
  • Not a per-channel report. A venue's own channel names — Coinbase's level2, ticker — must never cross this facade; the contract speaks data_kind(), never a venue's word for one.
  • Not a freshness or latency API. It reports the same observed-arrival fact coverage/1 reports, split by kind — never when, never how stale.

The invariant, if a venue implements this at all

Map.keys(coverage(opts)) ==
  coverage_by_kind(opts) |> Map.values() |> Enum.flat_map(&Map.keys/1) |> Enum.uniq()

The conformance suite asserts this, and that every key here is a kind the same venue's own capabilities().streamable declares, only when this callback is exported — an absent implementation is a venue that has not adopted this yet, not a failure, and asserting nothing in that case is deliberate.

create_account(keyword)

@callback create_account(keyword()) :: result(map())

Creates an account or sub-account at the venue.

create_watchlist(t, list, keyword)

@callback create_watchlist(String.t(), [String.t()], keyword()) ::
  result(DpExchange.Core.Types.Watchlist.t())

Creates a watchlist at the venue.

delete_watchlist(t, keyword)

@callback delete_watchlist(
  String.t(),
  keyword()
) :: result(:ok)

Deletes a watchlist at the venue.

estimate_withdrawal_fee(t, t, t, keyword)

@callback estimate_withdrawal_fee(String.t(), String.t(), Decimal.t(), keyword()) ::
  result(Decimal.t())

Estimates the fee to withdraw amount of asset over network.

Separate from withdraw/5 because the venues expose it separately, and because the estimate can differ from the charge. Do not record an estimate as a fee.

get_accounts(credentials, keyword)

@callback get_accounts(
  credentials(),
  keyword()
) :: result([map()])

Accounts visible to the credential.

get_auction_imbalance(symbol, keyword)

@callback get_auction_imbalance(
  symbol(),
  keyword()
) :: result([DpExchange.Core.Types.AuctionImbalance.t()])

The order imbalance published ahead of an opening or closing auction.

Optional. opts[:auction] is :opening or :closing and is required — the two are different auctions with different windows, and a venue asked for neither has nothing to answer.

Returns a list, newest first, because the venue publishes a series and not only a latest value: the imbalance updates every few seconds through the auction window and how it moved is the point. opts[:history] selects the published series where a venue serves the snapshot and the series separately — the same shape get_orders/2 uses for resting versus closed orders.

A series entry may carry less than a snapshot. Webull's NOII bars publish the three auction prices and the time and not the paired quantity, the imbalance quantity or the side; those come back nil, which means the venue did not publish them on that endpoint rather than that the imbalance was zero.

Not derivable from get_order_book/2. During an auction the continuous book stops being the price: what matters is how much can be matched, how much cannot, and where the auction would clear. A caller reading a continuous quote at 15:59 is reading a book that is not where the close will happen.

Published only inside the venue's auction windows; outside them a venue may answer with the last one it published, which is why Types.AuctionImbalance carries both the venue's own time and when it was observed.

get_balances(credentials, keyword)

@callback get_balances(
  credentials(),
  keyword()
) :: result([DpExchange.Core.Types.Balance.t()])

Balances for the credentialed account.

get_contract_stats(symbol, keyword)

@callback get_contract_stats(
  symbol(),
  keyword()
) :: result(DpExchange.Core.Types.ContractStats.t())

Risk statistics for a derivative — mark, index and open interest.

Returns Types.ContractStats. Mark and index are separate prices with separate meanings, and neither is what the instrument last traded at.

get_conversion(t, keyword)

@callback get_conversion(
  String.t(),
  keyword()
) :: result(DpExchange.Core.Types.Conversion.t())

A conversion's current state, quoted or committed.

get_corporate_events(keyword)

@callback get_corporate_events(keyword()) ::
  result([DpExchange.Core.Types.CorporateEvent.t()])

Dividends, earnings dates and splits. Each date is carried under its own name.

get_deposit_address(t, t, keyword)

@callback get_deposit_address(String.t(), String.t(), keyword()) ::
  result(DpExchange.Core.Types.DepositAddress.t())

A deposit address for asset on network.

The network is required and not defaulted. The same asset exists on several chains and the addresses are not interchangeable; a package choosing a default network would be choosing where a caller's funds go.

Read Types.DepositAddress's :memo_required before sending. nil there means the venue did not say, which is not the same as false.

get_fees(credentials, keyword)

@callback get_fees(
  credentials(),
  keyword()
) :: result(map())

The fee schedule that applies to this credential.

credentials() is required by this callback's own shape, but not every venue's fee schedule actually varies by credential — some venues publish one flat rate for everyone, and answering it costs no venue call at all. Where that is true, AdapterContract's assertion 17 (the credential gate) is satisfied by declaring the endpoint in Capabilities.no_venue_contact rather than by refusing without a credential this callback has nothing to check: see that field's own moduledoc, and dp_exchange_webull's get_fees/2 for the venue this was found on.

get_filings(t, keyword)

@callback get_filings(
  String.t(),
  keyword()
) :: result([DpExchange.Core.Types.Filing.t()])

Regulatory filings the venue indexes. This interface points at them; it never fetches one.

get_financials(t, atom, keyword)

@callback get_financials(String.t(), atom(), keyword()) ::
  result([DpExchange.Core.Types.FinancialStatement.t()])

Financial statements for an issuer.

kind selects balance sheet, income, cash flow or the venue's indicator set. Line items come back under the venue's own names — see Types.FinancialStatement for why they are not normalised into a fixed schema.

get_funding(symbol, keyword)

@callback get_funding(
  symbol(),
  keyword()
) :: result(DpExchange.Core.Types.Funding.t())

Funding for a perpetual — settled, projected, and when the next one lands.

Returns Types.Funding, which keeps the settled amount and the venue's estimate apart. A venue that does not trade perpetuals declares this :unsupported.

get_fx_rate(t, t, keyword)

@callback get_fx_rate(String.t(), DateTime.t(), keyword()) ::
  result(DpExchange.Core.Types.FxRate.t())

A foreign-exchange reference rate for pair at at.

Optional. Not a rate the venue trades at — a venue publishing this is relaying a third party's number for historical reference, which is why Types.FxRate carries the source and the benchmark alongside the rate.

at is the instant the rate is for, not a window: the venue answers for that moment. A venue that serves only recent history says so by refusing, rather than returning its nearest available rate under the requested timestamp.

get_historical_prices(symbol, t, keyword, keyword)

@callback get_historical_prices(symbol(), String.t(), keyword(), keyword()) ::
  result([DpExchange.Core.Types.Candle.t()])

Historical candles for symbol at timeframe.

The venue rejects a timeframe it does not serve rather than substituting the nearest one. A missing granularity silently becoming the closest one mislabels every candle it touches, and every value stays plausible.

get_market_overview(keyword)

@callback get_market_overview(keyword()) :: result(map())

A bulk snapshot across the venue's symbols, where it offers one.

get_news(keyword)

@callback get_news(keyword()) :: result([DpExchange.Core.Types.NewsItem.t()])

News the venue publishes or relays, with its own tagging.

get_notional_balances(credentials, t, keyword)

@callback get_notional_balances(credentials(), String.t(), keyword()) :: result([map()])

Every balance the account holds, each also valued in one notional currency.

Optional, and it is not get_balances/2 in another unit. The quantity of the asset is the venue's ledger; the notional figure beside it is the venue's valuation of that quantity at a rate the venue chose and does not have to publish. Two venues will disagree about the notional value of the same holding and both be right about the balance.

Rows are the venue's own maps for that reason: flattening a quantity and a valuation into one struct invites a caller to read one as the other, and the notional figure is the one that is only ever an estimate.

Callers reconciling a position use get_balances/2. This is for reporting.

get_option_chain(t, keyword)

@callback get_option_chain(
  String.t(),
  keyword()
) :: result(DpExchange.Core.Types.OptionChain.t())

The option chain for an underlying — expiry × strike, both sides.

Returns Types.OptionChain. Two-dimensional deliberately: a flat list of contracts is lossless in data and answers none of the questions a chain is asked.

get_option_expirations(t, keyword)

@callback get_option_expirations(
  String.t(),
  keyword()
) :: result([Date.t()])

The expiries listed on an underlying, without the strikes.

Venues expose this separately because a full chain is large and a caller choosing an expiry does not need every strike to do it.

get_option_greeks(t, keyword)

@callback get_option_greeks(
  String.t(),
  keyword()
) :: result(DpExchange.Core.Types.OptionGreeks.t())

Greeks and implied volatility for one contract.

Returns Types.OptionGreeks. Model output, not market data — two venues quoting the same contract publish different numbers and neither is wrong.

get_order(credentials, t, keyword)

@callback get_order(credentials(), String.t(), keyword()) ::
  result(DpExchange.Core.Types.Order.t())

One order's current state.

get_order_book(symbol, keyword)

@callback get_order_book(
  symbol(),
  keyword()
) :: result(DpExchange.Core.Types.OrderBook.t())

The order book for symbol, best price first on both sides.

get_orders(credentials, keyword)

@callback get_orders(
  credentials(),
  keyword()
) :: result([DpExchange.Core.Types.Order.t()])

Orders visible to the credential.

get_payment_method(credentials, t, keyword)

@callback get_payment_method(credentials(), String.t(), keyword()) :: result(map())

One funding source by the venue's own identifier.

Optional. Returns the venue's map, the same shape list_payment_methods/2 returns rows in — not a normalised struct, for the reason given there.

This is the call that answers whether a method is still usable, and the list is not. A method's verification state changes without the account doing anything: a bank can be closed, a card can expire, a venue can suspend a rail. A caller holding an identifier from an earlier listing and moving fiat against it without re-reading is acting on a status that may have been true an hour ago.

A venue that has no such identifier — or no per-method read — returns {:error, :not_supported}. Selecting the matching row out of list_payment_methods/2 is not this function: the list is a snapshot and this is a read.

get_positions(keyword)

@callback get_positions(keyword()) :: result([DpExchange.Core.Types.Position.t()])

Open positions — exposure, not holdings.

Distinct from get_balances/1: a balance says what the account holds, a position says what exposure it has taken and how far it is from liquidation. A spot-only venue declares this :unsupported; that is not the same as having none.

get_price(symbol, keyword)

@callback get_price(
  symbol(),
  keyword()
) :: result(DpExchange.Core.Types.Quote.t())

The current price for symbol.

get_rate_limit_status(arg1, keyword)

@callback get_rate_limit_status(
  credentials() | nil,
  keyword()
) :: result(map())

What the venue's ceiling currently is and how much of it is left.

get_roles(keyword)

@callback get_roles(keyword()) :: result(map())

The roles this credential holds, as the venue defines them.

get_screener(t, keyword)

@callback get_screener(
  String.t(),
  keyword()
) :: result([DpExchange.Core.Types.ScreenerResult.t()])

A venue screener, mover list or ranking, by the venue's own identifier for it.

Rows carry the venue's ranking and metrics. Two venues' lists under one name answer different questions; this interface does not merge or re-rank them.

get_staking_balances(keyword)

@callback get_staking_balances(keyword()) ::
  result([DpExchange.Core.Types.StakingBalance.t()])

Staked positions, one per asset, with their liquidity states kept apart.

get_staking_history(keyword)

@callback get_staking_history(keyword()) ::
  result([DpExchange.Core.Types.StakingTransaction.t()])

Movements in and out of staked positions, redemptions included with their progress.

get_staking_rates(keyword)

@callback get_staking_rates(keyword()) :: result([DpExchange.Core.Types.StakingRate.t()])

Staking rates on offer, per asset and provider.

This is custodial staking — the venue holds the asset and pays a rate. It is not on-chain staking: an endpoint that returns an unsigned transaction for a caller to sign and broadcast is a different capability and must never be reached through these callbacks. A caller believing it had staked when it holds an unsigned transaction nobody signed is the most expensive form of this family's recurring failure.

get_staking_rewards(keyword)

@callback get_staking_rewards(keyword()) ::
  result([DpExchange.Core.Types.StakingReward.t()])

Rewards accrued over a period. The period is part of the value, not a filter.

get_symbols(keyword)

@callback get_symbols(keyword()) :: result([symbol()])

Every symbol the venue lists.

get_top_of_book(symbol, keyword)

@callback get_top_of_book(
  symbol(),
  keyword()
) :: result(DpExchange.Core.Types.TopOfBook.t())

Best bid and ask for symbol — the top of the book, not a traded price.

Returns Types.TopOfBook, which has no price field. A caller wanting what the instrument last traded at calls get_price/2; a caller wanting what it can currently trade at calls this. The two must never stand in for one another — see Types.TopOfBook's moduledoc for the defect that rule was written from.

Most venues publish a dedicated BBO endpoint that is cheaper than a full book, which is why this is not get_order_book/2 with a depth of one. Several publish no timestamp with it and several publish no sizes; the type makes both optional rather than inventing them.

get_trade_history(credentials, keyword)

@callback get_trade_history(
  credentials(),
  keyword()
) :: result([DpExchange.Core.Types.Fill.t()])

Past fills for the credential.

get_trade_volume(credentials, keyword)

@callback get_trade_volume(
  credentials(),
  keyword()
) :: result([map()])

The credential's own traded volume, as the venue aggregates it.

Optional. This is the account's volume, not the market'sget_market_overview/1 answers the second question and this one answers "what have I traded".

It is not get_trade_history/2 summed. The venue's aggregation is the one its own fee tiers are computed from, and reproducing it means fetching every fill over the reporting window — on a venue that requires a symbol per request, that is one request per symbol per period, and the result would still be this package's arithmetic rather than the venue's ledger. Where the two disagree, the venue's is the one that decides what a caller is charged.

Shape is the venue's own, so map(): the fields differ enough between venues that a normalised struct would be mostly nil on all of them.

get_trades(symbol, keyword)

@callback get_trades(
  symbol(),
  keyword()
) :: result([DpExchange.Core.Types.Trade.t()])

Recent public trades for symbol — the tape.

Optional. This is not get_trade_history/2, which returns the credential's own fills. The tape is everyone's executions and has no order of yours behind it; a package answering one with the other would hand a caller a filtered view of the market and call it the market.

Broken trades are excluded unless opts[:include_broken] says otherwise. An exchange that busts an erroneous print has said it did not stand, and leaving it in a series puts a phantom high or low into every range and volatility figure built on it — none of which will error. Venues with no concept of busts have nothing to exclude.

opts[:since] and opts[:limit] narrow the window where a venue supports them, and go to the venue rather than being applied to the page it returned.

get_transactions(credentials, keyword)

@callback get_transactions(
  credentials(),
  keyword()
) :: result([map()])

The account's transaction history — everything that moved, not only trades.

Optional. Wider than get_trade_history/2 and wider than get_transfers/2: it includes fees, interest, dividends, adjustments and credits alongside deposits and fills. Rows are the venue's own maps, because the kinds do not share a shape and a struct would be mostly nil for every one of them.

Summing this is not a balance. A caller reconciling should use get_balances/2 as the authority and this as the explanation.

get_transfers(credentials, keyword)

@callback get_transfers(
  credentials(),
  keyword()
) :: result([map()])

Deposit and withdrawal history — needed to compute cost basis for transferred-in assets.

get_volume_profile(symbol, t, keyword)

@callback get_volume_profile(symbol(), String.t(), keyword()) ::
  result([DpExchange.Core.Types.VolumeProfile.t()])

Traded volume split by price and by side, one entry per interval.

Optional. Not a Candle with extra fields and not derivable from one: a candle's single volume number cannot say that of 1,000 shares, 600 lifted the ask and 400 hit the bid, nor at which prices each happened. Neither can be reconstructed from the other, which is why Types.VolumeProfile is its own type.

timeframe uses the same vocabulary get_historical_prices/4 does, and a venue that does not serve a width returns an error rather than the nearest one it does.

get_watchlist(t, keyword)

@callback get_watchlist(
  String.t(),
  keyword()
) :: result(DpExchange.Core.Types.Watchlist.t())

One watchlist including its membership.

list_approved_addresses(keyword)

@callback list_approved_addresses(keyword()) ::
  result([DpExchange.Core.Types.ApprovedAddress.t()])

Addresses on the withdrawal allow-list, with whether each is usable yet.

list_custody_fees(credentials, keyword)

@callback list_custody_fees(
  credentials(),
  keyword()
) :: result([map()])

The fees a venue charged for holding assets, as opposed to trading them.

Optional. Custody fees are periodic and are taken out of the balance directly, so they appear as a reduction with no trade behind it. A consumer reconciling balances against fills alone will find a gap it cannot explain, and this is what explains it.

Rows are the venue's own maps. An empty list means the venue charged nothing in the window asked for — it never means the venue does not charge. A venue with no custody product at all returns {:error, :not_supported}, which is the answer that distinguishes the two.

list_fee_promos(keyword)

@callback list_fee_promos(keyword()) :: result([map()])

Symbols currently carrying a promotional fee.

Optional, and not get_fees/2: that returns the schedule applying to a credential, and this is a public list of symbols where the venue is charging something other than its published schedule. A caller computing cost from the schedule alone is wrong for exactly the symbols on this list.

list_instruments(keyword)

(optional)
@callback list_instruments(keyword()) :: result([DpExchange.Core.Instrument.t()])

The venue's listings with the fields get_symbols/1 discards — base, quote, instrument type, trading status.

Optional: single-quote venues derive base and quote trivially and have no non-spot instruments, so requiring an implementation there is ceremony.

list_networks(arg1, keyword)

@callback list_networks(
  String.t() | nil,
  keyword()
) :: result([map()])

The blockchain networks an asset can move over, or the assets a network carries.

Optional. This is what get_deposit_address/3 needs before it can be called: that callback takes a network, and nothing else in the contract says which networks a venue will accept for a given asset. Guessing one produces an address on a chain the venue does not credit, and funds sent to it are gone — the single most expensive mistake in this family's surface.

Two directions, because venues publish both and they answer different questions:

  • list_networks("USDC", []) — which networks carry this asset
  • list_networks(nil, network: "ethereum") — which assets this network carries

Rows are the venue's own maps. Network naming is not standardised across venues — one venue's ethereum is another's ERC20 — and normalising here would invent a vocabulary that no venue accepts back.

list_payment_methods(credentials, keyword)

@callback list_payment_methods(
  credentials(),
  keyword()
) :: result([map()])

The funding sources this account can move fiat through.

Optional. Rows are the venue's own maps: a bank account, a card and a balance are different things with different fields, and flattening them into one struct would drop whichever the caller needed.

A payment method being listed does not mean it is usable. Venues hold new bank accounts pending verification, and the status lives in the row. A caller that filters on presence rather than status will pick one the venue will refuse.

list_portfolios(keyword)

@callback list_portfolios(keyword()) :: result([DpExchange.Core.Types.Portfolio.t()])

The portfolios this credential can address.

A portfolio is where you ask, not what you get back: balances, orders and positions are addressed to one with portfolio: id in opts. A venue with a single implicit context declares this :unsupported and ignores the option.

Where the option is omitted on a venue that has portfolios, the package uses the venue's default and does not invent one. A caller needing determinism passes the id.

list_watchlists(keyword)

@callback list_watchlists(keyword()) :: result([DpExchange.Core.Types.Watchlist.t()])

Watchlists held at the venue. The venue's list, which may differ from the host's.

market_status(keyword)

@callback market_status(keyword()) :: result(market_status())

Whether the venue is trading right now.

Crypto venues answer :open. An equity venue answers honestly, including pre- and post-market.

Not cosmetic. A feed that delivers nothing warns loudly and keeps warning, so without this an equities package alarms every night and every weekend — and a real outage becomes indistinguishable from Saturday. The venue is the only thing that knows its own calendar; the policy — whether to trade in extended hours — stays with the consumer.

"Crypto venues answer :open" is not a credential exemption by itself

AdapterContract's assertion 17 (the credential gate) skips a :required venue's market_status/1 only when that venue's own asset_classes/0 is exactly [:crypto] — crypto has no exchange-mandated trading session, so no credential can change what this call reports. That is narrower than it may look: dp_exchange_schwab serves equities and its real market_status/1 calls an authenticated venue endpoint, so a venue that is not crypto-only stays gated and must either authenticate this call for real or declare it :unsupported — see "17. credential gate" in DpExchange.Core.AdapterContract for the full argument and why this is scoped to asset_classes/0 rather than to the callback's name.

place_order(credentials, map, keyword)

@callback place_order(credentials(), map(), keyword()) ::
  result(DpExchange.Core.Types.Order.t())

Places an order. Irreplaceable by definition: this is the act.

place_orders(credentials, list, keyword)

@callback place_orders(credentials(), [map()], keyword()) :: result([map()])

Places several orders in one request. This moves funds.

Optional, and it is not place_order/3 in a loop. A batch is one request the venue accepts or rejects as a unit; N calls are N partial outcomes a caller has to reconcile, and the reconciliation is exactly what goes wrong when the third of five fails. A venue that publishes a batch endpoint gives a consumer an atomicity it cannot build from the single-order call, which is why this is a callback rather than a helper.

A partial batch is the shape to expect, not the exception. Venues validate per order and return per order, so the result is a list the same length as the request, each entry either an order or the venue's refusal of that one. A package must not collapse that into a single ok-or-error: a caller told "the batch failed" when four of five were placed has four positions it does not know about.

Venues cap the size — Webull at 50 — and a request over the cap is refused by the venue, not split here. Splitting would turn one atomic request into several and quietly undo the only reason to call this.

preview_order(credentials, map, keyword)

@callback preview_order(credentials(), map(), keyword()) :: result(map())

Validates an order without placing it, returning the venue's own estimate of what it would cost.

Optional. Schwab and Coinbase serve it. It is the call that checks an order against the venue's rules before committing to it — which matters most exactly where order writes are rate-limited and reads are not.

Declared through supports_order_preview, so a caller can tell "this venue has no preview" from "this package has not implemented one".

preview_replace(credentials, t, map, keyword)

@callback preview_replace(credentials(), String.t(), map(), keyword()) :: result(map())

Validates a change to an open order without making it, returning the venue's estimate of the amended order.

Optional, and distinct from preview_order/3 in the way that matters: preview_order/3 asks what an order that does not exist would cost, and this asks what an order that does exist would cost after a change. A caller cannot get the second by asking the first — the venue prices an amendment against the resting order's own state, including whatever of it has already filled.

The reason to have it at all is the same one behind replace_order/4. Amending is irreversible at the venue, and a caller who cannot price the amendment first is choosing between committing blind and cancel-then-place, which reopens the very window replace_order/4 exists to close.

Declared through supports_order_preview, alongside preview_order/3.

provider_name()

@callback provider_name() :: String.t()

The venue's display name.

quantization(symbol)

(optional)
@callback quantization(symbol()) :: result(map())

Rounds a price and quantity to what the venue will actually accept.

Optional: when a venue does not implement it, a caller skips quantization and falls back to per-asset-class defaults.

quote_conversion(t, t, t, keyword)

@callback quote_conversion(String.t(), String.t(), Decimal.t(), keyword()) ::
  result(DpExchange.Core.Types.Conversion.t())

Quotes a conversion of amount from one asset to another. Nothing moves.

Returns a Types.Conversion with status: :quoted and, where the venue states one, an :expires_at. The rate is held only until then. Committing is a separate call — commit_conversion/2 — and a caller that never commits has done nothing but ask.

remove_approved_address(t, t, keyword)

@callback remove_approved_address(String.t(), String.t(), keyword()) :: result(map())

Removes address from the withdrawal allowlist for network.

Optional. Removal is generally immediate where addition is not, which is the asymmetry a caller should expect: the venue is slow to widen what funds may reach and quick to narrow it.

rename_account(t, t, keyword)

@callback rename_account(String.t(), String.t(), keyword()) :: result(map())

Renames an existing account at the venue.

replace_order(credentials, t, map, keyword)

@callback replace_order(credentials(), String.t(), map(), keyword()) ::
  result(DpExchange.Core.Types.Order.t())

Replaces an open order atomically.

Optional. Every crypto venue in the family cancels and re-places, and on a venue that supports replacement those two calls are not equivalent: cancel-then-place opens a window in which no order is live, and on a moving market that window is the risk.

So this is a claim about risk rather than convenience, and it is declared through supports_order_replace rather than being inferred from the callback existing.

request_approved_address(t, t, arg3, keyword)

@callback request_approved_address(String.t(), String.t(), String.t() | nil, keyword()) ::
  result(map())

Asks the venue to add address to the withdrawal allowlist for network.

Optional, and the most consequential write in this contract: an address on the allowlist is one funds can be sent to. Venues therefore hold new entries under a time lock, and this call requests rather than grants — see Types.ApprovedAddress.usable?/2, which answers nil for an address whose lock has no stated end.

A caller must not treat a successful response as permission to withdraw. The allowlist is read back with list_approved_addresses/1.

runtime_id()

@callback runtime_id() :: atom()

The venue's stable identifier, matching its package's namespace segment.

stake(t, t, keyword)

Stakes amount of asset.

Returns the resulting transaction. This moves funds: a consumer calling it has decided to, and D2 puts that decision with the host rather than with this package.

start_link(keyword)

@callback start_link(keyword()) :: {:ok, pid()} | :ignore | {:error, term()}

Starts the venue directly. See child_spec/1 — a venue with no processes returns :ignore.

subscribe(list, keyword)

@callback subscribe(
  [symbol()],
  keyword()
) :: :ok | {:error, term()}

Subscribes the caller to symbols.

The venue decides everything about how. Connections, sharding, pacing, protocol, whether it streams at all or polls and pushes the results — none of it crosses this boundary, and a caller cannot discover it.

Events arrive as messages to the subscribing process, or to a pid given in opts, tagged so a process subscribed to several venues can tell them apart. The payload is a DpExchange.Core.Types.* struct — the same value the pull endpoints return — so one handler serves a price whether the caller asked for it or was sent it.

Back-pressure is a bounded mailbox, and it is declared

A venue pushing faster than its subscriber consumes drops beyond a stated bound and emits a :degraded notice saying so, and a second one when that subscriber catches up. Growing a mailbox silently until the node dies is the failure this avoids; dropping silently is the failure the notice avoids, and the pair of notices is what lets a consumer bracket exactly the window it has to reconcile from a pull endpoint.

DpExchange.Core.Fanout implements this, and a venue delivers through it rather than writing its own send/2 loop.

This paragraph used to say "drops oldest", and no package implemented any of it. Both halves are worth recording. A sender cannot drop the oldest message in another process's mailbox — nothing in the BEAM lets one process remove a message another has already been sent — so as written the guarantee described something no implementation could have honoured. What a sender can do is decline to add to a queue already past its bound, which is also the better trade here: a quote that arrives while a consumer is thirty thousand messages behind is worthless by the time it would be read, and the frames it would push out are no fresher. Meanwhile all five venues fanned out with a bare send/2 and had never looked at a subscriber's mailbox, so a consumer reading this section was told back-pressure was handled and declared when neither was true. An unimplementable sentence in a contract does not stay a wording problem; it becomes the reason a real guarantee is missing.

subscribe_notices(keyword)

@callback subscribe_notices(keyword()) :: :ok | {:error, term()}

Subscribes the caller to the package's notices — what it is doing and what is going wrong with it.

Distinct from subscribe/2, which carries what the venue says about the market. A monitoring process that never touches market data still needs to know a credential was rejected.

A notice is a prompt to re-read, never the record. Delivery is not guaranteed — see DpExchange.Core.Notice.

test_connection(arg1, keyword)

@callback test_connection(
  credentials() | nil,
  keyword()
) :: result(map())

Whether the venue is reachable and the credential, if given, is accepted.

transfer_internal(t, t, keyword, keyword)

@callback transfer_internal(String.t(), Decimal.t(), keyword(), keyword()) ::
  result(map())

Moves amount of asset between two accounts held at the same venue.

Optional. Not withdraw/5: nothing leaves the venue, no chain is involved, and no address is required. Conflating the two is dangerous in both directions — a caller that reaches for withdraw/5 for an internal move pays a network fee it did not need to, and one that reaches for this expecting an external transfer sends nothing anywhere.

Which accounts a venue exposes and how they are named is the venue's own; opts carries its source and destination identifiers.

unstake(t, t, keyword)

Redeems amount of a staked asset.

Returns immediately; the redemption does not complete immediately. The returned transaction carries :amount_remaining, which is non-zero for as long as the asset is unbonding. A caller that treats the return value as settled will spend an asset it does not have yet — see Types.StakingTransaction.

unsubscribe(list, keyword)

@callback unsubscribe(
  [symbol()],
  keyword()
) :: :ok | {:error, term()}

Stops delivery for symbols.

Addressed by the thing itself, never by a handle. Takes the same identifiers subscribe/2 was given: on crypto the pair, on an equity venue the symbol. A caller already knows what it subscribed to, so a subscription reference would be pure overhead wrapping something it already holds — and one more thing to leak.

A dead subscriber stops delivery too. A venue must not accumulate events for a process that no longer exists.

update_symbols(list, keyword)

@callback update_symbols(
  [symbol()],
  keyword()
) :: :ok | {:error, term()}

Changes a live subscription's symbol set without tearing the venue's connections down.

update_watchlist(t, keyword)

@callback update_watchlist(
  String.t(),
  keyword()
) :: result(DpExchange.Core.Types.Watchlist.t())

Replaces a watchlist's name or membership.

withdraw(t, t, t, t, keyword)

Withdraws amount of asset over network to address.

This is the only operation in this contract that cannot be undone. D2 places the decision to call it with the host, not with this package.

Two failure modes are worth naming because neither is a package bug:

  • the address is not on the venue's allow-list, or is on it and not yet active — see Types.ApprovedAddress
  • the asset requires a memo and none was given, in which case the funds leave and are not credited to anyone

A memo is passed as memo: in opts. A package must not synthesise one.

Functions

core_endpoints()

@spec core_endpoints() :: [{atom(), arity()}]

The endpoints a package must prove before it can stop being EXPERIMENTAL.

The rule matters more than the list

The facade will grow, and someone will have to classify an endpoint this list does not contain. An endpoint is core only if both hold:

  1. Irreplaceable — only this venue can answer it. If a consumer can get the same answer elsewhere, the package failing is an inconvenience rather than a blocker.
  2. Load-bearing — the consumer's primary job fails without it rather than degrading. If the documented behaviour on absence is "the caller does without", it is not core.

Reclassify by applying the two tests, never by amending a table.

Two properties of the boundary

Core is per venue. An endpoint the venue does not offer is :unsupported and drops out of that venue's core set; a market-data-only venue is not held to trading it does not have.

Trading is core exactly when it exists. If place_order/3 is active then the order group is core, and nothing but live trading proves it.

not_supported()

@spec not_supported() :: {:error, :not_supported}

The refusal every unsupported endpoint returns.

The atom, never the string. The source this was extracted from used both — in one module, both forms — so a caller matching the atom silently missed the string and treated a refusal as an unrecognised error.

notice_kinds()

@spec notice_kinds() :: [DpExchange.Core.Notice.kind()]

Every notice kind a venue may emit. See DpExchange.Core.Notice.

peripheral_endpoints()

@spec peripheral_endpoints() :: %{required({atom(), arity()}) => String.t()}

The endpoints outside the core set, each with the test it fails.

Recorded because it is what a future classifier reasons from, not as a list to append to.

required_callbacks()

@spec required_callbacks() :: [{atom(), arity()}]

Every callback a venue package must implement, as {name, arity}.

The conformance suite drives its completeness assertion off this rather than a hand-maintained list, so a callback added here cannot be forgotten there.