Current section

Files

Jump to
Raw

CHANGELOG.md

# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Status: EXPERIMENTAL
Stated here rather than only per-release, because a reader arriving at a specific version
needs it as much as one reading the top.
This package has not run in production. While it is `0.x` the API may change without a
major version. Coverage is uneven by design: fakes and live public endpoints are well
covered, order placement and authenticated flows are not.
**Whenever an endpoint moves to `:proven`, the entry that does it states the evidence**
what was run against the live venue, and when. "Marked proven" with no evidence is not an
acceptable changelog line.
## [Unreleased]
### Changed
- **BREAKING: `Socket` no longer maintains a `level2` order book. An `update` frame now
delivers `dp_exchange_core`'s new `Types.OrderBookDelta`, not a full `Types.OrderBook`.**
A consumer that used to receive a rebuilt `Types.OrderBook` on every `l2_data` frame —
including a single-row `update` — now receives a `Types.OrderBook` only on `snapshot`
(once per subscribe/resubscribe) and a `Types.OrderBookDelta` on every `update`: the
venue's own changed rows, in the venue's own order, both sides interleaved exactly as
the frame carried them, in a flat `levels :: [{side, price, quantity}]` list rather
than split into `bid_levels`/`ask_levels`. **A consumer wanting a maintained book now
builds and holds it itself.** This is not presented as a performance improvement — it
is the removal of state this package was never supposed to hold. See
`dp_exchange_core`'s `docs/design/closed/2026-09-06_stop-maintaining-books-in-packages.md`.
**Why:** holding the book cost 65–110 ms per delta at the book size DpCryptoManagement
measured live for `BTC-USD` (~22,800 bid / ~21,100 ask levels, issue #22), later
optimised to ~6.6 ms — but that work ran on the same single-threaded process
responsible for `WebSockex.send_frame/2`, so a socket busy rebuilding a book it was
never asked to keep could not service its own sends, which is the `:send_timeout`
behind issue #22. Maintaining state this package was not supposed to hold is what broke
the connections it was supposed to keep; this change removes the work rather than
making it faster a second time.
**A `quantity` of zero still means the level ceased to exist, not a price of zero**
carried through completely unresolved now, since resolving it would itself be
state-keeping.
**Reconnect reconciliation is now the consumer's job, not this package's.** A dropped
and resumed connection does not promise the deltas after it are contiguous with the
deltas before it. `subscribe_notices/1`'s existing `:link_down`/`:link_up` pair
brackets where a gap may fall; neither that notice nor anything else reconstructs a
missing delta. The correct response to `:link_up` is to re-pull `get_order_book/2`
(unaffected by this change) or accept the venue's own fresh `snapshot` on resubscribe —
not to keep applying deltas across a gap. Coinbase's `level2` channel publishes no book
sequence number, so `:sequence` on both types is always `nil` here.
`Socket`'s `books` state, `apply_book_event/3`, `apply_book_row/2`, `update_level/4`,
`remove_level/3`, `deliver_book/3` and `price_key/1` are all gone, along with
`bench/order_book_resort.exs`, which benchmarked work that no longer exists.
`Feed.payload_kind/1` now maps `%Types.OrderBookDelta{}` to `:order_book`, the same
`data_kind()` a full `%Types.OrderBook{}` gets — `coverage_by_kind/1` answers "is book
data arriving", not "in what shape", and the struct type itself already tells a caller
which shape it is holding.
**Also dropped, deliberately, as part of the same change:** the exact-scaled-integer
precision check `price_key/1` used to enforce (refusing a price with more than 8
decimal digits) existed only to support the `:gb_trees` ordering key that mechanism
needed — it was never an independent business rule. Sorting a snapshot's own rows via
`Decimal.compare/2` needs no such key and has no rounding step to guard against, so a
price at any precision the venue sends now passes through unchanged, the same as every
other decimal field this module decodes. Likewise, two numerically-equal,
differently-scaled prices in one snapshot (`"1.5"` and `"1.50"`) are no longer folded
into one last-write-wins level — that folding was an accidental side effect of the old
map-keyed implementation's own key, never a documented venue behaviour (unlike the
8-decimal `quote_increment` finding, this had no live measurement behind it), and
silently choosing a winner between two rows is itself the kind of substitution this
family refuses. Both rows now pass through as the venue sent them.
### Added
- **Five of Coinbase Prime's nine staking endpoints are now reachable from the facade
`dp_exchange_core`'s new "internal wiring" conformance assertion (assertion 16)
caught them as built and never called from this package's own `lib/`.**
`DpExchange.Coinbase.query_transaction_validators/3`, `staking_status/4`,
`unstake_status/4`, `claim_rewards/4` and `preview_unstake_wallet/6` now delegate
straight to the matching `DpExchange.Coinbase.Prime` function, the same pattern
`stake/3` and `unstake/3` already used for the other four. None of these map onto a
`dp_exchange_core.Venue` callback — `staking_status` answers a narrower question than
`get_staking_balances/1` would, `claim_rewards` is a write where that callback wants a
read, and `query_transaction_validators`/`preview_unstake_wallet` have no generic
analogue at all — so they are Coinbase-specific facade functions, the same shape as
the existing futures and portfolio extras (`list_futures_positions/1`,
`get_portfolio_breakdown/3`, and friends). `venue_does_not_serve/0`'s own
documentation already claimed these were "reachable as `Prime.X`"; that claim is now
true through the facade as well, not only by reaching past it into an internal module.
- **`coverage_by_kind/1` implemented — Coinbase is the motivating case for
`dp_exchange_core` 0.1.48's new optional callback.** `coverage/1` answers "is
anything arriving for this symbol" by counting any payload at all, so a `level2`
book update counted identically to a `ticker` quote. That blindness is not
hypothetical: `level2` delivered upward of 11,000 frames across 406 subscribed
symbols while `ticker` stayed dark on all but a handful, and `coverage/1` still
answered `:stream` for all 406 — correct by its own definition, and exactly why
DpCryptoManagement's issues #20 and #22 stayed unpinned for days.
`Feed`'s `delivering` map now tracks `%{symbol => %{kind => timestamp}}` instead of a
bare timestamp, keyed off which `Core.Types` struct actually arrived
(`%Types.Quote{}``:quotes`, `%Types.OrderBook{}``:order_book`) — never off this
venue's own channel names, which stay internal. `coverage_by_kind/1` on
`DpExchange.Coinbase` and `DpExchange.Coinbase.Fake` both satisfy the union
invariant `dp_exchange_core`'s conformance suite now checks whenever a venue exports
this callback: the symbol keys across every kind exactly match `coverage/1`'s own
keys, and every kind reported is one `capabilities().streamable` declares. The fake
reports everything under `:quotes` only, honestly — `subscribe/2` never synthesises
an order book, and claiming `:order_book` coverage it cannot back would be the
"differently capable" divergence this fake's own moduledoc forbids.
Bumped `dp_exchange_core` from `~> 0.1.36` to `~> 0.1.48` to pick up the callback.
- **`Fake` wired to `Core.FakeInjection` — DpCryptoManagement's issue #14.** Every
function with a real success path (not an unconditional `Venue.not_supported()`) now
checks a queued or always-set outcome first: `get_price/2`, `get_top_of_book/2`,
`get_historical_prices/4`, `get_order_book/2`, `get_trades/2`, `quantization/1` and
`close_position/3` support per-symbol targeting; every other real function (bulk
reads, account/portfolio/conversion calls, order placement, cancel/get/list, previews
and edits) supports whole-call injection. `subscribe/2`, `unsubscribe/2` and
`update_symbols/2` are deliberately not wired — each takes a symbol list in one call,
which whole-call injection cannot express partial failure for; neither is `coverage/1`
or `subscribe_notices/1`, both local bookkeeping that always succeeds by construction.
**No credential-bypass mode here.** Unlike `DpExchange.Robinhood.Fake` (the reference
implementation), this fake has no central credential check to bypass — most functions
never inspect `credentials` at all, an existing gap this wiring does not change.
See `docs/design/2026-09-04_webull-sharding-and-fake-injection.md` §3.6/§3.7 in
`dp-exchange-core`.
- **`get_market_overview/1` and `list_instruments/1` are implemented —
DpCryptoManagement's issue #10.** Both sat behind `Venue.not_supported()`, one filed as
a genuine venue absence (`@venue_does_not_serve`) with no per-item comment explaining
why, against `get_symbols/1` already calling the exact bulk endpoint
(`/products`/`/market/products`) that carries all of it. Live-verified: the response
Coinbase actually returns names `price`, `price_percentage_change_24h`, `volume_24h`,
`high_24h`, `low_24h`, `status` and `product_type` per product, and `get_symbols/1` kept
only `product_id`. Both new functions read the same response `get_symbols/1` already
fetches — via a shared `fetch_products/1` — rather than a second request.
### Removed
- **`Feed.pairs_per_socket/0` and `SymbolFormat.mapping/0` deleted — both were public
getters over a module attribute with no caller anywhere in this package's own `lib/`,
the other shape
assertion 16 exists to catch.** `Feed`'s own moduledoc already says sharding details
"never reach the facade" — `@pairs_per_socket` is consulted directly by `shards/1`,
the function that actually shards, and stays; the accessor was consulted only by a
test asserting the same number `shards/1`'s own tests already prove through behaviour.
`SymbolFormat.mapping/0`'s doc claimed it existed "so the conformance suite can drive
`CanonicalPair` with it," but `dp_exchange_core`'s conformance suite calls only
`to_canonical_symbol/1` and `to_exchange_symbol/1` (the actual behaviour callbacks) —
it never called `mapping/0`, and neither did anything else outside this package's own
test suite. **Breaking**, for the two functions removed; neither was part of the
`Venue` behaviour or documented as consumer-facing in `usage-rules.md`.
### Fixed
- **`Socket` full-re-sorted BOTH sides of the maintained level2 book on EVERY `l2_data`
frame, including an `update` changing a single price level**
`dp_exchange_core`'s `docs/design/2026-09-06_order-book-resort-cost.md`. Measured at
the book size DpCryptoManagement reported live for `BTC-USD` (issue #22, ~22,800 bid
/ ~21,100 ask levels): one update frame cost 62–110 ms across repeated runs in this
repo (`bench/order_book_resort.exs`) — a ceiling of roughly 9–16 book updates/second,
maximum, on a socket a shard shares across up to 100 symbols. Stated as a hypothesis
in the design, not a conclusion here: this is very likely a major part of why
`ticker` starves whenever `level2` is delivering broadly in #22, since the
single-threaded socket could never idle long enough to service the `ticker`
subscribe inside `FrameSender`'s 5-second window — but that causal claim is only
confirmed by this fix changing behaviour on their node, not by anything measured
here.
Each side's `%{Decimal => Decimal}` map is now a `:gb_trees` tree keyed by the price
scaled to an exact integer (`10^8` — verified 2026-09-06 against Coinbase's own
public `GET /api/v3/brokerage/market/products`: the smallest published
`quote_increment` across all 931 products is `0.00000001`, 8 decimal places, and no
product's own `price` field carries more precision than that either), carrying the
original `Decimal` as the tree's value. Delivery is now an ordered traversal —
`Enum.sort_by/3` no longer runs anywhere in the per-frame path. Measured against the
same book size, repeated runs in this repo: the same one-update-frame cost fell to
0.9–2.9 ms (roughly 345–1075 updates/second, maximum), and the isolated bids-only
sort/traversal fell from 49–83 ms to 0.5–6.2 ms. Run
`mix run bench/order_book_resort.exs` to reproduce on any machine.
A price that cannot be represented exactly at that scale is refused and reported
through the same `:data_quality` notice path as any other unparseable row, rather
than rounded — no real Coinbase price has needed this path so far, but the family's
own rule against silently substituting a nearby value applies here too. One
behaviour is deliberately NOT identical to the map-keyed implementation: two
numerically-equal, differently-scaled price strings (`"1.5"` and `"1.50"`) used to
become two map entries — two "levels" at one price, because `%Decimal{}` structs
compare unequal by field even when `Decimal.equal?/2` says they are the same number
— and now collapse into one, last-write-wins. That was a latent defect in the
map-keyed version, found and fixed here rather than a behaviour changed as a side
effect; everything else observable — order, `Decimal` values, count, the
`Core.Types.OrderBook` struct itself — is unchanged, and is asserted so against a
reference reimplementation of the old approach in `socket_test.exs`.
- **The alias-map fetch added for issue #22 was throttled by the caller's OWN rate
limiter at boot and never retried, disabling attribution for the life of the process —
DpCryptoManagement's issue #26, a regression in a fix this package shipped.**
`Feed`'s default `alias_map_source` called `Rest.get_alias_map/1` without
`rate_limit_blocking: true`, so it went through fail-fast `check/3` instead of blocking
`acquire/3`. The fetch is scheduled off the first `subscribe/3`, which for any real
consumer **is** boot — the single most contended moment for their own limiter
(universe discovery, catalogue reads and market overviews all landing at once) — so it
was scheduled into exactly the window most likely to throttle it. One throttled call
there, on code that never retried, was permanent: `state.alias_map` stayed `%{}` for
the life of the process. Measured live: 406 pairs requested as `-USDC`, delivered as
`-USD`, overlap 5 — `coverage_by_kind/1` and the consumer's own tracker each reporting
a truthful, and wildly different, count, precisely the situation the issue #22 fix
existed to end.
**This is the third instance of one family-wide pattern**`dp_exchange_robinhood`'s
issue #16, this package's own issue #23 sweep, and now this: a background call with
nothing waiting on it, failing instead of waiting, while `Core.HttpClient`'s own error
message names the fix in its text ("callers that can wait should set
`rate_limit_blocking: true`"). The issue #23 sweep audited every REST call site in this
package and missed this one, because the alias-map fetch's own HTTP path was mistaken
for its sibling WebSocket resubscribe path, which genuinely has no rate-limited replay
to default. That recurrence is worth more than any one of the three individual fixes:
the same shape of gap keeps landing at the one call site nobody thought to re-check.
Three changes, all in `Feed`:
1. `rate_limit_blocking: true` is now set unconditionally on the fetch, by a new
`default_alias_map_source/2` that also forwards `:limiter`, `:plug`, `:timeout`,
`:retry_attempts`, `:retry_delay` and `:weight` from `start_link/1`'s own `opts`
the same allowlist shape `Rest`'s own request pipeline uses.
2. Blocking removes the self-throttle as a failure mode but not every failure: the
limiter's own bounded wait can still time out. `transient_alias_map_failure?/1`
classifies that one case as worth retrying — the identical request can reasonably
succeed once the bucket has drained further — and treats everything else (an
unrecognised response shape, a refused request, an unclassified reason) as
permanent, matching `transient_subscribe_failure?/1`'s own default-to-permanent
stance one section up. Retries are bounded (`@max_alias_map_retries`, backed off by
`@alias_map_retry_delay_ms`, both overridable for a test's benefit) rather than
looped forever.
3. **The degraded-attribution notice was unreceivable by construction, also part of
issue #26.** The fetch is scheduled from `subscribe/3`; a consumer calling
`subscribe_notices/1` afterward — the ordinary sequence — could register only after
the fetch had already failed and fanned out to zero subscribers. A notice
announcing a *persistent* degraded state that can only ever fire in the one window
before anyone could be listening is worse than no signal: it looks like a working
alarm that never rings. Fixed by replay, not by moving the emission earlier (this
file already learned that moving-the-window lesson once, with
`next_resubscribe_delay/1`'s per-tick storm): `alias_map_status` and the reason that
produced it now persist in state, and `subscribe_notices/1` replays the identical
notice to a newly-registered subscriber whenever it finds the state already
`:unavailable` — once per new registration, never on a timer, and never re-sent to a
subscriber who already has it.
New tests in `feed_test.exs` drive the real fetch pipeline end-to-end for the first
fix — a real, named `Core.DefaultRateLimiter` with its sole token already spent, behind
a fake `:plug` response, proving `rate_limit_blocking: true` actually reaches
`Core.HttpClient` rather than merely surviving being typed into an allowlist — plus the
transient-retries-and-succeeds, permanent-fails-once, bounded-exhaustion and
late-notice-subscriber cases, all against the default `alias_map_source` codepath or a
controlled stand-in, none against a guessed `Process.sleep/1` duration.
- **An empty `pricebooks` array from `/best_bid_ask` was read as the venue naming a
product not listed, and there is no evidence this venue has ever said that this way —
audited alongside DpCryptoManagement's issue #25 (`dp_exchange_robinhood`'s confirmed
instance of the same substitution).** `get_top_of_book/2`'s `{"pricebooks" => []}` clause
turned a 200 with an empty array into a permanent `{:refused, :not_listed}` — permanent
because `Core.PollingFeed` reports a refusal once and never retries it.
Probed live 2026-09-06 against the closely related, unauthenticated
`/market/product_book` (same pricebook data, one product per call instead of a batch): a
product this venue has never listed answers `404 {"error":"NOT_FOUND","error_details":
"valid product_id is required"}`; a product it delisted but still recognises
(`/market/products/{id}` still answers 200) answers a *different* `404
{"error":"NOT_FOUND","error_details":"no pricebook found"}`. Neither is a 200 with an
empty array, and no online product checked (923 listed, spanning the lowest-volume
pairs) ever returned one either. This venue's own convention for "no book" is a
distinguishable non-2xx statement. `/best_bid_ask` takes a *list* of `product_ids` and
answers one pricebook per product it can — an ordinary batch-API shape is to omit an
entry it cannot answer rather than fail the whole request, which collapses "never
listed" and "listed but delisted" (two states the sibling endpoint tells apart) into one
indistinguishable silence, and says nothing about a real, momentarily bookless product
either.
The empty-array clause now returns `{:error, :empty_result}` — retryable, the same shape
a 500 already produces. A genuine venue statement (a 404, this venue's own convention)
still reaches `{:refused, :not_listed}` through the existing `classify/1` path, which
this change does not touch. New tests in `rest_test.exs` and `order_book_test.exs` cover
both: an empty array is retried, and a genuine 404 is still refused.
- **A timed-out channel subscribe was logged and thrown away — no retry until the next
60s tick reproduced the identical failure, DpCryptoManagement's issue #22.**
`FrameSender`'s own moduledoc says the whole point of turning a `send_frame` exit into
`{:error, :send_timeout}` is that a slow socket becomes "a failed batch, which a caller
can report and retry" — the retry half of that design was never wired into `Feed`. A
`level2` subscribe triggers a full per-symbol book snapshot; the socket is
single-threaded and cannot service the next `send_frame` while decoding it, so firing
`ticker`'s subscribe `@channel_spacing_ms` later still landed inside that window on a
100-symbol shard and blew the hardcoded 5s send window. Dropped, forever, since nothing
re-attempted it before the next resubscribe cycle recreated the same busy socket.
Measured live across a real ~400-symbol consumer, five boots over roughly 5.5 hours —
the exact inversion this predicts:
| state | quotes (`ticker`) | order_book (`level2`) |
|---|---|---|
| broken (4 boots) | ~5 / 406 | ~406 / 406, 11,000+ frames |
| healthy (1 boot) | 400 / 406 | 6 / 406 |
When `level2` got through broadly, its opening snapshot burst starved `ticker`; when
the venue refused most `level2` subscriptions outright (its own per-session stream
limit — see the "sharded" section above), `ticker` had the socket to itself and got
everything. A lone `:send_timeout` on a `ticker` subscribe was also observed directly
in an earlier run.
`{:error, :send_timeout}` and `{:error, {:send_exit, reason}}` are now retried —
transient, since the identical request can reasonably succeed once a busy or briefly
gone socket catches up. `{:error, {:credentials_required, channel}}` is not: no amount
of waiting supplies a credential that was never given, and it fails loudly on the first
attempt instead of looping. The backoff reuses `@channel_spacing_ms` rather than a
second, independently guessed number for the same busy-socket wait, bounded to two
retries (three attempts total) — the whole chain resolves in at most 24s, well inside
even the 60s default resubscribe cycle, so it can never stack fresh frames against the
unconditional re-issue. Exhausting the retries, and the permanent-error path, both now
emit a `Core.Notice` of kind `:coverage_change` in addition to the existing log — a
channel that never subscribed is exactly the invisible half-dead feed this issue is
about, and a `Logger.warning` alone gave a consumer no facade-level way to see it. A
socket that dies between attempts is re-checked, not assumed alive, and simply stops
the chain rather than sending into a corpse.
Deliberately unchanged: `@channels` order (`level2` before `ticker`) and
`@channel_spacing_ms` itself. Subscribing the lighter channel first is a plausible
additional fix, but it is unmeasured and changing two things at once would make the
next measurement uninterpretable — raised separately with the consumer instead.
- **`:rate_limit_blocking` was unreachable on every REST call this package makes —
family-wide gap, DpCryptoManagement's issue #23.** `Core.HttpClient.check_rate_limits/1`
reads this option to choose `acquire/3` (wait for capacity) over fail-fast `check/3`,
and its own error message on a self-inflicted throttle tells a caller to set it — but no
caller could, on this venue: `Rest.request/5`, `Rest.json_request/5` and
`Prime.request_opts/1` all stripped it from their forwarded-options allowlist before it
ever reached `Core.HttpClient`. The same defect (`dp_exchange_webull`'s issue #23,
`dp_exchange_robinhood`'s issue #16) audited across the rest of the family; this venue
was one of four still carrying it.
All three allowlists now forward `:rate_limit_blocking`, proven with a recording rate
limiter that records which of `acquire/3` / `check/3` was actually called — not merely
that the keyword survives the allowlist. **Not defaulted anywhere in this package**,
unlike `dp_exchange_webull`'s `Feed` and `dp_exchange_robinhood`'s `Feed`: this venue's
own periodic resubscribe (`DpExchange.Coinbase.Feed`'s unconditional 60s re-issue) sends
WebSocket frames, not HTTP, so there is no rate-limited background replay here to justify
choosing a default on a caller's behalf. A caller that wants blocking opts in explicitly.
- **A resubscribe interval shorter than one re-issue cycle wedged the feed — including
the 60s DEFAULT, past twelve shards.** A cycle is not instantaneous: shards are
staggered `@shard_spacing_ms` apart and each shard's channels `@channel_spacing_ms`
apart, so the last frame goes out about `(shards - 1) * 5_000 + 8_000` ms after the
tick. If the timer re-fired before that, cycles overlapped, frames queued behind each
other, `WebSockex.send_frame/2` blew its window, and the `Feed` stopped answering calls
entirely — `:sys.get_state/1` timing out. A wedged feed is strictly worse than a late
resubscribe.
Found by DpCryptoManagement while running the diagnostic added in the previous release
(issue #22): they set `resubscribe_interval_ms: 5_000`, below the 8s channel spacing,
and lost the run to it. They reported it against themselves rather than against the
option, which is how it got looked at properly — because checking it showed **the same
failure was reachable with no option set at all.** The 60s default is shorter than the
cycle span from twelve shards (1,101 symbols at `@pairs_per_socket`) upward, so a large
enough consumer would have walked into it on defaults alone. The knob exposed a limit
the default already had.
The next delay is now derived from the shard count that actually exists at each tick,
never from the configured value alone, and an extension is **logged** rather than
applied silently — a diagnostic knob whose value is quietly ignored is its own trap.
Nothing changes for any interval that was already comfortable.
- **`get_top_of_book/2` could never work without credentials, and the facade said
otherwise — family-wide defect sweep, Coinbase B1.** Unlike every sibling market-data
reader in `Rest`, this one call is hardcoded to `/best_bid_ask` with no
`/market/best_bid_ask` branch. Re-verified live 2026-09-05: authenticated is `401`, and
the public path a caller would expect by analogy is `404` — there is no public form to
fall back to, so inventing one would have been exactly the "nearby substitute" this
family refuses. Fixed by checking for credentials up front and returning
`{:refused, :missing_credentials}` before sending anything, rather than surfacing the
venue's `401` as an opaque error. `DpExchange.Coinbase`'s moduledoc and
`capabilities/0`'s `credential_benefit` comment both claimed "the same market data is
served publicly" without qualification — true of every other endpoint, false of this
one — and both now name the exception. `usage-rules.md` carried the identical claim and
is corrected the same way, since it ships inside the Hex tarball and is what a
consuming agent reads.
- **`apply_book_row/2` silently dropped a `level2` row it could not parse — family-wide
defect sweep, Coinbase B2.** Every other decode-failure path in `Socket`
(`deliver_ticker/3`, `deliver_book/3`) reports a `:data_quality` notice through
`report_quality/2`; this one returned the maintained book unchanged with no signal,
against the module's own stated discipline ("a payload that did not parse is reported,
not swallowed and not fatal"). Concrete cost: `new_quantity: "0"` is how the venue
signals level *removal*, so an unparseable quantity silently ignored could leave a
stale price level in the maintained book indefinitely with nothing indicating why.
`apply_book_row/3` now threads `state` through and reports a `:data_quality` notice for
an unparseable `price_level`/`new_quantity` and for a row missing those keys entirely —
the connection is still never torn down over one bad row.
- **`Socket.start_link/1` inherited WebSockex's own connect/recv timeouts by accident —
family-wide defect sweep, Coinbase B4.** No `:socket_connect_timeout` or
`:socket_recv_timeout` was set, so WebSockex supplied its own defaults — measured in
the vendored dependency, `deps/websockex/lib/websockex/conn.ex:10-11`: `6_000` ms
connect, `5_000` ms recv. That matters specifically because `Feed`'s `open_shard/5`
synchronous branch calls `Socket.start_link/1` from **inside** a `handle_call/3`, and
`Feed`'s own `@call_timeout` is `@frame_window_ms * 3` = `15_000` ms — a named, shared
process, so every other consumer's `subscribe/2`, `unsubscribe/2`, `update_symbols/2`
and `coverage/1` call queues behind that one call. The inherited defaults alone
(`6_000 + 5_000 = 11_000` ms) would burn roughly three-quarters of that budget on the
TCP connect and the handshake recv **alone**, against an unreachable or black-holing
venue, before a single subscribe frame is sent. The margin was never chosen; it was
whatever the dependency happened to default to.
Fixed by setting both explicitly at `3_000` ms each (`6_000` ms total), chosen
deliberately against `Feed`'s `15_000` ms budget — leaving roughly `9_000` ms of the
same call for the socket to send at least one subscribe frame (capped at `Feed`'s own
`5_000` ms `@frame_window_ms`) plus ordinary `GenServer` overhead. No failure
semantics changed: `start_link/1` still returns `{:error, reason}` synchronously
exactly as before, so the synchronous-primary-shard design is unchanged bit for bit —
only the margin after a slow or absent venue does. A caller passing either key in
`opts` still overrides it. The merge is factored into a small `@doc false`
`connection_opts/1` so a regression test can pin both the defaults and the override
precedence without opening a real socket.
- **The venue rewrites an aliased product id on delivery, and streaming passed the
rewritten id straight through — DpCryptoManagement's issue #22.** Measured live
2026-09-05 against `wss://advanced-trade-ws.coinbase.com`: subscribing `ticker` to
`["XLM-USDC", "AVAX-USDC"]` — sent exactly as asked, both real, listed products —
delivers every frame tagged `XLM-USD` and `AVAX-USD` instead; the venue's own
subscription acknowledgement even echoes the rewritten names back
(`"ticker" => ["XLM-USD", "AVAX-USD"]`), not the ones actually sent. This is the
venue's own declared behaviour, not a guess: the same public, unauthenticated
`/market/products` catalogue this package already reads for `get_symbols/1` and
`list_instruments/1` names it directly — on this date, 112 of the first 114 USDC
products carried a non-empty `alias` naming their `-USD` counterpart. On a settled
DpCryptoManagement node running 0.1.17 with 406 pairs requested, this was the same
defect wearing two faces: 174 of the 406 *requested* pairs delivered nothing under the
name asked for, while 401 pairs *never requested* were decoded and stored under a name
nobody subscribed.
Fixed in `Feed`, not `Socket`: `Socket` still decodes and delivers under whatever
`product_id` the venue actually sent, unchanged. `Feed.handle_info({:dp_exchange,
:coinbase, payload}, state)` now resolves a delivered id against `Rest.get_alias_map/1`
— the venue's own declared relationship, fetched **once**, asynchronously, the first
time `subscribe/3` or `update_symbols/2` runs (never per frame, never per subscribe;
see `Feed`'s moduledoc for why it is not read from `init/1` or inline in the
triggering call) — and delivers under every name in `wanted` that names the same
market: the caller's own requested name, and its alias where the caller subscribed to
that instead. A caller subscribed to both receives both, from one delivered frame.
`coverage/1` needed no code change to become honest, since it already reports
whatever key delivery is recorded under.
**A catalogue that cannot be fetched degrades rather than guesses.** A failed fetch
delivers under the venue's own id — today's pre-fix behaviour — and reports exactly
once, as a `:data_quality` notice naming the failure, that attribution is degraded and
why. Munging `-USDC` into `-USD` was considered and rejected: it would be exactly the
"nearby substitute" this family forbids, and wrong for any pair the venue does not
alias — nothing here assumes the suffix relationship holds in general, and the fix
reads only the venue's own `alias` field.
Regression tests in `feed_test.exs` drive the proven mechanism directly — a subscribe
to the alias form receiving frames tagged with the canonical form delivers under the
alias form; `coverage/1` lists what was requested; both names subscribed both receive
one delivered frame; a catalogue fetch failure delivers under the venue's id plus the
degraded notice, never a guessed mapping; the fetch happens once regardless of how many
subscribes or delivered frames follow — and `rest_test.exs` covers `get_alias_map/1`
itself against a catalogue shaped like the live response captured while proving this.
- **`Supervisor`'s own rate limiter was configured from `public_ceiling` unconditionally,
contradicting `capabilities/0`'s own documented promise that credentials buy the higher
ceiling.** `capabilities/0` states outright: *"Pass credentials and this package uses
the authenticated path, which has the higher ceiling."* `Rest`'s request paths honour
that — but `Supervisor`'s `init/1` started `DefaultRateLimiter` from `caps.public_ceiling`
(3 req/s) for every instance, credentialed or not, so a consumer supplying credentials
got the documented 10 req/s from the venue and a third of that from this package's own
throttle regardless — a mechanism silently disagreeing with the declaration it exists to
encode, per this module's own moduledoc rule. `limits/1` now reads `opts[:credentials]`
(the same opts `Feed` reads it from) and configures the bucket from
`authenticated_ceiling` when a non-empty credential map was given, `public_ceiling`
otherwise. New tests in `supervisor_test.exs` prove both ceilings actually reach the
running limiter, and that an empty `%{}` does not buy the higher one.
- **A shard whose socket failed to open at all was silent at the facade — no
`Core.Notice`, only a `Logger.warning` that never crosses it.** Every shard beyond the
first opens asynchronously; when its `Socket.start_link/1` failed (a transient connect
refusal, a timeout), `handle_info({:open_shard, _, _}, _)` logged and stopped, leaving
those symbols silently absent from `coverage/1` with nothing telling a consumer why —
the exact "silent half-dead feed" this module's own moduledoc is about, and worse than
the channel-subscribe case one section up, which already got a `:coverage_change`
notice for the identical shape of fact (subscribed intent that did not become
delivery). Now emits one, through the same `notify_shard_open_failed/3` path.
**Worse: a shard that failed to open had no automatic recovery path at all.** The
unconditional `:resubscribe` tick only ever walked `state.shards` — a shard whose
socket never opened is not a key in it, so the tick had nothing to re-issue for it, and
the only thing that would ever reconsider it was a fresh `subscribe/3` or
`update_symbols/2` call, which may never come for a consumer whose scope is stable
after boot. `retry_missing_shards/1` closes this: on every `:resubscribe` tick, any
shard `state.wanted` still implies but `state.shards` has no entry for is retried on
the same unconditional cadence an already-open shard's subscriptions are re-issued on,
staggered past them by the usual `@shard_spacing_ms`. New tests in `feed_test.exs`
cover both: the notice on a failed open, and the tick recovering a shard that never
opened.
- **Reconciling more than one ALREADY-OPEN shard in a single `update_symbols/2` call
dropped the stagger between them — only a brand-new shard's connect was staggered.**
`reshard/1` computes `position * @shard_spacing_ms` for every shard beyond the
synchronous primary and hands it to `touch_shard/4`, but `reconcile_shard/6` (an
existing, already-open shard) never received it — every already-open shard a single
call touched had its `level2` subscribe scheduled at the identical instant regardless
of position. This is not the connect burst `@shard_spacing_ms` was written against (no
new socket opens here), but a related hazard: `Socket.subscribe/4` blocks THIS `Feed`
process — via `FrameSender`, up to `WebSockex.send_frame/2`'s 5s window — for as long
as its target socket takes to acknowledge, and several such messages landing in this
process's single mailbox together serialise into back-to-back blocking sends, capable
of stalling `coverage/1` and every other call to this `Feed` for as long as the
slowest one takes. `reconcile_shard/7` now receives and applies the same `delay`
`open_shard/5` already did. A new test in `feed_test.exs` proves it with two live
sockets recording arrival time: touching two already-open shards in one call now
delivers their first frames `@shard_spacing_ms` apart, not together.
- **`Fake`'s `get_top_of_book/2` ignored `opts[:credentials]` entirely,
making the fake MORE capable than the real venue on the one call where that gap
matters.** `Rest.get_top_of_book/2` refuses `{:refused, :missing_credentials}` before
sending anything when given none — `/best_bid_ask` has no public form on this venue,
confirmed live (`401` authenticated, `404` at the `/market/...` path a caller would
expect). The fake answered `{:ok, %Types.TopOfBook{}}` regardless, so a consumer's test
written without credentials would pass against the fake and refuse identically in
production — precisely the silent "differently capable" divergence this module's own
moduledoc says it exists to prevent (*"Six were loud... Three were silent, and those
are the ones this is designed against"*). The fake now refuses the same way, gated on
the same field. New tests in `fake_test.exs` and an updated one in
`fake_injection_test.exs` (which previously called it with no credentials at all and
got away with it) cover both branches.
- **`level2` and `ticker` shared one shard size, and the venue's own `level2` ceiling is
well under it — DpCryptoManagement's issue #22 continuing, not reopened.** The `ticker`
starvation fixed earlier in this file (the timed-out-subscribe retry entry above) is a
separate, already-closed incident in the same file; this is a second, independent
defect the sharding line above deliberately left unchanged pending a separate
measurement ("Deliberately unchanged: `@channels` order... raised separately with the
consumer instead"). That measurement is this entry.
A consumer's real 406-symbol universe, on sixteen otherwise-healthy boots (0
`send_timeout`, 406/406 `quotes` coverage): `shards/1` at the old shared
`@pairs_per_socket` (100) split it `[100, 100, 100, 100, 6]`, and `level2`'s subscribe
was refused on every 100-symbol shard — `"too many L2 streams requested in a single
session"`, 5,099 times — while the six-symbol shard's was not.
`coverage_by_kind/1` answered `order_book: 6` throughout: exactly the tail shard's own
count, pinning the cause on shard size rather than the connection, the alias fix, or
`ticker` (which has no such ceiling and was unaffected on the same boots).
No Coinbase documentation states a per-session `level2` product ceiling — re-checked
2026-09-06 against the Advanced Trade channels reference, connection overview and
rate-limits page (which states an `8`-per-second-per-IP connect/message rate, not a
subscription count), and the older Exchange product's separate rate-limits page (which
states a different, inapplicable number: 10 duplicate subscriptions to the same
product-channel pair, not the count of distinct products, and for a product this
package does not speak). This package cannot narrow it by probing the venue either:
`level2` is authenticated, and this repo's own testing strategy draws tier 3
(authenticated, live) as needing credentials this repo must never hold — the same line
that already keeps this repo off order placement.
`level2` now gets its own shard grouping, at its own, independent size —
`@level2_pairs_per_socket`, `6` — rather than sharing `@pairs_per_socket` (100, still
`ticker`'s own size, unchanged) with `ticker`. `6` is not a rediscovered venue limit;
it is the largest `level2` subscription size this package has direct evidence the venue
accepts, taken from the production numbers above (100 refused four times out of four, 6
accepted once out of one) rather than guessed at some unverified point between them.
Every shard, either channel, now opens its own dedicated, single-channel socket — for
the 406-symbol universe above, 5 `ticker` sockets (unchanged) plus 68 `level2` sockets
(`ceil(406 / 6)`), 73 total against 5 before, affordable now that removing in-package
`level2` book maintenance (the change above this one) cut per-frame decode cost roughly
tenfold.
Every new socket — either channel — is staggered on one `@shard_spacing_ms` sequence
with every touched `ticker` shard ordered ahead of every touched `level2` shard, so
`ticker`'s own boot-time coverage stays exactly as fast as before this change while
`level2`'s far more numerous shards ramp in behind it — for the 406-symbol universe,
roughly six minutes for the last `level2` shard, against a ceiling that previously never
moved at all. `@channel_spacing_ms` — the wait between `level2` and `ticker` sharing one
socket — is deleted along with the shared-socket design it existed for; no socket
carries two channels any more, so the busy-decoding hazard it guarded against cannot
occur. `@subscribe_retry_delay_ms` keeps its value (still `8_000`ms) on its own
reasoning rather than borrowing from a constant that no longer exists.
An adaptive shard size, driven down at runtime by the venue's own refusal so a wrong
constant could never be silently wrong forever, was considered and not built:
correctly telling a live shard's bookkeeping apart from a stale one the venue already
emptied on refusal is real complexity with its own correctness risk (silently under- or
double-subscribing a shard), and did not clear its bar against a fixed,
evidence-grounded constant plus the safety net that already existed and needed no
change — `Socket`'s `error_kind/1` already classifies "too many" as `:rate_limited` and
reports it as a `Core.Notice` on every occurrence, and `coverage_by_kind/1` already
never marks a symbol covered for `:order_book` on subscribed intent alone. Both are
verified unchanged by this fix. If `6` is ever also refused, a consumer with
`subscribe_notices/1` wired up hears about it exactly as loudly as any other refusal in
this file, and lowering the constant is a one-line change rather than a runtime
decision made silently.
New tests in `feed_test.exs` pin the structural fix: a credentialed feed given a symbol
count that fits in one `ticker` shard splits it into two `level2` shards, with the
`ticker` shard chosen as the call's synchronous primary; a credential-less feed never
opens a `level2`-keyed shard at all. The 12-shard resubscribe-floor test is now a
13-shard one, and its expected numbers drop the deleted `@channel_spacing_ms` term —
both mechanical consequences of this change, not new behaviour of their own.
### Documentation
- **CLAUDE.md claimed this package parses Coinbase's `cb-after` / `cb-before`
rate-limit headers; it deliberately does not, and never has.** Those are pagination
cursors, not rate-limit data, and Coinbase publishes no `x-ratelimit-*` or
`retry-after` either — measured live 2026-08-28. A prior adapter's parser keyed off
the cursor headers and returned three hardcoded constants labelled as measurements
(`remaining: 100`, `limit: 100`, a reset time one minute out); porting it would have
been exactly the fabrication this family refuses — recorded in
`docs/reference/coinbase/reconciliation.md` §5.5, which CLAUDE.md's own claim
contradicted. Corrected to state what actually happens: nothing is parsed, and
`Core.HttpClient`'s generic parser correctly answers `nil`.
- **`docs/reference/coinbase/endpoint-inventory.md` still listed `/best_bid_ask` and
`/product_book` as not implemented — family-wide defect sweep, Coinbase B3.** Both were
implemented and declared `:experimental` in `capabilities/0` well before this release;
the note was never updated when they shipped, which is part of why B1's missing
public/private branch on `/best_bid_ask` went unnoticed. Both endpoints are now marked
`✓` in the endpoint list, the stale "absent" note is corrected, and the newly measured
fact from B1 is recorded where this file's other live measurements live: `/best_bid_ask`
has no public form, verified live 2026-09-05, unlike `/product_book`, whose
`market/product_book` twin is real and public.
- **`usage-rules.md`'s "Streaming" section never said which symbol a delivered frame
carries, and never mentioned `resubscribe_interval_ms` at all — family-wide defect
sweep, Coinbase B5.** Both are consumer-facing behaviour a subscribing agent needs to
act on correctly, and this file — the one that ships inside the Hex tarball and is not
the README — was silent on both. The alias-attribution fix above changes what symbol
arrives on every streamed frame; `resubscribe_interval_ms` has been a real `Feed`
option, forwarded straight through from `{DpExchange.Coinbase, resubscribe_interval_ms:
ms}`, since the resubscribe-wedge fix above added it, and neither fact was checkable
from the shipped docs. Added two sections: one stating a delivered frame is tagged
with the symbol the caller subscribed to, never the venue's rewritten alias, including
the degraded-attribution fallback and its `:data_quality` notice; one documenting
`resubscribe_interval_ms`'s 60,000 ms default, how to set it, and that a value below
one full re-issue cycle for the current shard count is silently clamped to the
computed floor and logged rather than honoured.
- **README's endpoint counts were stale.** It read "46 are declared `:experimental` and
41 `:unsupported`" with "38" of those the venue's own absence. Run against the real
`capabilities/0` (`mix run -e`, 2026-09-05): **48 `:experimental`, 39 `:unsupported`**,
of which **37** are `venue_does_not_serve/0` (the other 2 are `@not_ported`,
`get_funding/2` and `get_contract_stats/2`). Corrected to the measured numbers rather
than re-guessed.
- **`frame_sender.ex`'s moduledoc claimed `WebSockex.send_frame/2` has "no way to
override" its 5-second timeout.** The vendored websockex 0.5.1 exposes `send_frame/3`
with a timeout argument, so the claim was wrong. `FrameSender.send/3` still calls the
2-arg form, so nothing about the actual timeout behaviour changes here — see the design
doc's deferred section for why a longer timeout is a decision for later, not a
drive-by alongside this correction.
- **Every `ticker` frame from the real venue failed to decode — 0 `Quote`s delivered,
ever, against live Coinbase, for the entire life of this package.** Surfaced while
chasing DpCryptoManagement's issue #22: a live test against 60 non-aliased, canonical
`-USD` symbols captured 500+ consecutive `data_quality` notices and zero `Quote`s in a
20-second window. `build_quote/2` read `ticker["time"]` — a field that does not exist
on the row. Confirmed against Coinbase's own CDP API reference for the `ticker`
channel, independently, twice: the timestamp lives on the *message envelope*
(`"timestamp"`, one per frame), never on the individual `tickers` row. Every hand-built
test fixture in this package — including the ones ported from the host adapter's own
test suite (`baseline_test.exs`, "Phase 5.7") — encoded the identical wrong assumption,
which is why this passed every test ever written against it and only ever failed
against a genuine live socket. `dispatch/2` now reads the envelope's own `timestamp`
and threads it down to `build_quote/3`; the per-row field is gone.
Applied the same fix to `l2_data`/`OrderBook`, which had a related but different
defect: `deliver_book/2` didn't read *any* venue timestamp — it substituted
`DateTime.utc_now/0` unconditionally, which is the exact substitution this file's own
moduledoc already named as wrong for the ticker path (`Core.Types.Quote`'s "never
substitute now" principle) while doing it anyway one function down. `deliver_book/3`
now reads the same envelope `timestamp` and fails closed if it's absent, same as
`build_quote/3` — the maintained book state still updates either way, only the
outgoing delivery is withheld.
**Does not, on its own, explain why `level2`/`OrderBook` delivered zero data in any of
the three live tests run while chasing #22** — the old `DateTime.utc_now/0` fallback
always succeeded, so this was never why level2 was silent there. That remains open.
- **A `level2` capacity refusal from the venue was reported as `:credentials_rejected`
— DpCryptoManagement's issue #22, filed as a suspected regression of #20.** Coinbase
answers both a genuine auth failure and "too many L2 streams requested in a single
session" through the identical `{"type":"error","message":...}` frame shape.
`Socket.dispatch/2` collapsed both into `:credentials_rejected` — the shape the
original stub-token incident produced — which sent a consumer that finally wired
`subscribe_notices/1` looking for a broken credential that was never broken. Now
classified by message content: a capacity refusal reports `:rate_limited`, Core's own
kind for pressure rather than identity: everything else keeps the original
`:credentials_rejected` behavior.
**This does not, on its own, explain or fix why 4 of 5 shards deliver nothing.** #20's
fix addressed a genuine, confirmed bug (an unstaggered connect burst) but issue #22's
live evidence — the refusal persisting unchanged across 15+ minutes and two clean
restarts, with every socket healthy and connected — describes a *permanent* per-shard
rejection, not the *transient* reset #20 targeted. Whether Coinbase enforces `level2`
session capacity per account rather than per connection, which would make multi-socket
sharding for this channel fundamentally incompatible with this venue regardless of
spacing, is not something this repository can verify without live credentials. Left
open pending that evidence.
- **A scope wide enough to need three or more shards opened them all in the same
instant instead of staggered, and 60-second resubscribes re-issued the same burst
every minute — DpCryptoManagement's issue #20, a real ~406-symbol/5-shard production
scope where 4 of 5 shards (400 symbols) never delivered a single tick while the fifth
did.** `reshard/1` scheduled every shard past the synchronous first one with the
*same* fixed `@shard_spacing_ms` delay rather than one increasing per shard, so all of
them opened together — exactly the connect burst this module's own moduledoc already
named as the failure the venue answers with resets. Only a suite exercising three or
more shards could have caught it; the existing test only ever covered two (one
synchronous, one staggered), where a single fixed delay is indistinguishable from a
correct one. Fixed by scheduling each shard's turn `position * @shard_spacing_ms`
after the one before it, applied to both the initial open and the unconditional
60-second resubscribe. A regression test now exercises three shards.
- **`Feed.fan_out/2` crashed on a subscriber registered by name — DpCryptoManagement's
issue #15.** `subscribe/2`'s `to:` option accepts any value, and `fan_out/2` called
`Process.alive?/1` on it directly — which only accepts a pid and raises on anything
else. A consumer registering itself under a name (ordinary OTP practice) and handing
that name to `to:` crash-looped the whole `Feed` GenServer on every delivery. Fixed by
resolving a subscriber (pid or name) to a pid first, treating an unregistered name the
same as a dead pid: silently skipped, never a crash.
- **`feed_test.exs`'s own fake sockets never answered `WebSockex.send_frame/2`'s
internal `:gen.call`, silently turning several tests into a real, load-dependent race
against two independent ~5-second timeouts** (WebSockex's own hardcoded one and
`:sys.get_state/1,2`'s default) rather than a fast, deterministic assertion — the
file's slowest tests ran 5–15 real seconds each and occasionally lost the race outright
under load from the rest of the suite. Not flakiness to route around: traced to a
root cause and fixed there. One fake now replies immediately per `:gen`'s own reply
protocol (removing the stall entirely); the other, which intentionally models a socket
whose frames fail, now fails **immediately** rather than by never replying. Full
`feed_test.exs` run time: ~45s → ~3s.
- **`to_order/1` read both `Order.quantity` and `Order.filled_quantity` from the same
venue field — DpCryptoManagement's issue #12.** `order["filled_size"]` populated both,
so a fetched order's `remaining_quantity` (quantity minus filled) was always zero, even
for a genuinely open, partially-filled order — a correctness bug for anything
reconciling open-order state. `quantity` now reads the venue's own record of what was
requested, from `order_configuration`'s leaf `base_size` — the same field
`closing_configuration/1` already reads for a closing order's size, on the same
response envelope. A quote-sized market order's leaf carries `quote_size` instead, with
no rate here to convert it, so `quantity` is `nil` rather than a guess in that case.
### Added
- **`level2` is subscribed and decoded — `streamable` gains `:order_book`.** The channel
was recognised and had working auth machinery since an earlier release but was never
actually requested; `capabilities/0` said `[:quotes]` while the code that would have
served `:order_book` sat unused. `Socket` now maintains a real per-symbol book —
snapshot then patched by `update` deltas, `new_quantity: "0"` removing a level — and
delivers `Core.Types.OrderBook` sorted best-price-first on every change, matching this
family's existing convention (see Schwab's book services) of emitting on every venue
frame rather than throttling client-side.
- **Sharded — this venue's whole subscription no longer runs on one socket.** Measured
2026-08-27 against a live ~400-symbol universe: a `level2` subscribe over the venue's
real per-session limit gets `"too many L2 streams requested in a single session"` and
the socket closes, a total data gap rather than degraded coverage — 355 of 405 pairs
went stale, 1,480 refusals in one log window. `Feed` now opens one socket per 100
symbols (the number from that incident, carried over rather than re-derived), spaced
to avoid a connect burst, `level2` subscribed before `ticker` on each and the two
spaced apart so a snapshot decode in progress does not turn a `ticker` subscribe into
a `send_timeout`.
- **A reconnect now resubscribes.** WebSockex reconnects a dropped socket on its own and
leaves it subscribed to nothing — silently, since a connected socket receiving
nothing looks the same as a quiet market. `Feed` re-issues every shard's current
subscriptions on a 60-second timer, unconditionally; the reference implementation this
replaces lost a venue's entire coverage to exactly this gap for roughly forty minutes
before anyone noticed the chart had gone flat.
- **`level2` is skipped for a credential-less subscriber rather than failing loudly for
no reason.** It requires a credential and `ticker` does not; a caller with no
credentials only ever wanted the public channel, and sending a doomed authenticated
subscribe would either surface `credentials_required` as this call's synchronous
result — masking that `ticker` works fine — or cost a wire round trip to learn what
the credential's absence already answers.
### Documentation
- **The `:unsupported` list is now split.** `venue_does_not_serve/0` names the 38 endpoints
that are Coinbase's own absence — staking reads, the one-step convert, funding rails,
option chains, watchlists — each with the source and date behind it; three
(`get_funding/2`, `get_contract_stats/2`, `list_instruments/1`) stay under `@not_ported`
because they are the venue's surface and this package's backlog, not the venue's gap.
Robinhood found four callbacks mislabelled the other way; this pass checks Coinbase's own
list rather than assume it was filed correctly the first time.
- **`README.md` states what the contract covers** — 46 of 87 callbacks `:experimental`, and
points at `negative-claims.md` for every absence's source.
- **`docs/reference/coinbase/endpoint-inventory.md`'s counts refreshed.** It read "everything
authenticated is absent" until this release, which had been true at capture and stopped
being true as this package grew — the vendor-side numbers had not moved, this package's
coverage of them had, and the section conflated the two.
### Documentation
- **Every negative this package makes is audited**
`docs/reference/coinbase/negative-claims.md`, twelve claims with the source and date
consulted for each. Nine hold; **three were wrong**, and all three for the same reason:
each was a true statement about one endpoint restated as a claim about the venue.
`supports_order_preview: false` and `supports_order_replace: false` were assumed without
reading the list the endpoints are on — the second mattered more, because it told a caller
to cancel and re-place, opening a window in which no order is live. And
`get_trade_volume/2`'s "Advanced Trade does not aggregate" was read off
`/products/volume-summary`, which is *market* volume and a different question.
The check that would have caught all three is the one the table now enforces: **name the
endpoint you looked at, and the date.**
- **`usage-rules.md` gains the surface this release added** — the two accounts a futures
position is margined from, Prime's separate host and credential triple, convert's absent
expiry, portfolios as addresses, and the fee/volume pair.
- **`AGENTS.md` gains a pointer** to this package's own `usage-rules.md`, so a reader who
opens the generated file knows where the package's rules actually are.
### Changed
- **Core dependency moves to `~> 0.1.36`**, and `place_orders/3` is declared **absent with
the reason**: this venue places one order per request. A batch is one request the venue
accepts or rejects as a unit, and a caller placing several here calls `place_order/3`
several times and reconciles the outcomes itself.
### Added
- **Key permissions and the server clock**`get_roles/1`, `get_server_time/1` and a
`test_connection/2` that is no longer declared absent.
**`can_transfer` is a separate permission from `can_trade`**, and a key routinely holds one
and not the other. Asking is cheaper than discovering a missing one from a refused
withdrawal. The response also names **the portfolio the key is scoped to**, which is where
a caller finds out whose balance it has been reading.
**`test_connection/2` asks two different questions and picks by what it was given.**
Without credentials it reads the public clock — reachability alone. With them it reads the
key's permissions, which fails if the key is wrong and answers what the key can do if it is
right. An unreachable venue and an unaccepted key are different problems.
`get_server_time/1` returns the venue's own map **undiffed**. The difference a caller cares
about is against its own clock at the moment it asked, and computing it inside the package
would hide the round trip in the number. It is worth reading at all because this venue's
JWT window is two minutes: a host clock further out than that produces authentication
failures that look like a credential problem.
- **Convert, portfolios and the transaction summary** — the last ten Advanced Trade
endpoints in the coverage plan's Phase 11.
**Convert is the facade's only two-step operation, and Advanced Trade states no expiry at
all.** `expires_at` is `nil`, which means "not stated" and never "open-ended": a caller
committing a lapsed quote can be filled at the *current* rate rather than refused, which is
the dangerous outcome because the operation looks like it succeeded and every number is
real. `commit_conversion/2` and even `get_conversion/2` **re-ask for both accounts** — the
venue's own rule, unusual for a read — and this package fills neither in: a conversion
committed against accounts the caller did not name happens between the wrong two balances.
A status this package does not know maps to `nil`, never the nearest one.
**A portfolio is an address, not a value.** `list_portfolios/1` returns them,
`get_portfolio_breakdown/3` returns what is *inside* one — a different and much larger
answer — and `create_account/1` and `rename_account/3` reach the portfolio endpoints,
because Advanced Trade has no notion of creating an *account*. **Deleted portfolios stay in
the listing**: the venue keeps them because old orders still name their ids, and filtering
them out would make a historical id look like one that never existed.
**`get_trade_volume/2` was declared absent on a claim that was wrong.** This package held
that "Advanced Trade does not aggregate" the account's own volume; the transaction summary
does, in `volume_breakdown` per volume type with `advanced_trade_only_volume` and
`coinbase_pro_volume` beside it. The claim had been made from the *market* volume
endpoint's absence, which answers a different question. The two account totals ride
alongside the breakdown rather than being folded in: the venue documents the first as
non-inclusive of the second, so adding either to the breakdown double counts.
`get_fees/2` carries **both** `fee_tier` and `fee_tier_without_promotion` — they differ
while a promotion is running, and it can end between two calls — and keeps the tax's
`INCLUSIVE`/`EXCLUSIVE` flag, because the same rate quoted either way is a different amount
of money.
- **US derivatives — the nine CFM endpoints.** `get_positions/1` and
`list_futures_positions/1`, `get_futures_position/3`, `get_futures_balance_summary/2`,
the three sweep calls, and the three intraday-margin calls.
**Two accounts, and the balance summary names both.** Futures margin from an account held
with Coinbase Financial Markets; spot sits in one held with Coinbase Inc. `cfm_usd_balance`
is the first, `cbi_usd_balance` the second, `total_usd_balance` the pair — and a caller
sizing a futures position against the total is sizing against money that is not there.
Every amount keeps its `currency`; flattening it off is how two currencies get added.
**`:realised_pnl` is `nil` on a `Types.Position` from this venue, and that is not an
omission.** Coinbase publishes `daily_realized_pnl` — what the position realised *today*
and no lifetime figure. Putting a daily number in a field that means the position's answers
a different question under the same name: a caller summing it across reads counts one day
repeatedly. The daily figure is not discarded — `list_futures_positions/1` returns the
venue's own row, where it keeps its own name, along with `expiration_time`, which
`Types.Position` has no place for either because a future expires and a perpetual does not.
**A sweep is scheduled, not settled.** `schedule_futures_sweep/2` queues a move out of the
futures account and `list_futures_sweeps/2` reports the queue; a listed sweep has not
happened. **Omitting the amount sweeps every available excess dollar** — the venue's
documented default, stated here because a caller reading a missing amount as "nothing"
would move the lot. `cancel_futures_sweep/2` cancels *the* pending sweep and takes no id.
**`INTRADAY_MARGIN_SETTING_UNSPECIFIED` is not `_STANDARD`.** It is the venue declining to
say, and mapping it to the safer-sounding value would assert a setting the account may not
have. The venue's own strings are returned and required on the way in, with no default:
`UNSPECIFIED` is a value in the enum, and choosing it for a caller would set the account to
something it did not ask for.
`get_current_margin_window/2` carries both kill-switch flags. An account that believes it
is on intraday margin while the switch is enabled has more leverage in its plan than in its
account.
`supported_instrument_types` gains `:future`. `:perp` stays absent: Advanced Trade's
perpetuals are the INTX endpoints, which are `APPROVED-SKIP` as deprecated, and declaring a
surface this package does not reach would be a claim about the venue standing in for one
about the package.
- **Coinbase Prime custodial staking**`DpExchange.Coinbase.Prime`, all nine endpoints,
with `stake/3` and `unstake/3` now live on the facade.
**A different product, host and signing scheme.** Everything else in this package talks to
`api.coinbase.com/api/v3/brokerage` and signs a CDP JWT; Prime talks to
`api.prime.coinbase.com/v1` and signs an HMAC under an access key, a passphrase and a
signing key that Advanced Trade neither issues nor accepts. Two of the three credentials
is `{:error, :missing_prime_credentials}` rather than a request that is signed and wrong.
**These are not the CDP Staking API.** Those seven are on-chain: they take a wallet
address and return **unsigned transactions for the caller to sign and broadcast**.
Reaching them through `stake/3` would be this family's recurring failure at its most
expensive — a caller believing it had staked while holding a transaction nobody sent.
**Two scopes, and this package picks neither for you.** Prime publishes every staking
operation across a portfolio and again on one wallet, and the two are not interchangeable:
a portfolio-scoped unstake redeems across every wallet in the portfolio. `stake/3` and
`unstake/3` follow only what the caller said — a `:wallet_id` means the wallet, its
absence means the portfolio — and `opts[:portfolio_id]` is required, refused as
`{:error, :missing_portfolio}` before a request is made.
Four callbacks stay declared **absent with the reason**: Prime publishes no rate schedule
and no staking history at either scope; `staking/status` names one wallet and is not
"every staked position, one per asset" (reachable as `Prime.staking_status/4`); and
`claim_rewards` is a write that moves accrued rewards, not a report of what accrued.
**Nothing here has been run against Prime.** The paths are read from the vendor's pages on
2026-08-31 — thirteen pages, nine endpoints, four pairs documenting one path under two
names — and the signing scheme from Prime's authentication documentation. This repository
holds no Prime credential and money-moving endpoints are answered in production, not by a
test here. Responses come back as the venue's own maps for the same reason: a
`Types.StakingBalance` built from an unverified field name is a plausible number in the
wrong field.
- **Payment methods and the internal move**: `list_payment_methods/2`,
`get_payment_method/3` (`GET /payment_methods`, `GET /payment_methods/{id}`) and
`transfer_internal/4` (`POST /portfolios/move_funds`).
**A payment method's flags disagree with each other.** Each row carries `verified`,
`allow_deposit` and `allow_withdraw`, and a method verified for deposit is routinely not
verified for withdrawal. Rows stay the venue's own maps and no "usable" boolean is
synthesised from them — collapsing the flags is what makes a caller move fiat through a
method the venue refuses.
**`get_payment_method/3` is the read; the listing is a snapshot.** A method's state
changes without the account doing anything, and selecting the row out of an earlier
listing answers with whatever was true when that listing was taken.
**`transfer_internal/4` moves nothing off Coinbase** — no chain, no address, no network
fee. Both portfolio uuids are required and neither is defaulted: a move missing either is
`{:error, :missing_portfolio}` before a request is made, because the alternative is
shifting funds between portfolios the caller never named. The amount is sent in full
notation, since `Decimal.to_string/1`'s scientific form is not a number this venue reads.
### Changed
- **Core dependency moves to `~> 0.1.33`**, and with it twelve callbacks are now declared
rather than missing. Nine are declared **absent with the reason**, checked against the
venue's own reference on 2026-09-01: Advanced Trade publishes no allowlist
(`request_approved_address/4`, `remove_approved_address/3`), no networks list
(`list_networks/2`), no fiat registration (`add_payment_method/2`), no fee promotions
(`list_fee_promos/1`), no FX publication (`get_fx_rate/3`), no notional valuation
(`get_notional_balances/3`) and no custody product (`list_custody_fees/2`).
**`get_transactions/2` is absent for a different reason worth stating.**
`/transaction_summary` exists and is *not* it: that endpoint reports what the account
traded in a window and what it cost, not an enumeration of deposits, fees and
adjustments. Returning it here would have answered a different question while looking
like this one.
- **`quantization/1` — what the venue will actually accept**, and `Rest.get_product/2` for
the whole record. Both were `:unsupported`.
**The venue names four increments and they are not interchangeable.** `quote_increment`
bounds the *price* and `base_increment` the *quantity*; a caller rounding a price to the
base increment produces an order the venue rejects on a field it did not name. Both
minima are carried too — `base_min_size` is units and `quote_min_size` is cash, and a
market order sized in cash is bounded by the second where a limit order in units is
bounded by the first.
`status` is the venue's own word, unmapped: a boolean would lose the difference between a
product that is paused and one that is gone.
- **`get_symbols/1` reads the authenticated catalogue when a credential is present.** Third
and last of the public/private path corrections — the book, the candles and now the
product list were all reading `/market/…` regardless.
- **`get_trades/2` — the public tape.** `get_price/2` already reads this payload and keeps
only the newest print, because a `Quote` has room for one price; the rest were discarded
at the boundary. This returns them.
Not `get_trade_history/2`, which is the credential's own fills. `broken` is `false` on
every print — the ticker publishes no bust flag, and a venue with nothing busted reports
nothing busted.
- **`get_historical_prices/4` reads the authenticated candles path when a credential is
present.** The venue publishes the same candles twice — `/market/products/…` public and
`/products/…` for a credential — and this always called the public one, so a caller
holding a credential was silently forgoing whatever the authenticated view adds. Same
correction as the product book.
- **`get_order_book/2` — depth, which this package declared `:unsupported`.**
`GET /product_book` for a credential and `/market/product_book` without one — the venue
publishes the same book twice, and reading the public one while holding a credential
would silently forgo whatever the authenticated view adds. The venue's `limit` and
`aggregation_price_increment` are passed
through.
**Both sides come back as the venue ordered them.** Re-sorting would hide a venue that
sent a crossed or out-of-order book, which is exactly the thing worth seeing.
**A book the venue did not date is refused.** A depth snapshot carrying the client's clock
cannot be told apart from a current one, and a stale book read as current is the most
expensive wrong number here. `sequence` stays `nil` — the endpoint publishes none, and a
caller must not learn to detect stream gaps from a REST book.
### Fixed
- **`get_top_of_book/2` now carries the sizes.** It read `/products/{id}/ticker`, which
publishes `best_bid` and `best_ask` and nothing about how much is there — so `bid_size`
and `ask_size` were `nil` on every response.
That `nil` was honest and it was avoidable: the venue publishes `/best_bid_ask`, whose
pricebook carries the size at each level. **A price without a size is half a top of
book** — a caller sizing against the best bid needs to know whether there is 0.01 there
or 40, and `nil` gave it no way to ask.
An empty side is still `nil` rather than zero: one side of a book can genuinely be empty,
and zero would claim someone is quoting nothing at a price of nothing.
- **`get_trade_history/2` — past fills.**
**`trade_type` is not decoration.** Regular fills carry `FILL`; the venue also emits
`REVERSAL`, `CORRECTION` and `SYNTHETIC` for adjusted ones, and a reversal is not a trade
that happened. `Core.Types.Fill` has no field to say which is which, so summing a mixed
list produces a position and a cost basis that are both wrong and both plausible. This
returns **only `FILL` rows by default**, and `opts[:trade_types]` widens it — returning
all four under a type that cannot distinguish them would be a substitution, and refusing
them entirely would hide corrections the venue made.
A fill the venue did not date is **refused**, not stamped with the local clock: a fill is
an event at a moment, and a client timestamp places it wrongly in a history while looking
entirely reasonable.
`fee_currency` is `nil` rather than the pair's quote guessed from the symbol — a fee can
be charged in a third asset and often is. `UNKNOWN_LIQUIDITY_INDICATOR` maps to `nil`,
because neither `:maker` nor `:taker` is an honest answer to the venue saying it does not
know.
Filters go to the venue rather than being applied to the page it returned, and the walk
follows `cursor` to a page bound.
- **`get_balances/2` and `get_accounts/2`.** The package could not say what the credential
holds.
**The venue reports `available_balance` and `hold` and no total.** The total here is
their sum — arithmetic on two numbers the venue stated, not an estimate — and it is `nil`
when either is missing rather than the other one alone. "Available 1.25, total unknown"
and "total equals available" are different claims, and a consumer sizing against the
second when the first is true trades against money that is held.
**The endpoint pages, at 49 by default and 250 at most, and this follows the cursor.** A
caller reading one page holds some of its balances with nothing to say which are missing,
and every number on that page is real — which is what makes stopping there worse than
failing. `@max_account_pages` bounds it, so a server that always says `has_next` errors
rather than looping inside a facade call.
`get_accounts/2` is separate because an account is more than a number: a caller routing an
order needs the uuid and the platform, and a caller sizing one needs the balance.
Collapsing them would lose the first. `opts[:uuid]` reads the single-account endpoint.
`:timestamp` is when the request was made — a balance has no venue event time.
- **`convert/4` and `get_trade_volume/2` (Core 0.1.22) are declared unsupported, with the
reasons checked.** Advanced Trade's convert is the **two-step** form —
`POST /convert/quote`, `POST /convert/trade/{id}`, `GET /convert/trade/{id}` — which is
`quote_conversion/4` and friends, scheduled separately. The one-step `POST /conversions`
belongs to the **Exchange** API, a different product this package does not reach.
`/products/volume-summary` is market volume and lives there too; `get_trade_volume/2`
asks what *this account* traded, which Advanced Trade does not aggregate.
- **`preview_replace/4` and `close_position/3`.** Two documented endpoints this package had
no facade for.
`POST /orders/edit_preview` prices an amendment before it is made. It is not
`preview_order/3` with an order id: the venue prices the amendment against the resting
order's own state, including whatever of it has already filled, and its response carries
`average_filled_price` and `order_margin_total` — numbers a fresh order does not have.
It takes the same `:price` / `:quantity` change set `replace_order/4` does and refuses
anything else before the request.
`POST /orders/close_position` flattens a position by having the venue place the closing
order. **The returned `Order` carries no side.** The venue never states one, and it
worked the side out from a position this package did not read — filling in `:sell`
because closing is usually selling is wrong exactly where it matters, on a short. The
order type, time in force and size *are* read, from the `order_configuration` the venue
echoes back, and a configuration key this package does not recognise leaves them `nil`
rather than picking the nearest.
### Fixed
- **`cancel_all_orders/2` is declared unsupported, with the reason checked.**
`POST /orders/batch_cancel` takes an explicit `order_ids` list — it is the endpoint
`cancel_order/3` already uses, one id at a time. There is no "cancel everything" call
here, and assembling one from `get_orders/2` plus a batch would be N partial outcomes
with no way to reach an order that appeared between the listing and the cancel.
- **BREAKING: `get_historical_prices/4` returns `Core.Types.Candle`. It was returning
`Quote`s with `price: close`.**
The venue sends open, high, low and close for every bar. Three of them were discarded
here, at the boundary, where no caller could see it happen — and everything that came out
was a real number, so nothing looked wrong. A caller reading `price` was holding one
corner of a bar with no way to learn it.
**This is the same defect the coverage plan's 2.10 found in Schwab**, with the same
reasoning behind it, still live here after that one was fixed. The fake had it too: it
returned `get_price/2`'s `Quote`, so the suite agreed with the bug it existed to catch.
Bars now carry all four prices and `:opened_at` — the venue's own bucket start, used
as-is. A bar the venue did not date is refused with `:missing_venue_timestamp` rather
than stamped with the local clock, which would place it wrongly while looking right.
### Fixed
- **This package claimed the venue has no order preview and no atomic replace. It has
both.** `supports_order_preview` and `supports_order_replace` were declared `false` on
those claims, and neither was checked against the venue's reference. Coinbase publishes
`POST /orders/preview` and `POST /orders/edit`; both flags are now `true` and both
endpoints are implemented.
The replace claim was the worse of the two. Its moduledoc called
`supports_order_replace: false` "a claim about **risk** rather than convenience", because
cancel-then-replace opens a window in which no order is live. The risk was real and the
claim was wrong: **the package was describing a hazard it was creating by not implementing
the endpoint that avoids it.**
### Added
- **`preview_order/3`** builds the same `order_configuration` as `place_order/3`, so a
preview is a preview of the order that would actually be sent. **A `200` carrying a
populated `errs` is a refusal** — returning it as a successful preview would tell a caller
its order is fine when the venue has already said otherwise. A `warning` is passed through
and does *not* make it a refusal.
- **`replace_order/4`** edits price or size in place. **Any other change is refused rather
than dropped**: a caller trying to change the side is describing a different order, and
editing only the price would leave it holding one it did not ask for. The venue's edit
response carries no order body, so the order is **read back** rather than reconstructed
from the request — reporting what was asked for as though the venue had confirmed it is
the mistake this whole contract is written against.
### Added
- **`cancel_order/3`, `get_order/3`, `get_orders/2`.** The order lifecycle, where there was
none.
**Cancellation is a batch endpoint that refuses per order.** `POST /orders/batch_cancel`
answers with a `results` array carrying its own `success` and `failure_reason` per id, so
a `200` says nothing about whether anything was cancelled. A batch of one is still a
batch. An order already filled comes back as a **refusal**, not an `:ok` — "I cancelled
it" and "it was not there to cancel" are different facts, and a caller retrying on the
second is chasing nothing.
**`CANCEL_QUEUED` maps to `:open`, not `:cancelled`.** An order accepted for cancellation
is still live until the venue says otherwise; reporting it gone invites a second order for
the same exposure.
**A status, side, order type or time-in-force this package does not recognise is `nil`,
never the nearest atom.** A venue adding a word later produces an absent field rather than
a plausible wrong one.
`get_orders/2` filters at the venue rather than in this package — a client-side filter
over one page would silently drop matching orders sitting on the next. **It returns one
page and does not follow the cursor**, which is stated rather than left for a caller to
discover while reconciling.
- **`place_order/3`.** This venue could not place an order; it can now.
Coinbase names the order type and the time-in-force in a **single key**
`limit_limit_gtc`, `market_market_ioc`, `stop_limit_stop_limit_gtd` — and the set of names
is sparse. There is no `limit_limit_ioc`, no `market_market_gtc`.
**A pair the venue does not name is refused before the request is sent.** Sending
`{:limit, :ioc}` as `limit_limit_fok` would place an order that fills-or-kills where the
caller asked for immediate-or-cancel, and every field in the request would look right.
Three further refusals rather than defaults: a limit without a price, a stop-limit without
a stop price, and a market order sized in neither base nor quote. `post_only` is omitted
when unset rather than sent as `false`, because silence is not a decision to take
liquidity.
A `200` carrying `success: false` is a **refusal**, not a placed order.
`client_order_id` is the venue's idempotency key: a caller's own is passed through, and a
v4 UUID is generated from the VM's CSPRNG when absent.
### Added
- `DeprecatedEndpointsTest` — fails the build if any code path constructs one of Coinbase's
six vendor-deprecated INTX endpoints. They are absent today; nothing kept them absent.
- `docs/reference/coinbase/endpoints-enumerated.tsv` and a rewritten inventory: the documented
surface is **712 REST operations and 46 socket channels**, enumerated endpoint by endpoint
from all 806 reference pages, replacing a page count. **Deribit alone was recorded as 37 and
is 115** — Coinbase renders it as twelve sibling trees with no `deribit` in their paths.
- Prime's custodial staking enumerated: **13 documentation pages, 9 endpoints**, four pairs
being duplicate pages for one path.
### Added
- Repo scaffold from the DpExchange standard; extraction pinned to the host's
`553fa787` with its working-tree state recorded, since the Coinbase subtree was dirty
at extraction time.