Current section

Files

Jump to
Raw

usage-rules.md

# Using `dp_exchange_robinhood`

> **EXPERIMENTAL.** Not run in production. Pin three-part. Maturity is per endpoint —
> read `capabilities/0`, not this banner.

Everything general is in
[`dp_exchange_core`'s usage rules](https://hexdocs.pm/dp_exchange_core/usage-rules.html).
This file is only what is **specific to Robinhood**.


## BREAKING — `Quote` and `OrderBook` no longer carry `:timestamp`

They carry **`:venue_time`** (the venue's own, `nil` where the venue publishes none) and
**`:observed_at`** (when this package read it, always present) — the shape
`Core.Types.TopOfBook` has always had. (This is a family-wide change recorded for history:
this venue returns neither type today — `get_price/2` and `get_order_book/2` are
`:unsupported` — and its book is a `Core.Types.TopOfBook`, which has carried both fields
from the start.)

```elixir
# before
quote.timestamp

# after
quote.venue_time  # may be nil — the venue did not date this
quote.observed_at # always present
```

**Why it had to break.** `:timestamp` was documented as the venue's own, "never invented",
and two packages in this family could not keep that promise: the frames they decode carry no
venue time at all. With one field their only options were to lie or to drop real data, and
they lied. Now they can say `nil` and mean it.

**What to do with `nil`.** Whatever you would have done with a wrong answer, but knowingly.
The consumer who decided this design stores `venue_time` as their time-series point time
where it is present, and where it is `nil` stores `observed_at` **and records that they
did** — so a mis-bucketed value is attributable rather than invisible. That decision was not
expressible before, because there was no way to see which kind of time you had.

`Trade`, `Fill`, `Balance` and `OrderBookDelta` are **unchanged** — they keep a single
`:timestamp`, because every one of them is built from a venue-supplied time and fails closed
without it.

Full reasoning and the options that were weighed:
[`dp_exchange_core` issue #31](https://github.com/DistortionPoint/dp-exchange-core/issues/31).

## This venue has no streaming API, and you cannot tell

Robinhood Crypto publishes no socket. `subscribe/2` is served by a REST poll inside this
package and delivers the same `Core.Types.TopOfBook` to the same subscriber as a WebSocket
venue would. **Not `Core.Types.Quote`** — see the next section for why.

```elixir
children = [{DpExchange.Robinhood, credentials: creds, symbols: ["BTC-USD"], subscriber: self()}]
```

The one visible difference is `coverage/1`, which reports **`:internal_poll`** rather than
`:stream`. That is deliberate: the difference shows up as *what is arriving*, never as
*how*, so nothing above the facade branches on transport.

Do not build a poll of your own on top of this. The package already polls, paced against
this venue's budget, and a second loop doubles the request count for no extra data.

**Cadence and cost.** One cycle every 30 seconds (`interval_ms:`, default `30_000`), and each
cycle is ONE signed request carrying every wanted symbol (at most 100 per request, this
package's own conservative cap since the vendor states none: a larger set is sent as several
requests and merged; every request after the cycle's first shares the `bisect_budget_ms:`
below). A universe too slow to request whole inside that budget is not starved at its tail:
which chunk goes first rotates each cycle, so every symbol is requested across cycles. The
symbols a cycle left out are in no `coverage/1` until a fresh book arrives for them, and the
first such cycle raises one `%Core.Notice{kind: :degraded, severity: :warning}` (to
`subscriber:` and `subscribe_notices/1` registrants, `details.skipped` the count), with one
`severity: :info` notice when a cycle again fits. Not once per cycle. If the
venue refuses a request (400 or 404), it is split in half and asked again, down to single
symbols, for at most `bisect_budget_ms:` (default `20_000`; a positive integer or the feed does not start,
`{:invalid_option, :bisect_budget_ms}`). A symbol the venue refused alone is delivered to
`subscriber:` as `{:dp_exchange, :robinhood, {:refused, symbol, reason}}`, left out of the
bulk request from then on, and asked for by itself each cycle until it answers again or you
drop it. A 401 or 403 is not split and blames no symbol: it is the cycle's error. A symbol the
venue simply omits from a response is silence, retried next cycle, never a refusal.

**Every option the supervision tree reads.** `credentials:` (the api key and base64 seed;
wrapped so a crash report never prints them), `symbols:` (the boot set; upper-cased, a list of
non-blank strings or the feed does not start), `subscriber:` (above), `name:` / `feed:` /
`limiter:` (process names; defaults `DpExchange.Robinhood.Supervisor`, `...Feed`,
`...RateLimiter`), `interval_ms:` (default `30_000`), `start_delay_ms:` (wait before the first
poll; default none), `bisect_budget_ms:` (above), `max_queue_len:` (below),
`rate_limit_blocking:` (default `true` for the poll, so it waits for capacity rather than
skipping a symbol), and the request options forwarded to every poll: `base_url:`,
`retry_attempts:`, `retry_delay:` (milliseconds), `timeout:`, `log_requests:`, `weight:`,
`max_retry_after_s:` and `max_body_bytes:`. `plug:`, `adapter:` and `req_adapter:` are test
seams for a fake transport and are not for production use. `environment:` may be absent,
`nil` or `:production`: this venue has no sandbox, and any other value stops the feed at start
(`{:invalid_option, :environment}`) rather than poll production under the name of a test.

## A monitoring process can subscribe to notices separately from market data

**Pass `subscriber:`, because there is no default.** A feed started without one delivers
data to nobody, and sends notices only to `subscribe_notices/2` registrations. It used to
default to the Feed itself, which then delivered each book back to its own mailbox in a loop
and to nobody else.

**`subscribe/2` honours `to:`** (default: the calling process). Books and refusals go to
every pid that subscribed, and to the `subscriber:` given to the supervision tree above,
which stays one of them. A subscribed pid is monitored and dropped when it dies; a registered
name is kept (and skipped while nothing holds it). Delivery is bounded by `:max_queue_len`
like every other venue's. `unsubscribe/2` removes symbols from the polled set; it does not
remove the subscriber. `subscribe([])` registers the subscriber and polls nothing. A `to:`
that is not a pid or a registered name (`true` and `false` are not names) answers
`{:error, {:invalid_subscriber, to}}`.

```elixir
:ok = DpExchange.Robinhood.subscribe(["BTC-USD"], to: consumer_pid)
```

`subscribe_notices/1` registers `opts[:to]` (default: the caller) for this feed's own
`Core.Notice.t()` traffic — the coverage-outage pair fired when this poll starts or stops
delivering anything at all, the `:degraded` budget notices described above, and the
`:link_down` notices below for a poller that crashed or did not answer a coverage read — and
that registration is additive, not exclusive.
A monitoring process that never wants a book can register here without displacing whoever
already gets the quotes:

```elixir
:ok = DpExchange.Robinhood.subscribe_notices(to: monitoring_pid)
```

Answers `{:error, :feed_not_started}` if the feed named in `opts[:feed]` (default: the
one this venue's own supervision tree started) is not running — the same shape
`subscribe/2` already answers, rather than a bare `:ok` that quietly registered nothing.
`:to` must be a pid or a registered name; anything else (including `true` and `false`)
answers `{:error, {:invalid_subscriber, to}}`, checked before the feed is looked at (it used
to be `{:invalid_field, :to}`, and an unusable value used to be stored and crash the feed
on its next notice). A `subscriber:` given
at start must be a pid or a name too, or the feed does not start
(`{:invalid_option, :subscriber}`).

**Options are checked, not ignored.** Options that are not a keyword list answer
`{:error, {:invalid_options, value}}` from every facade call that reads them (it used to
raise in your process, `place_order/3` and `cancel_order/3` included); `coverage/1`,
`coverage_by_kind/1` and `wanted/1` have no room for an error, and an empty answer would read
as "nothing covered", so they raise `ArgumentError`.
`get_top_of_book/2`, `get_symbols/1`, `list_instruments/1`, `subscribe/2` and
`update_symbols/2` serve nothing windowed, depth-limited or candle-based, so `:limit`,
`:depth`, `:since` and `:include_partial` on them answer `{:error, {:unsupported_option,
key}}` instead of being dropped (a `nil` value is an absent option). `get_orders/2` keeps its
own `:limit`, described below.

**An option a call does not read is refused (BREAKING).** A misspelt `limt:` or `sinse:` used
to be ignored and answer a different question than you asked; every option-taking call now
checks its options with `DpExchange.Core.Options.check/2` before anything is signed or sent
and answers `{:error, {:unknown_option, key}}` (the first offending key, `nil` values
included); `coverage/1`, `coverage_by_kind/1` and `wanted/1` raise `ArgumentError`. The Fake
refuses identically and does not consume a queued injection on a refused call. A function
answering `{:error, :not_supported}` does so whatever its options. **A shared transport key is
accepted only by a call that reads it.** A call that sends an HTTP request (market data,
balances, accounts, orders, quantization, estimated price) accepts the shared transport keys
(`DpExchange.Core.Options.transport_options/0`: `credentials`, `base_url`, `plug`, `limiter`,
`timeout`, `retry_attempts`, ...) and `:timestamp` where it signs; every HTTP-facing one
(`plug`, `adapter`, `req_adapter`, `limiter`, `timeout`, `retry_attempts`, `retry_delay`,
`max_retry_after_s`, `max_body_bytes`, `weight`, `log_requests`, `rate_limit_blocking`) reaches
`Core.HttpClient`; none is accepted and dropped (`cancel_order/3` alone drops the two retry
keys from this list: it is sent once). **`:environment` is read only to be refused**:
Robinhood Crypto publishes no sandbox, so on a request call anything but absent, `nil` or
`:production` answers `{:error, {:unsupported_option, :environment}}`, instead of sending a
call to production that you believed was a test. A call that sends no request
(`subscribe/2`, `unsubscribe/2`, `update_symbols/2`, `subscribe_notices/1`, `coverage/1`,
`coverage_by_kind/1`, `wanted/1`, `market_status/1`) accepts only its own keys below plus `:credentials` (one
credential set may be handed to every call); `timeout:`, `plug:`, `environment:` and the rest
answer `{:error, {:unknown_option, key}}` (the three reads raise `ArgumentError`), because
nothing there would honour them. Each call reads exactly (the table is
`DpExchange.Robinhood.Rest.function_options/1`):

| Call | Own options |
| --- | --- |
| `get_top_of_book/2`, `get_symbols/1`, `list_instruments/1` | `:limit`, `:depth`, `:since`, `:include_partial` (each answers `:unsupported_option`) |
| `quantization/2`, `get_accounts/2`, `cancel_order/3`, `get_estimated_price/5` | none (`:account_number` is refused: these endpoints take no account) |
| `get_balances/2` | `:account_number`, `:asset_codes` |
| `place_order/3` | `:account_number`, `:client_order_id` |
| `get_order/3` | `:account_number` |
| `get_orders/2` | `:account_number`, `:cursor`, `:limit`, `:created_at_start`, `:created_at_end`, `:updated_at_start`, `:updated_at_end`, `:symbol`, `:side`, `:type`, `:state` |
| `subscribe/2` | `:feed`, `:to`, plus the four unsupported market keys |
| `update_symbols/2` | `:feed`, plus the four unsupported market keys |
| `unsubscribe/2`, `coverage/1`, `coverage_by_kind/1`, `wanted/1` | `:feed` |
| `subscribe_notices/1` | `:feed`, `:to` |
| `market_status/1` | none (always `{:ok, :open}`; any key but `:credentials` is `:unknown_option`) |

**`:timestamp` is whole seconds.** It is the `x-timestamp` header, which the venue honours for
30 seconds. A present `:timestamp` that is not a positive integer (a float, a string, `0`, a
negative) answers `{:error, {:invalid_field, :timestamp}}` from every call that signs, before
anything is signed or sent; on `place_order/3` that keeps it a plain error rather than an
unconfirmed write. **The Fake also refuses a `:base_url` the real path would not sign for**
(`{:error, {:insecure_url, :robinhood}}`, `{:error, {:invalid_base_url, :not_a_string}}`),
after the call's arguments and options and before credentials (`place_order/3` checks
credentials first, as the real path does). A feed started with `:bisect_budget_ms` above what
its interval allows has its poller's fetch timeout raised to the budget plus 10 seconds, so a
long bisection is not killed part-way. An unanswered `coverage/1` read sends one `:link_down`
per outage, closed by the next good poll's `:link_up`, not one per read. An outage opened while
no symbol is wanted (nothing is polled then) is closed by a `:link_up` with
`details.reason: :poll_idle` as soon as the poller is running again or the set is emptied. The
Fake answers `get_estimated_price/5` for `side: "both"` with a `"bid"` row and an `"ask"` row per
quantity, never a row whose side is `"both"`.

Requires `dp_exchange_core ~> 0.3.111`.

**A subscriber that dies releases its symbols.** `subscribe/2` monitors the `:to` pid. When the
last registered subscriber has died and the feed was started without a `:subscriber`, the
symbols added for them leave the polled set. Symbols given at boot or to `update_symbols/2`
stay, as do all symbols while a boot `:subscriber` exists.

## A crashed poller costs one restart, never your whole symbol set

The poll is a **linked** child of `Feed` — not a supervised sibling you can restart
independently. `Feed` traps exits, so the poller dying abnormally does not take `Feed`
down with it: `coverage/1` and `coverage_by_kind/1` clear (the fresh poller starts with
nothing delivered yet), you get a `:link_down` `Core.Notice`, and this package restarts
the poll with the symbols this feed actually had — including any added afterward via
`update_symbols/2` — without you calling anything again. The restart is bounded: more than five poller crashes
within a minute stop `Feed` with `{:poller_crash_loop, reason}`, handing the loop to the
supervisor (which restarts it from the static `opts`, as below). A poller that is alive but
did not answer a `coverage/1` read gives that one read `%{}` and a `:link_down` notice
(`severity: :warning`); the feed keeps running. A fetch task killed from outside does not
kill the poll either.

**A `:link_down` is closed by a `:link_up`.** The first poll cycle that delivers a book after
a `:link_down` (a crashed poller or an unanswered coverage read) emits one
`%Core.Notice{kind: :link_up}` with `details.reason` and `details.symbols` (the wanted set,
sorted), latched: not once per cycle, and none without a prior `:link_down`.

**A 401 or 403 on the poll is a `:credentials_rejected` notice.** One
`%Core.Notice{kind: :credentials_rejected}` when a cycle is refused for credentials, latched per
feed (not one per cycle) and cleared by the next cycle that delivers a book. Coverage is
unchanged: the symbols simply stop being covered, as before.

**What still costs you your whole symbol set: `Feed` itself crashing** — a bug outside
the poller-crash path, or anything that kills the `Feed` pid directly.
`DpExchange.Robinhood.Supervisor` restarts `Feed` under `:one_for_one`, but from the
*static* `opts` your supervision tree started it with; every `update_symbols/2` and
`subscribe_notices/1` call you made afterward is gone. Nothing inside this package can
replay those calls — it never held onto the functions or the process that made them.
If your consumer needs to survive a `Feed` restart unattended, monitor the `Feed` pid
(or the `DpExchange.Robinhood` pid it sits under) yourself and re-issue
`update_symbols/2` on `:DOWN`.

**Your `private_key` will not appear in the crash log.** `Feed` keeps the credentials you
supplied at start for as long as it runs — it needs them to rebuild the poller's `fetch`
callback after a restart — and a crash of `Feed` logs its state via OTP's default crash
report, which is where you *would* see the key, because a crash report prints unredacted
`Logger` metadata otherwise. The credential map is wrapped in a struct before it ever
reaches state, so the crash line reads `credentials:
#DpExchange.Robinhood.Credentials<...>` rather than the key pair itself. This is not a
claim about your own code: if you read `state.credentials` yourself via
`:sys.get_state/1` or similar, you get the same struct — call `Map.from_struct/1` on it
to get the plain map back.

## Credentials are required for everything, market data included

Every call is signed with an Ed25519 key — the book, the catalogue, accounts, holdings and
orders alike. There is no anonymous endpoint on this venue at all:

```elixir
{:ok, book} = DpExchange.Robinhood.get_top_of_book("BTC-USD", credentials: %{
  api_key: "rh-api-…",
  private_key: "<base64 32-byte seed>"
})
```

Without credentials, every one of those calls returns
`{:error, {:missing_credentials, :robinhood}}` before any request is built — an `:error`,
not a `:refused`, because a missing local credential never reaches the venue and is never
the venue's own word about anything.
**A blank credential counts as a missing one.** An `:api_key` or `:private_key` that is `nil`,
`""`, or only whitespace, is refused locally with `{:error, {:missing_credentials, :robinhood}}` — it is
never signed with. This matters because the usual way a credential goes missing is not a
`nil`: it is a `.env` line reading `NAME=` with nothing after it, and `System.get_env/1`
hands that back as `""`. Signing with it produced a well-formed request the venue refused
for a reason naming signatures, which points at the signing code rather than at the
credential.

`DpExchange.Robinhood.Fake` — the in-process double
your own tier-1 tests run against — answers the identical shape for the identical reason,
on every function that reaches a real endpoint, not only the market-data ones. If your test
suite pinned the fake's older answer here (`{:refused, :missing_credentials}`, or an
account/order call that quietly succeeded with no credentials at all), that was the fake
being *differently* capable than this venue rather than *less* capable than it, and your
test was passing against behaviour this venue cannot produce.

**Parity guarantee:** every public function on `DpExchange.Robinhood` exists on
`DpExchange.Robinhood.Fake` with the same name and arity (including
`venue_does_not_serve/0` and `quotes/0`); a test asserts that the facade's
`__info__(:functions)` minus the Fake's is empty. The fake may have extra arities, never
fewer.

**The fake remembers the orders it placed, per process.** `place_order/3` issues a UUID,
a new one per order; `get_order/3` and `get_orders/2` (narrowed by `:symbol`, `:side`, `:type` and
`:state`) return those orders with their own symbol, side, size and price, and
`cancel_order/3` returns the order cancelled and later reads say `:cancelled`. An id the
fake never issued is the venue's 404, `{:refused, :not_found}`, on a read and on a
cancel. A test that reads an order it never placed gets that refusal, not a canned order.
**`client_order_id` is an idempotency key in the fake too:** placing again with a key it has
already seen (on the request, or `opts[:client_order_id]`) returns the original order, as
it now stands, instead of a second one.

**The private key is the base64 32-byte seed Robinhood issues**, not a 64-byte secret key.
Passing the wrong one is refused here with `{:error, {:invalid_private_key, reason}}` —
`{:expected_32_bytes, n}`, `:not_base64` or `:not_a_string` (a value that is neither a
string nor `nil`; `nil` is a missing key) — rather than producing a
signature the venue rejects with nothing to explain it. A key or private key with
surrounding whitespace or a control character, typically a trailing newline from a `.env`
file, is `{:error, {:invalid_credentials, :robinhood}}` (it used to be
`{:invalid_api_key | :invalid_private_key, :surrounding_whitespace_or_control_characters}`).
The error names the venue, never the field or the value.

A `:base_url` that is not `https` with a host (a loopback host may use `http` for a local
stub) is refused before anything is signed as `{:error, {:insecure_url, :robinhood}}` (it
used to be `{:insecure_base_url, scheme}`); a non-string is `{:invalid_base_url,
:not_a_string}`.

You hold the credentials. A direct call signs one request with them and keeps nothing. The
supervised poll is the exception: it signs every cycle, so it keeps the credentials you
started it with (wrapped, as described above).

## `get_price/2` is `:unsupported` — this venue has no last-trade data at all

If you came here after filing an issue that looked like a Robinhood quote returning a
fabricated price, this is that incident's writeup — **DpCryptoManagement's issue #21.**

`best_bid_ask` is the only quote-adjacent endpoint this venue serves, and it carries only a
bid and an ask — never a trade price. An earlier version of this package filled
`Core.Types.Quote.price` from the ask whenever
the venue sent none. That produced a real-looking number with the wrong meaning: a taker's
ask, presented as a trade that never happened. `Core.Types.Quote`'s own moduledoc now names
this incident directly as the reason `Quote` carries no bid or ask field at all — a package
filling `price` from `ask` "is exactly what one of them did."

Removing that fallback was correct, and it left nothing honest for a last-trade call to
return. There is no separate trade-tape endpoint to fall back to either: Robinhood Crypto's
documented surface is nine operations in total, and none of the other eight is a trade feed
— confirmed by reading all five of the vendor's documentation pages, recorded in
`docs/reference/robinhood/negative-claims.md`. `get_price/2` therefore always returns
`{:error, :not_supported}`, and `venue_does_not_serve/0` lists it as the venue's own
absence, not a gap in this package.

A `bid` or `ask` of `0` means no resting order on that side (`nil`); an absent or `null`
side is `nil` too, so one side alone is a real one-sided book. A side that is PRESENT but
unreadable (an empty string, `NaN`, a non-number, a negative) is refused by
`get_top_of_book/2` as `{:error, {:invalid_decimal, :bid | :ask, value}}` and never becomes
`nil`: `nil` means "the venue says there is no order". A row with neither key at all (a
renamed field) is `{:error, {:missing_required_field, :bid_ask}}`. On the bulk/feed path a
refused row is dropped (that symbol is uncovered this cycle), never delivered as an empty
book. In `value`, a short string is quoted as the venue sent it and a long one is cut. A
number with an absurd exponent (beyond +-1000) is unreadable like `NaN`. The bulk read
returns only symbols it was asked for, each once: a row for another symbol is dropped, and a
symbol the venue answers twice is returned as its first readable row.

`bid` and `ask` are both real and both still live — through `get_top_of_book/2` and the
`:top_of_book` poll above. If your code wants "the price," pick one of `bid` or `ask`
deliberately rather than reaching for a `price` field that no longer exists: **it is not a
mid**, and a series built from the ask sits a spread above a mid-based series from another
venue, which matters the moment you compare two venues' numbers.

## No candles, no order book, no volume

The venue publishes none of them:

| | |
|---|---|
| `get_historical_prices/4` | `{:error, :not_supported}` |
| `get_order_book/2` | `{:error, :not_supported}` |
| `bid_size` / `ask_size` on a book | always `nil` — `best_bid_ask` publishes no size |

`historical_timeframes` is an **empty list**, which is the honest answer for a venue with
no candle endpoint. Route backfill and volume-dependent work elsewhere.

`venue_does_not_serve/0` tells you which `:unsupported` endpoints are the venue's shape
versus which this package simply has not ported — both answer the same way, but only one of
them might change.

## A missing venue timestamp does not fail the call

`get_top_of_book/2` carries `venue_time: nil` rather than refusing the call — and on this
venue that is **every** book, not an occasional one: v2's `best_bid_ask` response
(`V2BestBidAsk`) is three fields, `symbol`, `bid` and `ask`, with no `timestamp` property at
all. That is correct, not a gap: `Core.Types.TopOfBook` itself says `venue_time` is `nil`
"where the venue publishes none," and a book that arrived without a date is still a real,
current book — refusing it would throw away a genuine bid and ask over a field that is
allowed to be absent. `observed_at` is always this package's own clock at request time.

## The catalogue is what your credential sees

`get_symbols/1` walks the paginated `trading_pairs` endpoint. The prior adapter measured 86
symbols, all USD-quoted, on 2026-08-05 — **as seen by that credential**. Listings can differ
by account tier, so treat the count as a property of your key rather than of the venue.

The walk stops if the venue ever points at a page it already served
(`{:error, {:pagination_loop, path}}`), rather than looping forever against a live API.
A `next` is followed only if it continues the same endpoint on this venue's host: a `next`
naming another endpoint path is `{:error, {:unexpected_next_path, path}}`, as is one
outside `/api/`, and another host is `{:error, {:foreign_next_url, host}}`. The same walk
(bounded at 50 pages) serves accounts, holdings and orders, and the catalogue past the bound is
`{:error, :too_many_trading_pair_pages}`.

**A pair the venue marks `is_api_tradable: false` is excluded from `get_symbols/1`
entirely** — `V2TradingPair`'s own field for whether this package's other v2 endpoints
(`best_bid_ask`, `estimated_price`, `orders`) will accept the symbol at all. `status`
alone does not say that: a pair can read `"tradable"` and still answer 400 on every quote
or order call for it. `list_instruments/1` does not exclude the row — it marks `status:
:unknown` instead, because `Core.Instrument` has no finer state than
`:tradable`/`:delisted`/`:unknown` for "listed but API-refused". A pair where the venue
never sends `is_api_tradable` at all is unaffected by either of these — that field being
absent is unmeasured, not a negative.

## v2 needs the account number that v1 did not

`get_accounts/2` is the prerequisite for everything else. **`opts[:account_number]` is a
required query parameter** on `get_balances/2`, `get_orders/2`, `get_order/3` and
`place_order/3` — v1 took none and answered for the credential's own account, so a call
without one is a v1 habit v2 will not honour. Each refuses locally with
`{:error, {:missing_option, :account_number}}` rather than sending it (for `get_order/3` and
`place_order/3` the order id and the order request are checked first).

`cancel_order/3` is the exception: it takes no account number, and it is a **POST**, not a
DELETE. `get_accounts/2` walks every page of `V2AccountsResponse` when the venue offers a
`next` (bounded at 50 pages, `{:error, :too_many_account_pages}` past it): a credential with
more than one account gets all of them, and `get_balances/2` finds its account across pages.

## Holdings and orders page to the end, bounded — they used to read one page silently

`get_balances/2` and `get_orders/2` both used to read only the first page of their own
paginated endpoint (`V2HoldingsResponse`, `V2OrdersResponse`) and return it as `{:ok, _}` —
a holding or an order past page one was simply not in the reply, indistinguishable from not
existing. Both now walk every page to the end and fail closed
(`{:error, :too_many_holdings_pages}` / `{:error, :history_truncated}`) rather than hand
back a truncated list as complete.

`get_orders/2` has one more case: pass `opts[:limit]` to fetch exactly **one** page instead
of walking. This venue's `GET orders` endpoint takes no `limit` query parameter at all, so
`opts[:limit]` is not sent to the venue and does not cap how many rows come back — it only
decides whether this package walks `next` or stops after the page it already has. Pair it
with `opts[:cursor]` to choose which page. It must be a positive integer (or absent or `nil`):
anything else is `{:error, {:invalid_field, :limit}}` before anything is signed, from the
real path and the Fake alike (it used to mean "one page" whatever it held).

**A read refuses what it cannot read rather than returning a plausible value.** An order id
listed twice in one history is `{:error, {:duplicate_order_id, id}}`; a holding listed twice
is `{:error, {:duplicate_asset, code}}` and an account number listed twice
`{:error, {:duplicate_account, number}}` (a consumer summing them would count one twice). A
pair listed twice with different meanings in `list_instruments/1` is
`{:error, {:duplicate_symbol, symbol}}` (an identical repeat is one entry, and
`get_symbols/1` lists each pair once). A present order amount that is unreadable, or on the
wrong side of zero (size and prices not positive; filled size, average price or fee
negative), is `{:error, {:invalid_decimal, field, value}}` on `get_order/3`, `get_orders/2`
and `cancel_order/3` (the answer to `place_order/3` describes an order that exists and is
not refused for it). A present `side`, `type`, `state` or `time_in_force` outside the venue's
enums is `{:error, {:unknown_enum, field, value}}` on those same reads (the value cut at 64
bytes), where `place_order/3` leaves that one field `nil`. A top of book whose bid is above its
ask is `{:error, {:crossed_book, bid, ask}}`; the poll drops that symbol for the cycle.
`quantization/2` with neither increment readable is `{:error, {:missing_required_field,
:increments}}`, never rounding rules that state none.

The filters it sends are `:created_at_start`, `:created_at_end`, `:updated_at_start`,
`:updated_at_end`, `:symbol`, `:side`, `:type` and `:state`. `:state` takes the venue's own
word or a `Core` status atom: `:cancelled` is sent as `"canceled"` and `:rejected` as
`"failed"`. A filter outside the venue's enums (`side` `buy`/`sell`; `type` `market`,
`limit`, `stop_loss`, `stop_limit`; `state` `open`, `canceled`, `filled`, `failed`,
`pending`), a `symbol` that is not a non-empty string, or a date that is neither a
`%DateTime{}` nor a string that parses as an ISO 8601 date-time with an offset is refused as
`{:error, {:invalid_field, key}}` before anything is signed. `DpExchange.Robinhood.Fake`
narrows by `:symbol`, `:side`, `:type`, `:state` and the four date filters (bounds
inclusive; the vendor does not say, so that is unmeasured). It holds one page of the orders
this process placed, so `:limit` changes nothing there, and a `:cursor` is refused as
`{:error, {:invalid_field, :cursor}}` because the Fake never issued one.

`get_order/3` calls `GET /api/v2/crypto/trading/orders/{id}/`, a path the vendor's OpenAPI
`paths` does not list. It appears only in the spec's sample client, and it has not been
measured.

## Two prices, and the one that accounts for size

- `get_top_of_book/2` — the top of the book, as the venue publishes it
- `get_estimated_price/5` (`symbol, side, quantity, credentials, opts`) — what a **given
  quantity** would execute at now. `DpExchange.Robinhood.Fake` has it too and refuses what
  the real path refuses (symbol `{:invalid_symbol, v}`, side `{:unsupported_side, v}`, quantity
  `{:invalid_field, :quantity}`, then credentials; `quantity` may be a list of
  at most 10, the vendor's stated maximum, beyond which `{:error, {:too_many_quantities, 10}}`
  is answered locally and nothing is sent), answering only the
  fields it can honestly state (no fees). A negative number in the venue's reply is refused
  as `{:invalid_decimal, key, value}`, and so is a result row that is not an object
  (`{:error, :unexpected_response_shape}`)

There is no third. `get_price/2` is `:unsupported` — see above.

**`estimated_price` moved from `marketdata` to `trading` between v1 and v2.** A package
pointed at the old path gets a 404 that reads like an outage.

Several quantities go in one request — `["0.1", "1", "10"]` — which is how you see the slope
rather than three points taken at three times.

## Placing: the config key is named after the order's type

`market` takes `market_order_config`, `limit` takes `limit_order_config`, and so on. This
package builds that key from the type rather than taking it from you: **a config under the
wrong key is silently ignored and the order is placed with none.**

**Everything is refused before anything is signed, in this order** (the Fake gives the same
answer in the same order): a request that is not a map `{:invalid_order_request, request}`;
a key the builder does not read (`post_only`, `reduce_only`, ...)
`{:unsupported_order_field, key}`; `{:invalid_symbol, v}` (an absent symbol is `{:invalid_symbol, nil}`); `{:invalid_side, v}`
(`:buy` / `:sell`, or the strings in any case); `{:missing_field, key}` for `:side`,
`:order_type` (never defaulted or inferred), `:time_in_force`, `:quantity`, `:price` or
`:stop_price`; an order type outside `:market`, `:limit`, `:stop` (the contract's atom; the
venue's own `:stop_loss` is accepted too) and `:stop_limit`
`{:unsupported_order_type, type}`; `{:invalid_quantity, v}`, `{:invalid_price, v}`,
`{:invalid_stop_price, v}` (zero, negative, `NaN`, unparseable, **or a float** — a float has
already lost digits; pass a `Decimal`, an integer or a numeric string);
`{:unexpected_field, key}` for a `price` on a market or stop order or a `stop_price` on a
market or limit order (a field the type does not use is never silently dropped);
`{:invalid_client_order_id, v}`; then `{:missing_option, :account_number}`; then missing or
malformed credentials.

**`time_in_force` is required on `limit`, `stop` and `stop_limit` orders** — the venue's
default for an absent one is not this package's to assume — and this package supports all
four values the vendor's own schema documents — `:gtc`, `:day` (the venue's own `gfd`,
"good for day"), `:gfw` and `:gfm` ("good for week" and "good for month") — pass any of
them as the `:time_in_force` key of `place_order/3`'s request map. Anything else
(`:ioc`, `:fok`, `:gtd`, a string, ...) is refused locally as
`{:error, {:unsupported_order_combination, type, tif}}` rather than silently dropped, which
would have placed your order under an instruction the venue never received.
`market_order_config` carries no `time_in_force` in the venue's own schema, so **a market
order takes none: a supplied one is `{:error, {:unexpected_field, :time_in_force}}`**, not
dropped. `gfw` and `gfm` decoded to `nil` for one release — not invented
locally and not mapped to a nearest-match value, because Core's `time_in_force` vocabulary
had no atom for either yet. `dp_exchange_core` 0.1.45 added both, so that gap is closed and
`capabilities().supported_time_in_force` now lists all four you can actually place.

**Reading it back is not symmetric with placing it.** Only a `stop_loss` or `stop_limit`
order echoes `time_in_force` when you `get_order/3` or `cancel_order/3` it — confirmed
against the vendor's own OpenAPI document, 2026-09-06: `OrderResponse.limit_order_config`
has no `time_in_force` property at all, unlike the request-side config that placed it. A
`limit` order's `time_in_force` therefore decodes `nil` on every read, always — the venue's
own asymmetry, not a gap here. If you need to know what you set, that is the value you
passed to `place_order/3`, not something you can re-derive from reading the order.

**A `client_order_id` you supply must be a UUID** (canonical 8-4-4-4-12 hex, either case;
the vendor's `format: uuid`). Anything else, including a non-string, is
`{:error, {:invalid_client_order_id, value}}` before anything is signed; `nil` and `""`
still mean "none supplied". The Fake refuses the same.

**A placement that may have reached the venue and was not confirmed is never a bare
error.** A 5xx or transport failure after the retries, a 2xx whose body is not JSON, is not
an order object or has no `id`, or a refusal on an attempt after the first (the earlier
attempt may have been accepted) is
`{:error, {:order_unconfirmed, client_order_id, reason}}`, carrying the `client_order_id`
that was actually sent — the one this package generated included. Look the order up with
`get_order/3`, or re-send with that SAME id; a new id may place a second order. Errors
decided before anything is sent (validation, missing credentials, our own rate limiter
refusing the first send) stay plain `{:error, reason}`, and so does the venue's 429 to the
ONLY attempt: the order cannot exist. **Every other 4xx the venue answers to the only attempt
is `{:refused, _}`** (405, 406, 415, 422, 409 and the rest: it received the request and did
not act; `cancel_order/3` follows the same rule). A 429 or a 4xx answering a retry is
`:order_unconfirmed` (the reason is `{:refused_after_retry, refusal}` for a 4xx), because the
earlier attempt may have been accepted. A 2xx that carries an `id` is a
confirmed order even if a field holds a value this package does not know (an unknown `side`
or `state`): you get `{:ok, %Order{}}` with that field `nil`; a *read* of such an order
(`get_order/3`) still refuses it.

**A cancel is sent once.** `cancel_order/3` is never retried internally and reads neither
`:retry_attempts` nor `:retry_delay`: passing either (any value) answers
`{:error, {:unknown_option, key}}` before anything is sent, on the Fake too. If it fails
ambiguously, `get_order/3` says what happened. An id
that is not a non-blank string is `{:error, {:invalid_order_id, id}}` on `get_order/3` and
`cancel_order/3`, decided before the account option and credentials. A 404 on an order call
is `{:refused, :not_found}`.

**`client_order_id` is an idempotency key.** It is generated when you do not supply one, and
re-sending the same one returns the original order instead of placing a second. If a request's
response never reached you, retry with the *same* id — the request map's `:client_order_id`
(read first) or `opts[:client_order_id]` is there for exactly that. `nil` and `""` count as
absent and a fresh id is generated; any other value that is not a UUID is
`{:error, {:invalid_client_order_id, value}}`.

## Cancelling returns the venue's real state, not an assumed one

`cancel_order/3`'s response is v2's own `V2CryptoOrder` — the same schema `get_order/3`
reads — decoded the same way. `status` is whatever the venue actually reports at that
moment: `:open` if the cancel is still in flight, `:cancelled` once it lands, or a fill's
status if one won the race against your cancel. An earlier version of this function
discarded that body and always returned `:open`, which was correct for v1's cancel endpoint
(a bare acknowledgement string, no order data) and wrong for the v2 endpoint this package
actually calls — confirmed against the vendor's own OpenAPI document, 2026-09-06. You still
do not have to poll separately to find out whether a cancel took; the response already
says.

## Fees ride on the order you placed, not a schedule

**This package calls v2 specifically to get `fee_charged`** — `to_order/1` decodes it as
`Order.fee` on every read. The venue does not state a currency for that figure, so
`fee_currency` stays `nil` rather than assuming it matches the pair's quote asset; that
assumption is a convention, not the venue's word. `estimated_fee_remaining` — a second real
field on the same response — has no slot on `Types.Order` and is not decoded, because there
is nowhere honest to put it.

## Holdings: total, tradable, and no hold figure

`get_balances/2` keeps `balance` and `available_balance` apart — the difference is a balance
sitting in an open order. **`hold` is `nil` because the venue publishes no such figure**, and
subtracting would state a number it never did.

**A holdings row the venue did not attribute to an asset refuses the whole reply** with
`{:error, :unexpected_response_shape}`. An amount you cannot name an asset for cannot be
sized, booked or reconciled against, and dropping the row silently would read as "you hold
none of that asset" — a different and more dangerous claim than "this response could not be
read".

`balance` itself may be `nil`, and that is not the same thing: it means the venue did name
the asset and did not state a quantity for it (null, empty or absent). Read that as unknown,
never as zero. A quantity the venue **did** send and that cannot be read, or is negative, is
not unknown: the whole reply is `{:error, {:invalid_decimal, :balance | :available_balance,
value}}`.

## Holdings include the account's cash (dp-exchange-core issue #35)

Holdings are crypto assets only, so a funded account's cash used to never appear in
`get_balances/2` — a host summing cash across venues read zero on an account that actually
held money. `get_balances/2` now appends one more `Balance` for the account's cash, read
from `GET /api/v2/crypto/trading/accounts/`: `buying_power` as `available_balance`,
`buying_power_currency` as `currency`. **`balance` is `nil` on this row** — the venue states
no total cash figure, only what is available, so `balance` is never a copy of
`available_balance` dressed up as a total. When `opts[:asset_codes]` is given, the cash row
appears only if that list names the account's currency. Each entry must be a non-blank
string, otherwise `{:error, {:invalid_field, :asset_codes}}` before anything is signed.
Codes are matched case-insensitively. **One code is sent to the venue** as its `asset_code`
filter; **two or more are not**, because the vendor documents `asset_code` as a single value and
never says it repeats (unlike `symbol`). The whole account is read and narrowed in this package,
and the same narrowing is applied to a single code, so a venue that ignores its filter cannot
widen the answer.

This second read can refuse on its own: `{:refused, {:account_not_found, account}}` (it was
`{:error, ...}`, which reads as retryable; the credential does not hold that account) when no
row from the accounts call matches `opts[:account_number]`, `{:error,
:unexpected_response_shape}` when the matched row has no readable currency, or `{:error,
{:missing_required_field, :buying_power}}` when it has no readable amount. A holdings list
that decoded cleanly still refuses the whole call rather than answer without the cash row —
the zero-dollar answer this exists to stop.

## Two error shapes that mean "do not act on this answer"

Both are returned by calls that previously answered `{:ok, _}` carrying a value you could not
act on. A consumer matching only `{:ok, _}` needs no change; one that enumerates error
reasons should know them.

`{:error, {:undecodable_response, :robinhood}}` — the venue answered `2xx` with a body this
package could not decode. The realistic cause is not malformed JSON from Robinhood; it is a
`200` that never reached Robinhood, such as a captive portal or a CDN maintenance page
answering `200 text/html`. **Worth retrying**: nothing about the request was wrong.

`{:error, :unexpected_response_shape}` — the body decoded and is not the thing this endpoint
returns. From `get_order/3`, `cancel_order/3` and `get_orders/2` that means
it was not an order object (an object that is an order but carries no `id` is
`{:error, {:missing_required_field, :id}}` instead); from `get_balances/2`, that a holdings
row named no asset. **Not retryable on its own** — the same request produces the same
shape.

On `place_order/3` neither of these (nor `{:undecodable_response, _}`) is ever returned
bare: the request was sent, so the answer is
`{:error, {:order_unconfirmed, client_order_id, reason}}` with `reason` being the shape
above, and they are both more honest than the empty `%Order{}` this package used to return
as success. Re-read with `get_order/3` (or `get_orders/2`) before re-sending, and pass that
same `client_order_id` if you do — the venue treats it as an idempotency key.

## A slow subscriber gets dropped, and told — it does not get an unbounded mailbox

If your process falls far enough behind that its mailbox reaches **10,000 queued
messages**, this package stops sending it events and emits a `Core.Notice`:

```elixir
%Core.Notice{
  kind: :degraded,
  severity: :warning,
  message: "subscriber #PID<0.123.0> is 10000 messages behind, past the 10000 bound — ...",
  details: %{subscriber: "#PID<0.123.0>", queue_len: 10_000, bound: 10_000, dropping: :newest}
}
```

and a second one, `severity: :info`, once you have drained below **half** the bound (5,000 by
default). Not merely back under it: a mailbox hovering at the bound used to flip on every
message, with a notice each time, into the same full mailbox. Now one stall costs one pair.
**Those two notices bracket exactly
the window you have to reconcile** from the pull endpoints — that is what the pair is for,
and why the recovery notice exists at all rather than just the alarm.

Three things are worth knowing about the shape of this:

- **The newest is dropped, not the oldest.** A sender cannot remove a message from your
  mailbox; only you can. So what happens is that nothing further is added while you are over
  the bound. It is also the better trade: a quote that arrives while you are ten thousand
  messages behind is stale by the time you would read it, and the frames it would have
  displaced are no fresher.
- **Only you are affected.** Another subscriber keeping up keeps receiving everything. One
  slow consumer is never allowed to become an outage for the others.
- **`coverage/1` does not change.** It reports what the *venue* delivered to this package,
  not what this package forwarded to you. A symbol whose frames are being dropped for your
  backlog is still arriving, and reporting it as `:not_covered` would point you at the venue
  when the backlog is yours.

Notices themselves are never dropped, whatever your queue looks like — the notice telling
you that you are being dropped must not be the first casualty of it.

Raise or lower the bound at start:

```elixir
{DpExchange.Robinhood, max_queue_len: 50_000}
```

Any positive integer. It must be an integer — a string or a float raises at `init/1` rather
than quietly falling back to the default, because a back-pressure setting you believe you
configured and did not is worse than not having configured one.

**If you are seeing these notices, the fix is on your side.** Consume in a process that does
nothing else, or hand the payload straight to a queue or an ETS table and do the work
elsewhere. The bound is not a tuning knob for throughput; it is the line past which this
package stops writing into memory you are not reading.

## Telemetry: attach one handler, see every venue

This package emits the `:telemetry` events `DpExchange.Core.Telemetry` documents. Attach to
the names, not to anything venue-specific:

```elixir
:telemetry.attach_many(
  "dp-exchange-metrics",
  DpExchange.Core.Telemetry.event_prefixes(),
  &MyApp.Metrics.handle/4,
  nil
)
```

`:provider` in the metadata is how you tell venues apart — one handler covers the whole
family.

**These names were documented long before anything emitted them.** If you attached a handler
before this version and saw nothing, that was not your venue being quiet:
`:telemetry.attach/4` against a name nobody emits succeeds, so an empty panel was
indistinguishable from no traffic. Every one of them fires now.

Five things to know before you build on them:

- **Telemetry is the metrics channel; `Core.Notice` is the action channel.** Telemetry is
  aggregate and lossy by design. Alarm on notices. Graph telemetry. Alarming on a gauge
  documented as droppable is how a missed sample becomes a page at 3am.
- **`:stop` fires for every outcome**, success or error. That is deliberate: a latency panel
  built on successes alone shows a venue getting *faster* exactly as it starts failing,
  because the slow calls are the ones dropping out of the sample. `:exception` is a separate
  event from a `:stop` carrying an error — an error result is the venue answering badly, an
  exception is this package failing to ask.
- **`retry_after_ms` is always milliseconds**, including where the venue's own header is in
  seconds. A panel summing a mixture of the two is wrong by a factor of a thousand without
  ever looking wrong.
- **The poll has a link too.** `[:dp_exchange, :link, :up]` (metadata `provider: :robinhood`)
  fires on the poll's first successful delivery, and again on recovery from a
  delivering-nothing outage; `[:dp_exchange, :link, :down]` fires on entering one. A feed
  that is healthy from boot therefore shows an `:up`, not silence.
- **`:bytes` may be absent from `[:dp_exchange, :link, :event]`**, and absent is not zero. A
  polling route has no frame, so there is no byte count that means what a socket's does.
  Treat a missing `:bytes` as "this route cannot measure that", not as no data.

`:endpoint` metadata is the request URL with the query string removed. Deliberate: telemetry
metadata reaches logs, aggregators and third-party exporters, and the query string is the one
part of a URL that can carry a token.

## Every capability claim is dated, and says what it was measured against

`capabilities/0` carries two provenance fields, and they are there to be read:

```elixir
caps = DpExchange.Robinhood.capabilities()
caps.measured_at       # ~D[...] — when someone last checked
caps.measured_against  # what they checked: which documents, which endpoints, live or not
```

**`measured_against` is the one that matters when you are deciding how much to trust a
claim.** It distinguishes a figure measured live against the venue's API from one read off a
documentation page and never probed — and this package's own declarations contain both
kinds, stated per claim rather than as one blanket sentence.

This venue's rate ceilings (`public_ceiling` and `authenticated_ceiling`, both 100 requests a
minute with bursts of 300, counted per account) are of the second kind: the figures in the
vendor's own OpenAPI document, which says they fluctuate and differ per endpoint. They have
never been probed against the live venue.

A claim read from documentation is not worse, it is *different*: it is what the vendor says
it will do, which is the right basis for a ceiling you must not exceed and a weaker basis for
behaviour you are about to depend on. If your decision turns on the difference, read the
field rather than assuming.

`measured_at` going stale is a real thing that happens — venues change, and a declaration is
a claim about a real venue. `script/check_doc_sources.sh` reports the age on its weekly run
so it does not quietly get old, but nothing forces it: if you are relying on a capability in
a way you cannot afford to have wrong, check the date and, where it matters, verify against
the venue yourself.

## What this venue does not have

Money movement, in all of it. **This is a crypto brokerage with no funding API**: the
vendor's crypto trading documentation publishes nine endpoints and none of them is a payment
method, a transfer, an allowlist, a network list or a transaction ledger. Money reaches the
account through the Robinhood application, which needs a person.

**The no-streaming claim at the top of this file was checked, not inherited.** Five
documentation pages read in full: zero occurrences of `websocket`, `wss://` or `streaming`,
including in the JavaScript bundles that carry the endpoint lists. See
`docs/reference/robinhood/negative-claims.md`, which records every negative this package
makes with the source and date behind it, and the method, so any of them can be re-run.

## Streaming calls answer; they never exit your process

`subscribe/2`, `unsubscribe/2`, `update_symbols/2` and `subscribe_notices/1` return
`{:error, :feed_not_started}` when this venue's feed is not running (`unsubscribe/2`
answers `:ok`: there is nothing to stop). They return `{:error, :feed_timeout}` when the
feed is too busy to answer within its call budget, and `{:error, {:feed_exited, reason}}`
when it dies while answering. `coverage/1` answers an empty map in all three cases,
`coverage_by_kind/1` answers `%{}` (as on every other venue; a running feed answers
`%{top_of_book: coverage}`) and `wanted/1` an empty list; each means "not observed". A feed
given as a pid on another node is treated as not running by those three reads. Treat
`:feed_timeout` as transient and retry. Treat the other two as a feed your supervision tree
has to bring back.

**`:feed_timeout` means "no answer", not "not applied".** The request is already in the
feed's mailbox and is applied when the feed reaches it. Retrying is safe because each of
these calls is idempotent (add, remove or replace a symbol set, applied in order), and
`wanted/1` reads back what the feed holds once it answers.

**A symbol list must be a proper list of non-blank strings.** Anything else (a bare string,
`nil`, an atom, a blank or whitespace-only element) answers `{:error, {:invalid_symbols,
value}}` from `subscribe/2`, `unsubscribe/2` and `update_symbols/2`, and the feed is left as
it was. The arguments are checked, symbols first and then the subscriber, before the feed is
looked at, so a bad argument answers the same whether or not a feed is running. It used to
be `{:invalid_field, :symbols}`, and before that it was passed through, where a non-list
crashed the feed and lost every later subscription. Symbols are upper-cased on the way in.

**A single-symbol read refuses a bad symbol locally.** `get_top_of_book/2`, `quantization/2`
and the estimate call answer `{:error, {:invalid_symbol, value}}` for a symbol that is not a
string or is blank after trimming, before anything is signed (it used to be
`{:invalid_field, :symbol}`). The bulk read answers `{:invalid_symbols, value}` for a
non-list.

**A symbol the venue does not list is `{:refused, :not_listed}`** on the single-symbol reads
(`get_top_of_book/2`, `quantization/2`, the estimate call), the Fake too. The venue says so as
a 400 `"Invalid symbol: X"`, which used to come through as
`{:refused, {:venue_error, 400, "Invalid symbol: X"}}`; every other refusal keeps its
`{:venue_error, status, detail}`. An empty `results` array is still not a statement and is
`{:error, :empty_result}`; a `results` array with rows but none for the symbol asked about is
`{:error, :symbol_not_in_response}`. The poll's per-symbol `{:refused, symbol, reason}` message
to `subscriber:` still carries the venue's own reason.

## What every HTTP answer becomes

Every request is signed afresh on every attempt (Ed25519 over `api_key <> timestamp <> path
<> method <> body`, `path` including the query string and `timestamp` whole seconds; the venue
honours a timestamp for 30 seconds, so a retry is never sent with the first attempt's
signature). Nothing below crashes in your process and nothing is answered as `{:ok, _}` with a
default put in the venue's place.

- **A read** (market data, catalogue, accounts, holdings, orders, estimates) is retried up to
  `retry_attempts:` (default 3) on a transport failure (`:timeout`, `:closed`,
  `:econnrefused`) or a 5xx, then answers `{:error, {:exchange_error, :robinhood, message}}`.
  `400`, `401`, `403` and `404` are `{:refused, {:venue_error, status}}`, or
  `{:venue_error, status, detail}` when the body carries a text reason, in the vendor's
  `ErrorResponse` shape (`errors[].detail`) or as a top-level `detail`, cut at 1,024
  characters. **Any other 4xx on a read** (405, 406, 409, 410, 415, 422, ...) is the plain
  `{:error, {:exchange_error, :robinhood, "HTTP 422: ..."}}`, never a refusal: the rule below
  that every other 4xx is `{:refused, _}` is the rule for writes.
- **A 429** is `{:error, {:exchange_error, :robinhood, "Rate limited by the venue — retry
  after Ns"}}` on every call: never a refusal and never retried (the venue's `Retry-After`
  holds the limiter's bucket).
- **A 2xx whose body is not the endpoint's shape**: `null`, a number, a boolean or a list is
  `{:error, :unexpected_response_shape}`; a bare JSON string, an empty body or text that is not
  JSON is `{:error, {:undecodable_response, :robinhood}}`.
- **A response past `max_body_bytes:`** (default 16 MiB) is `{:error, {:exchange_error,
  :robinhood, {:response_too_large, limit}}}` and is not retried.
- **A placement** that may have been sent (a transport failure or 5xx after the retries, a 2xx
  that is not an order) is `{:error, {:order_unconfirmed, client_order_id, reason}}`, and every
  retry carries the same `client_order_id`. **A cancel** is sent once and answers a transport
  failure or a 5xx as the plain error above: `get_order/3` says whether it took.