# `DpExchange.Core.Venue`
[🔗](https://github.com/DistortionPoint/dp-exchange-core/blob/v0.3.76/lib/dp_exchange/core/venue.ex#L1)

**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 `c: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 `c:subscribe/2` over its
own polling and pushes the results; a consumer calling `c: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

`c: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
`c: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.

## "Optional" means two different things in this file

Only three callbacks are in `@optional_callbacks`, and only those three may be left out of
a venue module entirely: `c:list_instruments/1`, `c:quantization/1` and
`c:coverage_by_kind/1`. `required_callbacks/0` is every other callback, and the compiler
and the conformance suite insist the module defines each one.

Many other callback docs below begin "Optional". Read that as **a venue may not serve
this**: it declares the endpoint `:unsupported` in `c:capabilities/0` and its function
still exists and answers `{:error, :not_supported}`. It does not mean the function may be
missing.

## A library does not start itself

`c: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.

# `credentials`

```elixir
@type credentials() :: map()
```

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

# `market_status`

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

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

# `maturity`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

# `add_payment_method`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

Cancels an order.

`{:ok, %Order{}}` where the venue's cancel answer carries the order, and
`{:ok, :cancelled}` where it does not: `dp_exchange_coinbase`'s batch-cancel-of-one,
`dp_exchange_webull`'s and `dp_exchange_schwab`'s cancels answer success with no order
data. This type said `Order.t()` only, which three of five venues never returned. A
caller wanting the order after a `:cancelled` asks `get_order/3`; a package does not
fabricate one from the request.

# `capabilities`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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`

```elixir
@callback coverage(keyword()) :: %{required(symbol()) =&gt; 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`
*optional* 

```elixir
@callback coverage_by_kind(keyword()) :: %{
  required(DpExchange.Core.Capabilities.data_kind()) =&gt; %{
    required(symbol()) =&gt; 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`

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

Creates an account or sub-account at the venue.

# `create_watchlist`

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

Creates a watchlist at the venue.

# `delete_watchlist`

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

Deletes a watchlist at the venue.

# `estimate_withdrawal_fee`

```elixir
@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`

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

Accounts visible to the credential.

# `get_auction_imbalance`

```elixir
@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`

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

Balances for the credentialed account.

# `get_contract_stats`

```elixir
@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`

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

A conversion's current state, quoted or committed.

# `get_corporate_events`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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.

**Oldest first, ordered by each candle's own `opened_at`**, never by the order the venue's
rows arrived in. Venues disagree on the order they send, and several do not document it at
all. A series read in the wrong order still yields plausible returns and indicators, just
wrong ones. A venue that has only one candle, or none, satisfies this trivially, which
is why the conformance suite does not check it against a fake that answers with one.

# `get_market_overview`

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

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

# `get_news`

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

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

# `get_notional_balances`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

One order's current state.

# `get_order_book`

```elixir
@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`

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

Orders visible to the credential.

# `get_payment_method`

```elixir
@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`

```elixir
@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`

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

The current price for `symbol`.

# `get_rate_limit_status`

```elixir
@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`

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

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

# `get_screener`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

Every symbol the venue lists.

# `get_top_of_book`

```elixir
@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`

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

Past fills for the credential.

**Oldest first, by each fill's own `:timestamp`**, as `get_trades/2` is.

# `get_trade_volume`

```elixir
@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's** — `get_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`

```elixir
@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.

**Oldest first, by each trade's own `:timestamp`**, as `get_historical_prices` is, and
for the same reason. Venues send the tape newest first, oldest first, or without saying,
and `List.last/1` meaning "the latest print" on one venue and "the oldest" on another is
a wrong answer that raises nothing. Trades with the same timestamp keep the venue's order.

# `get_transactions`

```elixir
@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`

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

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

# `get_volume_profile`

```elixir
@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`

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

One watchlist including its membership.

# `list_approved_addresses`

```elixir
@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`

```elixir
@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`

```elixir
@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`
*optional* 

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

The venue's listings with the fields `c: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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

# `place_orders`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

The venue's display name.

# `quantization`
*optional* 

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

Renames an existing account at the venue.

# `replace_order`

```elixir
@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`

```elixir
@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`

```elixir
@callback runtime_id() :: atom()
```

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

# `stake`

```elixir
@callback stake(String.t(), Decimal.t(), keyword()) ::
  result(DpExchange.Core.Types.StakingTransaction.t())
```

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`

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

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

# `subscribe`

```elixir
@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`

```elixir
@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 `c: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`

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

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

# `transfer_internal`

```elixir
@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`

```elixir
@callback unstake(String.t(), Decimal.t(), keyword()) ::
  result(DpExchange.Core.Types.StakingTransaction.t())
```

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`

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

Stops delivery for `symbols`.

**Addressed by the thing itself, never by a handle.** Takes the same identifiers
`c: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.

**It is feed-wide, not per subscriber.** Nothing in the call names a subscriber, so
unsubscribing a symbol stops it for every process subscribed to it on this venue. Two
consumers sharing one venue's feed must agree on its symbol set, or each run their own
feed.

# `update_symbols`

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

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

# `update_watchlist`

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

Replaces a watchlist's name or membership.

# `withdraw`

```elixir
@callback withdraw(String.t(), String.t(), Decimal.t(), String.t(), keyword()) ::
  result(DpExchange.Core.Types.Withdrawal.t())
```

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.

# `core_endpoints`

```elixir
@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`

```elixir
@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`

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

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

# `peripheral_endpoints`

```elixir
@spec peripheral_endpoints() :: %{required({atom(), arity()}) =&gt; 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`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
