All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
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]
Added
coverage_by_kind/1implemented — Coinbase is the motivating case fordp_exchange_core0.1.48's new optional callback.coverage/1answers "is anything arriving for this symbol" by counting any payload at all, so alevel2book update counted identically to atickerquote. That blindness is not hypothetical:level2delivered upward of 11,000 frames across 406 subscribed symbols whiletickerstayed dark on all but a handful, andcoverage/1still answered:streamfor all 406 — correct by its own definition, and exactly why DpCryptoManagement's issues #20 and #22 stayed unpinned for days.Feed'sdeliveringmap now tracks%{symbol => %{kind => timestamp}}instead of a bare timestamp, keyed off whichCore.Typesstruct actually arrived (%Types.Quote{}→:quotes,%Types.OrderBook{}→:order_book) — never off this venue's own channel names, which stay internal.coverage_by_kind/1onDpExchange.CoinbaseandDpExchange.Coinbase.Fakeboth satisfy the union invariantdp_exchange_core's conformance suite now checks whenever a venue exports this callback: the symbol keys across every kind exactly matchcoverage/1's own keys, and every kind reported is onecapabilities().streamabledeclares. The fake reports everything under:quotesonly, honestly —subscribe/2never synthesises an order book, and claiming:order_bookcoverage it cannot back would be the "differently capable" divergence this fake's own moduledoc forbids.Bumped
dp_exchange_corefrom~> 0.1.36to~> 0.1.48to pick up the callback.Fakewired toCore.FakeInjection— DpCryptoManagement's issue #14. Every function with a real success path (not an unconditionalVenue.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/1andclose_position/3support 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/2andupdate_symbols/2are deliberately not wired — each takes a symbol list in one call, which whole-call injection cannot express partial failure for; neither iscoverage/1orsubscribe_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 inspectcredentialsat all, an existing gap this wiring does not change. Seedocs/design/2026-09-04_webull-sharding-and-fake-injection.md§3.6/§3.7 indp-exchange-core.get_market_overview/1andlist_instruments/1are implemented — DpCryptoManagement's issue #10. Both sat behindVenue.not_supported(), one filed as a genuine venue absence (@venue_does_not_serve) with no per-item comment explaining why, againstget_symbols/1already calling the exact bulk endpoint (/products//market/products) that carries all of it. Live-verified: the response Coinbase actually returns namesprice,price_percentage_change_24h,volume_24h,high_24h,low_24h,statusandproduct_typeper product, andget_symbols/1kept onlyproduct_id. Both new functions read the same responseget_symbols/1already fetches — via a sharedfetch_products/1— rather than a second request.
Fixed
An empty
pricebooksarray from/best_bid_askwas 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 becauseCore.PollingFeedreports 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 answers404 {"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 different404 {"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_asktakes a list ofproduct_idsand 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 existingclassify/1path, which this change does not touch. New tests inrest_test.exsandorder_book_test.exscover 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 asend_frameexit 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 intoFeed. Alevel2subscribe triggers a full per-symbol book snapshot; the socket is single-threaded and cannot service the nextsend_framewhile decoding it, so firingticker's subscribe@channel_spacing_mslater 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
level2got through broadly, its opening snapshot burst starvedticker; when the venue refused mostlevel2subscriptions outright (its own per-session stream limit — see the "sharded" section above),tickerhad the socket to itself and got everything. A lone:send_timeouton atickersubscribe 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_msrather 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 aCore.Noticeof kind:coverage_changein addition to the existing log — a channel that never subscribed is exactly the invisible half-dead feed this issue is about, and aLogger.warningalone 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:
@channelsorder (level2beforeticker) and@channel_spacing_msitself. 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_blockingwas unreachable on every REST call this package makes — family-wide gap, DpCryptoManagement's issue #23.Core.HttpClient.check_rate_limits/1reads this option to chooseacquire/3(wait for capacity) over fail-fastcheck/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/5andPrime.request_opts/1all stripped it from their forwarded-options allowlist before it ever reachedCore.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 ofacquire/3/check/3was actually called — not merely that the keyword survives the allowlist. Not defaulted anywhere in this package, unlikedp_exchange_webull'sFeedanddp_exchange_robinhood'sFeed: 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_msapart and each shard's channels@channel_spacing_msapart, so the last frame goes out about(shards - 1) * 5_000 + 8_000ms after the tick. If the timer re-fired before that, cycles overlapped, frames queued behind each other,WebSockex.send_frame/2blew its window, and theFeedstopped answering calls entirely —:sys.get_state/1timing 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/2could never work without credentials, and the facade said otherwise — family-wide defect sweep, Coinbase B1. Unlike every sibling market-data reader inRest, this one call is hardcoded to/best_bid_askwith no/market/best_bid_askbranch. Re-verified live 2026-09-05: authenticated is401, and the public path a caller would expect by analogy is404— 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's401as an opaque error.DpExchange.Coinbase's moduledoc andcapabilities/0'scredential_benefitcomment 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.mdcarried 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/2silently dropped alevel2row it could not parse — family-wide defect sweep, Coinbase B2. Every other decode-failure path inSocket(deliver_ticker/3,deliver_book/3) reports a:data_qualitynotice throughreport_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/3now threadsstatethrough and reports a:data_qualitynotice for an unparseableprice_level/new_quantityand for a row missing those keys entirely — the connection is still never torn down over one bad row.Socket.start_link/1inherited WebSockex's own connect/recv timeouts by accident — family-wide defect sweep, Coinbase B4. No:socket_connect_timeoutor:socket_recv_timeoutwas set, so WebSockex supplied its own defaults — measured in the vendored dependency,deps/websockex/lib/websockex/conn.ex:10-11:6_000ms connect,5_000ms recv. That matters specifically becauseFeed'sopen_shard/5synchronous branch callsSocket.start_link/1from inside ahandle_call/3, andFeed's own@call_timeoutis@frame_window_ms * 3=15_000ms — a named, shared process, so every other consumer'ssubscribe/2,unsubscribe/2,update_symbols/2andcoverage/1call queues behind that one call. The inherited defaults alone (6_000 + 5_000 = 11_000ms) 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_000ms each (6_000ms total), chosen deliberately againstFeed's15_000ms budget — leaving roughly9_000ms of the same call for the socket to send at least one subscribe frame (capped atFeed's own5_000ms@frame_window_ms) plus ordinaryGenServeroverhead. No failure semantics changed:start_link/1still 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 inoptsstill overrides it. The merge is factored into a small@doc falseconnection_opts/1so 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: subscribingtickerto["XLM-USDC", "AVAX-USDC"]— sent exactly as asked, both real, listed products — delivers every frame taggedXLM-USDandAVAX-USDinstead; 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/productscatalogue this package already reads forget_symbols/1andlist_instruments/1names it directly — on this date, 112 of the first 114 USDC products carried a non-emptyaliasnaming their-USDcounterpart. 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, notSocket:Socketstill decodes and delivers under whateverproduct_idthe venue actually sent, unchanged.Feed.handle_info({:dp_exchange, :coinbase, payload}, state)now resolves a delivered id againstRest.get_alias_map/1— the venue's own declared relationship, fetched once, asynchronously, the first timesubscribe/3orupdate_symbols/2runs (never per frame, never per subscribe; seeFeed's moduledoc for why it is not read frominit/1or inline in the triggering call) — and delivers under every name inwantedthat 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/1needed 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_qualitynotice naming the failure, that attribution is degraded and why. Munging-USDCinto-USDwas 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 ownaliasfield.Regression tests in
feed_test.exsdrive the proven mechanism directly — a subscribe to the alias form receiving frames tagged with the canonical form delivers under the alias form;coverage/1lists 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 — andrest_test.exscoversget_alias_map/1itself against a catalogue shaped like the live response captured while proving this.
Documentation
docs/reference/coinbase/endpoint-inventory.mdstill listed/best_bid_askand/product_bookas not implemented — family-wide defect sweep, Coinbase B3. Both were implemented and declared:experimentalincapabilities/0well 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_askwent 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_askhas no public form, verified live 2026-09-05, unlike/product_book, whosemarket/product_booktwin is real and public.usage-rules.md's "Streaming" section never said which symbol a delivered frame carries, and never mentionedresubscribe_interval_msat 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_mshas been a realFeedoption, 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_qualitynotice; one documentingresubscribe_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
:experimentaland 41:unsupported" with "38" of those the venue's own absence. Run against the realcapabilities/0(mix run -e, 2026-09-05): 48:experimental, 39:unsupported, of which 37 arevenue_does_not_serve/0(the other 2 are@not_ported,get_funding/2andget_contract_stats/2). Corrected to the measured numbers rather than re-guessed.frame_sender.ex's moduledoc claimedWebSockex.send_frame/2has "no way to override" its 5-second timeout. The vendored websockex 0.5.1 exposessend_frame/3with a timeout argument, so the claim was wrong.FrameSender.send/3still 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
tickerframe from the real venue failed to decode — 0Quotes 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-USDsymbols captured 500+ consecutivedata_qualitynotices and zeroQuotes in a 20-second window.build_quote/2readticker["time"]— a field that does not exist on the row. Confirmed against Coinbase's own CDP API reference for thetickerchannel, independently, twice: the timestamp lives on the message envelope ("timestamp", one per frame), never on the individualtickersrow. 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/2now reads the envelope's owntimestampand threads it down tobuild_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/2didn't read any venue timestamp — it substitutedDateTime.utc_now/0unconditionally, 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/3now reads the same envelopetimestampand fails closed if it's absent, same asbuild_quote/3— the maintained book state still updates either way, only the outgoing delivery is withheld.Does not, on its own, explain why
level2/OrderBookdelivered zero data in any of the three live tests run while chasing #22 — the oldDateTime.utc_now/0fallback always succeeded, so this was never why level2 was silent there. That remains open.A
level2capacity 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/2collapsed both into:credentials_rejected— the shape the original stub-token incident produced — which sent a consumer that finally wiredsubscribe_notices/1looking 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_rejectedbehavior.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
level2session 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/1scheduled every shard past the synchronous first one with the same fixed@shard_spacing_msdelay 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 turnposition * @shard_spacing_msafter 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/2crashed on a subscriber registered by name — DpCryptoManagement's issue #15.subscribe/2'sto:option accepts any value, andfan_out/2calledProcess.alive?/1on 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 toto:crash-looped the wholeFeedGenServer 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 answeredWebSockex.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. Fullfeed_test.exsrun time: ~45s → ~3s.to_order/1read bothOrder.quantityandOrder.filled_quantityfrom the same venue field — DpCryptoManagement's issue #12.order["filled_size"]populated both, so a fetched order'sremaining_quantity(quantity minus filled) was always zero, even for a genuinely open, partially-filled order — a correctness bug for anything reconciling open-order state.quantitynow reads the venue's own record of what was requested, fromorder_configuration's leafbase_size— the same fieldclosing_configuration/1already reads for a closing order's size, on the same response envelope. A quote-sized market order's leaf carriesquote_sizeinstead, with no rate here to convert it, soquantityisnilrather than a guess in that case.
Added
level2is subscribed and decoded —streamablegains:order_book. The channel was recognised and had working auth machinery since an earlier release but was never actually requested;capabilities/0said[:quotes]while the code that would have served:order_booksat unused.Socketnow maintains a real per-symbol book — snapshot then patched byupdatedeltas,new_quantity: "0"removing a level — and deliversCore.Types.OrderBooksorted 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
level2subscribe 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.Feednow opens one socket per 100 symbols (the number from that incident, carried over rather than re-derived), spaced to avoid a connect burst,level2subscribed beforetickeron each and the two spaced apart so a snapshot decode in progress does not turn atickersubscribe into asend_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.
Feedre-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.level2is skipped for a credential-less subscriber rather than failing loudly for no reason. It requires a credential andtickerdoes not; a caller with no credentials only ever wanted the public channel, and sending a doomed authenticated subscribe would either surfacecredentials_requiredas this call's synchronous result — masking thattickerworks fine — or cost a wire round trip to learn what the credential's absence already answers.
Documentation
- The
:unsupportedlist is now split.venue_does_not_serve/0names 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_portedbecause 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.mdstates what the contract covers — 46 of 87 callbacks:experimental, and points atnegative-claims.mdfor 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: falseandsupports_order_replace: falsewere 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. Andget_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.mdgains 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.mdgains a pointer to this package's ownusage-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, andplace_orders/3is 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 callsplace_order/3several times and reconciles the outcomes itself.
Added
Key permissions and the server clock —
get_roles/1,get_server_time/1and atest_connection/2that is no longer declared absent.can_transferis a separate permission fromcan_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/2asks 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/1returns 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_atisnil, 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/2and evenget_conversion/2re-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 tonil, never the nearest one.A portfolio is an address, not a value.
list_portfolios/1returns them,get_portfolio_breakdown/3returns what is inside one — a different and much larger answer — andcreate_account/1andrename_account/3reach 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/2was 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, involume_breakdownper volume type withadvanced_trade_only_volumeandcoinbase_pro_volumebeside 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/2carries bothfee_tierandfee_tier_without_promotion— they differ while a promotion is running, and it can end between two calls — and keeps the tax'sINCLUSIVE/EXCLUSIVEflag, because the same rate quoted either way is a different amount of money.US derivatives — the nine CFM endpoints.
get_positions/1andlist_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_balanceis the first,cbi_usd_balancethe second,total_usd_balancethe pair — and a caller sizing a futures position against the total is sizing against money that is not there. Every amount keeps itscurrency; flattening it off is how two currencies get added.:realised_pnlisnilon aTypes.Positionfrom this venue, and that is not an omission. Coinbase publishesdaily_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/1returns the venue's own row, where it keeps its own name, along withexpiration_time, whichTypes.Positionhas no place for either because a future expires and a perpetual does not.A sweep is scheduled, not settled.
schedule_futures_sweep/2queues a move out of the futures account andlist_futures_sweeps/2reports 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/2cancels the pending sweep and takes no id.INTRADAY_MARGIN_SETTING_UNSPECIFIEDis 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:UNSPECIFIEDis 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/2carries 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_typesgains:future.:perpstays absent: Advanced Trade's perpetuals are the INTX endpoints, which areAPPROVED-SKIPas 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, withstake/3andunstake/3now live on the facade.A different product, host and signing scheme. Everything else in this package talks to
api.coinbase.com/api/v3/brokerageand signs a CDP JWT; Prime talks toapi.prime.coinbase.com/v1and 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/3would 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/3andunstake/3follow only what the caller said — a:wallet_idmeans the wallet, its absence means the portfolio — andopts[: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/statusnames one wallet and is not "every staked position, one per asset" (reachable asPrime.staking_status/4); andclaim_rewardsis 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.StakingBalancebuilt 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}) andtransfer_internal/4(POST /portfolios/move_funds).A payment method's flags disagree with each other. Each row carries
verified,allow_depositandallow_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/3is 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/4moves 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, sinceDecimal.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/2is absent for a different reason worth stating./transaction_summaryexists 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, andRest.get_product/2for the whole record. Both were:unsupported.The venue names four increments and they are not interchangeable.
quote_incrementbounds the price andbase_incrementthe 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_sizeis units andquote_min_sizeis cash, and a market order sized in cash is bounded by the second where a limit order in units is bounded by the first.statusis 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/1reads 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/2already reads this payload and keeps only the newest print, because aQuotehas 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.brokenisfalseon every print — the ticker publishes no bust flag, and a venue with nothing busted reports nothing busted.get_historical_prices/4reads 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_bookfor a credential and/market/product_bookwithout 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'slimitandaggregation_price_incrementare 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.
sequencestaysnil— the endpoint publishes none, and a caller must not learn to detect stream gaps from a REST book.
Fixed
get_top_of_book/2now carries the sizes. It read/products/{id}/ticker, which publishesbest_bidandbest_askand nothing about how much is there — sobid_sizeandask_sizewerenilon every response.That
nilwas 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, andnilgave it no way to ask.An empty side is still
nilrather 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_typeis not decoration. Regular fills carryFILL; the venue also emitsREVERSAL,CORRECTIONandSYNTHETICfor adjusted ones, and a reversal is not a trade that happened.Core.Types.Fillhas 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 onlyFILLrows by default, andopts[: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_currencyisnilrather than the pair's quote guessed from the symbol — a fee can be charged in a third asset and often is.UNKNOWN_LIQUIDITY_INDICATORmaps tonil, because neither:makernor:takeris 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
cursorto a page bound.get_balances/2andget_accounts/2. The package could not say what the credential holds.The venue reports
available_balanceandholdand no total. The total here is their sum — arithmetic on two numbers the venue stated, not an estimate — and it isnilwhen 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_pagesbounds it, so a server that always sayshas_nexterrors rather than looping inside a facade call.get_accounts/2is 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.:timestampis when the request was made — a balance has no venue event time.convert/4andget_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 isquote_conversion/4and friends, scheduled separately. The one-stepPOST /conversionsbelongs to the Exchange API, a different product this package does not reach./products/volume-summaryis market volume and lives there too;get_trade_volume/2asks what this account traded, which Advanced Trade does not aggregate.preview_replace/4andclose_position/3. Two documented endpoints this package had no facade for.POST /orders/edit_previewprices an amendment before it is made. It is notpreview_order/3with 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 carriesaverage_filled_priceandorder_margin_total— numbers a fresh order does not have. It takes the same:price/:quantitychange setreplace_order/4does and refuses anything else before the request.POST /orders/close_positionflattens a position by having the venue place the closing order. The returnedOrdercarries no side. The venue never states one, and it worked the side out from a position this package did not read — filling in:sellbecause closing is usually selling is wrong exactly where it matters, on a short. The order type, time in force and size are read, from theorder_configurationthe venue echoes back, and a configuration key this package does not recognise leaves themnilrather than picking the nearest.
Fixed
cancel_all_orders/2is declared unsupported, with the reason checked.POST /orders/batch_canceltakes an explicitorder_idslist — it is the endpointcancel_order/3already uses, one id at a time. There is no "cancel everything" call here, and assembling one fromget_orders/2plus 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/4returnsCore.Types.Candle. It was returningQuotes withprice: 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
pricewas 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'sQuote, 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_timestamprather 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_previewandsupports_order_replacewere declaredfalseon those claims, and neither was checked against the venue's reference. Coinbase publishesPOST /orders/previewandPOST /orders/edit; both flags are nowtrueand 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/3builds the sameorder_configurationasplace_order/3, so a preview is a preview of the order that would actually be sent. A200carrying a populatederrsis a refusal — returning it as a successful preview would tell a caller its order is fine when the venue has already said otherwise. Awarningis passed through and does not make it a refusal.replace_order/4edits 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_cancelanswers with aresultsarray carrying its ownsuccessandfailure_reasonper id, so a200says 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_QUEUEDmaps 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/2filters 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 nolimit_limit_ioc, nomarket_market_gtc.A pair the venue does not name is refused before the request is sent. Sending
{:limit, :ioc}aslimit_limit_fokwould 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_onlyis omitted when unset rather than sent asfalse, because silence is not a decision to take liquidity.A
200carryingsuccess: falseis a refusal, not a placed order.client_order_idis 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.tsvand 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 noderibitin 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
553fa787with its working-tree state recorded, since the Coinbase subtree was dirty at extraction time.