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

A venue's capability declaration — the return of `c: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 `t: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.

# `catalog_access`

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

# `catalog_size`

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

# `ceiling`

```elixir
@type ceiling() ::
  %{
    :limit =&gt; non_neg_integer(),
    :per_ms =&gt; pos_integer(),
    optional(:burst) =&gt; pos_integer(),
    optional(:scope) =&gt; 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 an absent `:burst` means "not published", never a depth of zero.
A present `:burst` is a `pos_integer()`: `Capabilities.new/1` refuses `0`, which allows
nothing at once, so even an idle bucket's first request would wait a whole interval.
(`DefaultRateLimiter` accepts `burst: 0`, a test's way to build an exhausted bucket, but a
venue's declaration is a claim about the venue and no venue publishes that.)

## `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.

# `ceiling_scope`

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

# `credential_benefit`

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

# `data_kind`

```elixir
@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_webull` cannot say that `1w`/`1M`/`1y` are equity, option and futures only.
- `dp_exchange_schwab` cannot declare `:order_book` for options alone, which is one of two
  reasons `OPTIONS_BOOK` stays unwired — see that package's
  `docs/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 :fills

**Three 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.

# `maturity`

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

# `session`

```elixir
@type session() :: :pre_market | :regular | :post_market | :extended
```

Which trading session an order names, on a venue whose market closes.

# `t`

```elixir
@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()}) =&gt; 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()
}
```

# `active?`

```elixir
@spec active?(t(), {atom(), arity()}) :: boolean()
```

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

# `data_kinds`

```elixir
@spec data_kinds() :: [data_kind()]
```

The normalised data kinds a subscription can deliver.

# `endpoints_at`

```elixir
@spec endpoints_at(t(), maturity()) :: [{atom(), arity()}]
```

Every endpoint declared at `maturity`.

# `maturities`

```elixir
@spec maturities() :: [maturity()]
```

The maturity vocabulary.

# `maturity`

```elixir
@spec maturity(t(), {atom(), arity()}) :: maturity()
```

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

# `new`

```elixir
@spec new(keyword() | map()) :: t()
```

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.

The scalar fields are checked for kind as well as for vocabulary: `supported_quotes` is a
list of non-empty strings, `max_candles_per_request` a positive integer or `nil`,
`measured_at` a `Date` or `nil`, and `measured_against` a non-empty string or `nil`.

## 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]

# `no_venue_contact?`

```elixir
@spec no_venue_contact?(t(), {atom(), arity()}) :: boolean()
```

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

# `time_in_force`

```elixir
@spec time_in_force() :: [atom()]
```

The time-in-force vocabulary: the atoms `supported_time_in_force` may name, and the only ones
`DpExchange.Core.Types.Order.new/1` accepts for an order's `:time_in_force`. One list for both,
so a venue cannot declare a duration the order it then returns is unable to carry.

---

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