A venue's capability declaration — the return of DpExchange.Core.Venue.capabilities/0,
and the thing a consumer branches on instead of on venue identity.
Three kinds, and conflating them is the easy mistake
Kind 1 — activation and maturity. Is this function answerable, and is it proven?
One map from facade function to :proven | :experimental | :unsupported. This is the
only kind that turns anything on or off.
Kind 2 — domain. For an active function, which arguments are valid? Which order types the venue takes, which quotes it lists, which candle widths it serves. These constrain a call; they do not gate one.
Kind 3 — parameters. Limits and shapes of an active function. Page sizes, rate ceilings, whether volume is reported. These tune behaviour and gate nothing.
Kind 1 is a map, not a field per endpoint
Three states cannot fit in a boolean, and a field per endpoint drifts the moment the facade grows: the field gets added, some venue forgets it, and the default decides. One map cannot fall out of step with the facade, and the conformance suite drives its bidirectional assertion straight off it.
Anything not named in the map is :experimental — the only honest default. Not
:unsupported, which would claim a refusal the venue never made, and certainly not
:proven, which is earned by production use rather than by careful implementation.
What is never declared: transport
There is no has_websocket, no websocket_module, no stream_channels and no
pairs_per_socket, and their absence is deliberate. Both endpoints exist on every
venue — every venue can be pulled and can be subscribed — so there is nothing to
declare and nothing for a consumer to branch on.
Those four fields existed because a host was starting and sharding the venue's
connections and needed to be told how. It no longer is. What a caller legitimately needs
is which kinds of data stream, not which channels carry them: "level2" is one venue's
word, :order_book is everyone's. See data_kind/0.
What is not here because it is not the venue's call
auto_collect, default_quotes and overview_suits_collection were in the declaration
and are not any more. They are consumer collection policy: which quote asset to
collect and what that costs in storage is a business decision, and no venue package ever
read them.
The split is clean. A venue declares what it can serve — supported_quotes, and a
catalog_size class so a consumer knows one venue lists thousands and another millions.
A consumer decides what it will collect.
no_venue_contact is a refinement of Kind 1, not a fourth kind
credential_benefit: :required is a claim about the VENUE: no active endpoint is
served without a credential. It is a true claim about dp_exchange_webull and is also
a wrong predictor of get_fees/2 there, which answers a flat crypto spread rate
captured from Webull's own published pricing (source: :published_rate in its result)
and never builds a request — no credential could change what it returns, because
nothing it returns comes from asking. no_venue_contact names that per-ENDPOINT fact
explicitly, so a consumer (and AdapterContract's assertion 17) can tell "the venue
requires credentials in general" apart from "this specific endpoint has no venue call
for a credential to gate."
This was found the hard way: a 2026-09-06 sweep on dp_exchange_webull gated
get_fees/2 behind a credential to satisfy assertion 17's (then-unqualified) rule,
reasoning that the real path "had never run through Auth.headers/2" — true, and the
reason there was nothing to gate, not a reason to add a gate. That broke a real
consumer who resolves fees before any account is attached, by design carrying no
credential at that point.
What belongs here is narrow, and the field name says the test: would this
endpoint's answer, on THIS venue, change if the venue itself were unreachable? An
endpoint that reads only compile-time constants or arguments already in hand belongs
here. An endpoint that reaches the venue via any transport — HTTP, WebSocket, a cached
response from one — does not, even if it currently succeeds without a credential for
an unrelated reason (a public endpoint on a :no_difference venue is not
no_venue_contact; it still dials out, credentials are just not what gates it).
Declaring an endpoint here that does contact the venue defeats assertion 17 exactly
the way the hand-maintained exemption list it replaced did, so a venue adding an entry
here should be able to point at the real implementation and show the absence of any
request-building call — the same standard dp_exchange_webull's get_fees/2 moduledoc
documents for itself.
Summary
Types
How the venue's catalogue can be reached.
Roughly how many instruments the venue lists.
Requests per interval, as the venue publishes it.
What a ceiling is counted against.
What credentials buy on this venue.
A kind of data a subscription can deliver — normalised, so a consumer never learns a venue's channel vocabulary.
How well one endpoint is known.
Which trading session an order names, on a venue whose market closes.
Functions
Whether an endpoint is answerable at all.
The normalised data kinds a subscription can deliver.
Every endpoint declared at maturity.
The maturity vocabulary.
The maturity of one endpoint.
Builds a declaration, validating what a struct alone cannot express.
Whether an endpoint is declared to make no venue call at all.
Types
@type catalog_access() :: :enumerable | :query_only
How the venue's catalogue can be reached.
:enumerable — get_symbols/1 returns the whole list. :query_only — there is no
list-everything call and get_symbols/1 requires a search term.
@type catalog_size() :: :small | :large | :vast | :unknown
Roughly how many instruments the venue lists.
A class rather than a count, because the count changes daily and the decision it informs does not. It exists so a consumer can tell that re-pulling one venue's catalogue on a timer is fine and another's is not — the difference between a thousand instruments and millions is a different strategy, not a different number.
@type ceiling() :: %{ :limit => non_neg_integer(), :per_ms => pos_integer(), optional(:burst) => pos_integer(), optional(:scope) => ceiling_scope() } | nil
Requests per interval, as the venue publishes it.
There are two of these on a venue with a better authenticated path, and which applies depends on what the caller supplied. Carrying one number means a package holding credentials meters itself against the public limit and leaves most of its budget unused.
:burst is optional because most venues do not publish one
A GCRA limiter takes three parameters — rate, interval, and how far a caller may run ahead of the rate before being made to wait. This type carried only the first two, so a venue that publishes its burst depth had nowhere to declare it and the package had to hardcode the number beside the declaration, which is precisely the drift this struct exists to prevent.
Gemini publishes one: "we offer a burst rate of five additional requests that are queued". It is the first venue in the family to do so, and it found this gap.
Optional, not required, because a venue that does not publish a burst depth must not be
made to invent one — and nil here means "not published", which a consumer can tell
apart from a declared burst of zero.
max_leverage: :per_account, for venues that margin without a single ceiling
max_leverage began as Decimal.t() | nil, on the reasonable-looking assumption that a
venue which margins has a leverage. Schwab does not, and the assumption cost more than
it looked: nil with supports_margin: true raises, so the only ways to ship were to
declare supports_margin: false, which is false, or to invent a multiplier — the
substitution this whole family exists to refuse.
A Schwab MarginAccount carries five different buying powers — overall, non-marginable,
day-trading, option and stock — which are not multiples of one another, so no one of them
is "the" leverage. A CashAccount at the same venue carries none of them, so any number
reported for one would be invented. The Reg-T fields, regTCall and sma, are call and
credit amounts rather than ratios. Equities margin is not crypto margin, and Kraken's
5x does not carry over.
So :per_account is a positive statement: the venue margins, and the ceiling belongs
to the account rather than to the venue — read it from the balance response. nil still
raises when supports_margin: true, because nil means "nobody said", and the error
names :per_account so a venue author discovers the option instead of inventing a number.
@type ceiling_scope() :: :credential | :account | :application
What a ceiling is counted against.
:credential is the default and was the only case while every venue was crypto: one
key, one budget. The others exist because Schwab's is neither.
:credential— per API key. One key, one bucket.:account— per account, so a host running several accounts through one registration shares nothing between them and cannot infer one budget from another.:application— per registered application, shared across every credential and account it issues. A ceiling a host cannot raise by adding keys.
This changes what a caller must do, which is why it is declared rather than left implicit: a limiter keyed by credential silently over-permits a venue that counts by account.
@type credential_benefit() :: :no_difference | :higher_ceiling | :required
What credentials buy on this venue.
A boolean could not say this. The old field answered "does this venue reject public calls without credentials", which has two states, while the real question has three — and the middle one is the common case:
:no_difference— public data is served, and credentials change nothing about it.:higher_ceiling— public data is served, but the authenticated path has a materially higher rate limit. A package holding credentials should be using it.:required— public data is not served at all without credentials.
@type data_kind() ::
:quotes
| :top_of_book
| :order_book
| :trades
| :candles
| :orders
| :fills
| :balances
| :positions
A kind of data a subscription can deliver — normalised, so a consumer never learns a venue's channel vocabulary.
A list-valued capability is a UNION across asset classes, not an intersection
streamable, authenticated_streamable and historical_timeframes are flat lists with
no asset-class dimension, and until 2026-09-10 nothing said what that flatness means for
a venue serving more than one class. Two readings are available and they are opposites:
"every path serves this" or "some path serves this". A venue author had to guess.
The rule is the union: a value belongs in the list if the venue serves it on any path
this package reaches. Both multi-asset venues in the family had already chosen that
reading independently, which is how the ambiguity stayed invisible —
dp_exchange_webull's Rest.wide_timeframes/0 says so in its own doc, and
dp_exchange_schwab's Rest.timeframes/0 calls its list "canonical candle widths this
venue serves".
What makes a union honest rather than an overstatement is the second half of the rule:
the per-call path must FAIL CLOSED for a combination it does not serve.
dp_exchange_webull is the worked example — historical_timeframes includes 1w and
1M because the equity, option and futures bars serve them, and
get_historical_prices/5 answers {:error, {:unsupported_timeframe, _}} for a crypto
category rather than degrading to the nearest width it does serve. Declare the union and
substitute at the call, and you have built exactly the failure this family exists to stop.
The limitation this leaves, stated rather than implied
A consumer cannot ask "which widths for crypto specifically" or "is the order book streamable for options". They get the venue-wide answer and discover the per-class truth from an honest refusal. That is a real gap with two confirmed instances:
dp_exchange_webullcannot say that1w/1M/1yare equity, option and futures only.dp_exchange_schwabcannot declare:order_bookfor options alone, which is one of two reasonsOPTIONS_BOOKstays unwired — see that package'sdocs/design/ideas/schwab-depth-and-account-activity-streaming.md.
Closing it means an asset-class dimension on these fields, which is a breaking change to a published type for a gap no consumer has yet reported hitting. It is recorded here so the next person weighing it starts from the two instances rather than from zero — and so that a venue author reading this file today knows which reading to write against.
:top_of_book is not :order_book
Venues stream these on separate channels because they are separate things: a best-bid/ask
feed carries one level and a depth feed carries many, and the first is deliberately
cheaper. They are kept apart here for the same reason Types.TopOfBook is not
Types.OrderBook — a consumer subscribing to depth and receiving a single level would be
told it has a book when it has a quote.
What these were measured against
Gemini's AsyncAPI document and Schwab's Streamer service list, read 2026-08-31:
bookTicker -> :top_of_book
depth, depth5/10/20 (+Fast) -> :order_book
trade -> :trades
ordersAccount, ordersSession -> :orders
balancesAccount (+Snapshot) -> :balances
positionsAccount (+Snapshot) -> :positions
LEVELONE_* -> :quotes
NYSE_BOOK, NASDAQ_BOOK, OPTIONS_BOOK -> :order_book
CHART_EQUITY, CHART_FUTURES -> :candles
ACCT_ACTIVITY -> :orders and :fillsThree streamed channels have no kind here yet, deliberately: Gemini's
settlementsAccount and contractStatus are prediction-market lifecycle events, and its
requestForQuote* family is an RFQ surface. None has a facade home, and inventing a kind
before the shape is decided would put a name on the contract that nothing can deliver.
@type maturity() :: :proven | :experimental | :unsupported
How well one endpoint is known.
:experimental is the default and the only honest starting state. :proven is earned
per endpoint by production use.
@type session() :: :pre_market | :regular | :post_market | :extended
Which trading session an order names, on a venue whose market closes.
@type t() :: %DpExchange.Core.Capabilities{ authenticated_ceiling: ceiling(), authenticated_streamable: [data_kind()], catalog_access: catalog_access(), catalog_size: catalog_size(), credential_benefit: credential_benefit(), endpoints: %{optional({atom(), arity()}) => maturity()}, has_staking: boolean(), historical_timeframes: [String.t()], max_candles_per_request: pos_integer() | nil, max_leverage: Decimal.t() | :per_account | nil, measured_against: String.t() | nil, measured_at: Date.t() | nil, no_venue_contact: [{atom(), arity()}], public_ceiling: ceiling(), reports_trade_volume: boolean(), streamable: [data_kind()], supported_instrument_types: [atom()], supported_order_types: [atom()], supported_quotes: [String.t()], supported_sessions: [session()], supported_time_in_force: [atom()], supports_fractional_shares: boolean(), supports_margin: boolean(), supports_multi_leg_orders: boolean(), supports_order_preview: boolean(), supports_order_replace: boolean(), supports_short_selling: boolean() }
Functions
Whether an endpoint is answerable at all.
:proven and :experimental both mean it works — maturity says how well a thing is
known, never whether it runs.
Examples
iex> caps = DpExchange.Core.Capabilities.new(
...> endpoints: %{{:place_order, 3} => :unsupported},
...> supported_quotes: []
...> )
iex> DpExchange.Core.Capabilities.active?(caps, {:place_order, 3})
false
@spec data_kinds() :: [data_kind()]
The normalised data kinds a subscription can deliver.
Every endpoint declared at maturity.
@spec maturities() :: [maturity()]
The maturity vocabulary.
The maturity of one endpoint.
Anything undeclared is :experimental — a venue that forgot to mention an endpoint has
not thereby claimed it is proven, nor refused it.
Examples
iex> caps = DpExchange.Core.Capabilities.new(endpoints: %{}, supported_quotes: [])
iex> DpExchange.Core.Capabilities.maturity(caps, {:never_declared, 1})
:experimental
Builds a declaration, validating what a struct alone cannot express.
Raises rather than returning an error tuple: this runs at venue-package definition time, where a wrong value is a programming error rather than a runtime condition — and a declaration that is wrong is worse than one that is missing, because a consumer will act on it.
Examples
iex> caps = DpExchange.Core.Capabilities.new(
...> endpoints: %{{:get_price, 2} => :proven},
...> supported_quotes: ~w(USD)
...> )
iex> DpExchange.Core.Capabilities.maturity(caps, {:get_price, 2})
:proven
iex> DpExchange.Core.Capabilities.new(
...> endpoints: %{{:get_price, 2} => :probably_fine},
...> supported_quotes: ~w(USD)
...> )
** (ArgumentError) endpoint {:get_price, 2} declares unknown maturity :probably_fine — must be one of [:proven, :experimental, :unsupported]
Whether an endpoint is declared to make no venue call at all.
A narrower claim than credential_benefit: :required, which describes the venue in
general: this says one specific active endpoint answers from data already in hand — a
captured, published constant or a purely local computation — so it never dials out and
no credential could change what it returns. See the moduledoc's no_venue_contact
section for what belongs here and what does not.
AdapterContract's assertion 17 reads this to know which active endpoints on a
credential_benefit: :required venue may legitimately answer {:ok, _} with
credentials stripped.
Examples
iex> caps = DpExchange.Core.Capabilities.new(
...> endpoints: %{{:get_fees, 2} => :experimental},
...> no_venue_contact: [{:get_fees, 2}],
...> supported_quotes: []
...> )
iex> DpExchange.Core.Capabilities.no_venue_contact?(caps, {:get_fees, 2})
true
iex> DpExchange.Core.Capabilities.no_venue_contact?(caps, {:get_price, 2})
false