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 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.
[Unreleased]
Removed
SymbolFormat.mapping/0— dead code, and a breaking change if anything outside this package called it. Found by Core's new "16. internal wiring" conformance assertion (DpExchange.Core.UnwiredCheck), which reads the real:xrefcall graph restricted to this package's ownlib/— a test calling a function does not count as wiring it, which is deliberate: it is the same shape asrate_limit_blockingnever being set by its caller (issue #16), the defect the assertion exists to catch family-wide.mapping/0had no caller anywhere inlib/:to_canonical_symbol/1andto_exchange_symbol/1read the@mappingmodule attribute directly,quotes/0reads@mapping.quotesdirectly, andCore.AdapterContract's conformance suite — despite this function's own doc claiming it exists "so the conformance suite can driveCanonicalPairwith it" — never calls.mapping()on a venue'sSymbolFormatmodule; it only ever callsto_canonical_symbol/1andto_exchange_symbol/1. Core's own reference pattern inusage-rules/symbols.mddoes not expose amapping/0either. A vestigialdef-exposed getter over a module attribute the real code never reads through it.
Fixed
Auth.headers/5recomputed the signed payload with its own second copy of the concatenationpayload/5already implements, rather than callingpayload/5. Also found by the "16. internal wiring" assertion:payload/5had no caller inlib/, only inauth_test.exs. The two copies agreed byte-for-byte — confirmed both against each other (the test atauth_test.exs:52already verified a signature produced byheaders/5against a payload reconstructed bypayload/5) and against the vendor's own documentation,docs.robinhood.com/crypto/trading/→ Authentication → Headers and Signature, fetched live 2026-09-06:message = f"{api_key}{current_timestamp}{path} {method}{body}", method uppercase, path including the query string, and the reference implementation Robinhood links from that page passesbody=""for a bodyless request — the same empty-string-in-the-concatenation this package always did, not the literal omission the page's own prose says in passing (concatenating""and omitting it produce the identical string, so there was never a behavioural difference either way). So this was not the "two implementations disagree" defect the internal-wiring check exists to catch on a signing path — a signature-correctness bug — but the hand-kept duplicate was still a landmine waiting for the day the two drifted.headers/5now builds its signed string by callingpayload/5, so there is exactly one implementation of the venue's signature ordering, and it is the one both the header path and the test suite exercise. No change to any wire behaviour — every existing signature-shape test passes unchanged.get_top_of_book/2decoded v1's field names against the v2 endpoint this package actually calls, and every real poll silently returnedbid: nil, ask: nil— the venue's entire quoted-price surface, and the whole data path behindsubscribe/2. Found during a bug audit, 2026-09-06, and confirmed against the vendor's own OpenAPI document atdocs.robinhood.com/crypto/trading/, fetched live the same day: v1'sbest_bid_ask(schemaBidAskPrice) publishesbid_inclusive_of_sell_spreadandask_inclusive_of_buy_spread; v2'sbest_bid_ask(schemaV2BestBidAsk, whatRest.get_top_of_book/3has always called) is a different, three-field schema:symbol,bid,ask.row["bid_inclusive_of_sell_spread"]is never present on a v2 row, sodecimal/1correctly returnednilfor it, every time — a200 OKwith a well-formed, plausible-lookingTopOfBookstruct on every poll, which is exactly why nothing caught it:Core.PollingFeedcounts any{:ok, event}as a delivery regardless of which fields arenil, so this never tripped the "delivering nothing" escalation, and every existing test matched the delivered struct only bysymbol, never bybid/ask.Now reads
row["bid"]/row["ask"].venue_timestaysnilfor this endpoint — not a parse failure, but the honest answer to atimestampfield v2 never sends at all (v1's did). Every test fixture acrossrest_test.exs,feed_test.exsandtrading_test.exsthat built a v1-shapedbest_bid_askbody against the v2 path is rewritten to the real v2 shape, and the delivery tests now assert actualbid/askvalues rather than only the struct'ssymbol— the exact gap that let this ship. This is the same defect class as the wrongtime_in_forcefield fixed earlier (a venue-schema mismatch invisible to tests because the fixtures encoded the same wrong assumption as the code), on the venue's most load-bearing endpoint.Affected published versions:
0.1.11through0.1.17inclusive. The v1→v2 URL switch landed inea25ffb, the commit immediately before the0.1.11release, and the decoder was never moved with it. A consumer that polledtop_of_bookon any of those versions received aTopOfBookwhosebid,askandvenue_timewere allnil— so any stored history written from this venue over that range holds no prices and cannot be repaired from the package side. It is stated here rather than only in the fix description because a consumer streaming this into a time-series store has bad rows already written, and nothing in an upgrade tells them which ones.cancel_order/3discarded the venue's real response and always returned a fabricatedstatus: :open, regardless of what the venue actually said. Confirmed against the vendor's own OpenAPI document, 2026-09-06: v1's cancel endpoint (POST /api/v1/.../cancel/) really does answer with a baretext/plainacknowledgement and no order data, which is what the discarded-body behaviour was originally correct for. v2's cancel endpoint — the one this module calls — is different:200returnsapplication/jsonagainst$ref: V2CryptoOrder, the identical schemaget_order/3reads. A cancel that lands returnsstate: "canceled"; one that loses a race to a fill returns the fill's own state. Hardcoding:openwas silently wrong for either outcome the moment the v1→v2 migration happened.cancel_order/3now decodes the response withto_order/1, exactly likeget_order/3andplace_order/3.Fake.cancel_order/3moves from:opento:cancelledto match — the real venue's ordinary case for a call that succeeds — and both facade tests and the fake-injection suite are updated.Fake.quantization/1could not be called the way the real facade'squantization/2is, and never checked credentials at all. Every other real, successful-path function on this venue'sFake(get_top_of_book/2,get_symbols/1,list_instruments/1) gates onauthenticated/1, matching the real venue signing every call.quantizationdid not: it took nooptsparameter at all, so a caller reaching it the way production code reachesDpExchange.Robinhood.quantization/2— withcredentials:inopts— gotUndefinedFunctionError, and a caller invoking arity 1 got an unconditional success no credentials could have produced against the real venue. Both are the "differently capable" defectusage-rules/testing.mdwarns about. Nowquantization/2(opts defaulting to[], so the old arity-1 call still works), authenticated the same way its siblings are.
Documentation
A documentation-only sweep for claims the code contradicts, 2026-09-06. Nine false claims, none of which changes behaviour:
README.md's usage example did not work.get_top_of_book("BTCUSD", …)andsubscribe(["BTCUSD"])used a separatorless symbol, and canonical form here isBASE-QUOTE.CanonicalPair.to_exchange/2on asep: "-"mapping splits the canonical string on-and finds none, so"BTCUSD"goes to the venue as"BTCUSD-"— a malformed symbol, not a working call. Both examples now read"BTC-USD".README.mdclaimed the conformance suite passes "against Robinhood's live public endpoints." There are none: every endpoint on this venue requires a credential, which is exactly whydocs/reference/robinhood/endpoint-inventory.mdrecords that "no tier-2 test exists here."CLAUDE.md,test_helper.exsand.env.samplecarried the same claim in three more shapes, including amix test --include tier2command for a tag nothing intest/sets..env.sampledescribed a different venue. It named Gemini's OAuth flow, a "WEBULL App Key", and aDpExchange.Gemini.get_price(…, environment: :sandbox)example — and said "every endpoint that would need [a credential] is declared:unsupported", which is the opposite of this venue, where every implemented endpoint needs one.usage-rules.mdlistedvolumeon a quote as "alwaysnil." This package never returns aCore.Types.Quoteat all sinceget_price/2became:unsupported. The row now names what a caller does receive:bid_size/ask_sizeon aTopOfBook, alwaysnilbecausebest_bid_askpublishes no size.usage-rules.mdsaidvenue_timeisnil"when the venue's row has no readable timestamp," implying it is sometimes present.V2BestBidAskhas notimestampproperty at all, so it isnilon every book.Feed's moduledoc said a "direct one-offget_price/2call" goes throughRest's forwarded-options allowlist.get_price/2returns{:error, :not_supported}at the facade and never reachesRest; the one-off call that does isget_top_of_book/2.Rest.get_estimated_price/5's doc called it "a different number fromget_price/3's last trade." There is no last trade on this venue and noget_price/3anywhere —usage-rules.mdalready said "two prices … there is no third," and the facade's ownget_estimated_price/5doc still said "third." Both corrected.- Wrong arities on six real cross-references.
Rest's docs called its ownget_symbols/2andlist_instruments/2"/1" (the facade's arity), the facade called its ownget_symbols/1"/2" (Rest's arity), and the{:get_price, 2}entry in@venue_does_not_servesaidget_price/3. config/*.exsanddocs/design/README.mdnamed the wrong package —:dp_exchange_geminianddp_exchange_core— andruntime.exscalled this a "contract library" that "opens no sockets" whose seam:rate_limit_moduleit reads; that key is read bydp_exchange_coreunder its own app name.README.mdtold a consumer to pin~> 0.1.0whilemix.exsis at0.2.1— a constraint that cannot resolve the current package. Now~> 0.2.0, in both the banner and thedepssnippet..gitignoreignoreddp_exchange_coinbase-*.tarin this repo, so a built Hex tarball here was not ignored at all. Corrected to this package's own name.
time_in_forceis not symmetric between placing an order and reading it back, and several comments and tests claimed it was. Confirmed against the vendor's own OpenAPI document, 2026-09-06: on the REQUEST side,AddOrderV2.limit_order_config,.stop_loss_order_configand.stop_limit_order_configall carrytime_in_force— true, and what the request-building code already did correctly. On the RESPONSE side,OrderResponse.limit_order_confighas notime_in_forceproperty at all; only.stop_loss_order_configand.stop_limit_order_configecho it back. A limit order'stime_in_forceis therefore knowable from theplace_order/3call that set it, never from re-reading the order —get_order/3andcancel_order/3honestly decodenilfor it, always, on a limit order, which the decoder already did correctly; only the comments andusage-rules.mdclaimed otherwise, and several tests exercised the decoder against alimit_order_configfixture carryingtime_in_force, a shape the real venue never sends. Rewritten to usestop_loss_order_config/stop_limit_order_configfor the decode-side tests, with a new test asserting the limit-ordernilcase directly.This feed's own moduledoc said the venue "publishes no bulk-stats endpoint." That is not what the vendor's document says.
best_bid_ask'ssymbolquery parameter is documented as repeatable (?symbol=BTC-USD&symbol=ETH-USD, one signed request, oneresultsarray covering every symbol asked for) — confirmed 2026-09-06.Core.PollingFeed's own moduledoc names Robinhood as the intended user of its:fetch_allmode for exactly this shape. Not adopted here yet::fetch_allhas no refusal path inCore.PollingFeedtoday, and the vendor's document does not say what a batched call does when one symbol in it is invalid — guessing wrong would turn one bad symbol into a feed-wide crash loop, worse than today's per-symbol design. Recorded asdocs/design/ideas/bulk-best-bid-ask-fetch.mdrather than implemented as a guess.
Added
This feed now says out loud when it has delivered nothing, not only to a log a human has to go grepping for — DpCryptoManagement's issue #21, closed at the source. Issue #21 was itself a wrong credential (ciphertext where a key belonged) making every fetch fail, every cycle, for a whole deployment, with the only trace
Core.PollingFeed's ownLogger.warning— "has delivered NOTHING in 154 consecutive attempts" — a sentence nobody was watching for at the time. That earlier fix (below,get_price/2→:unsupported) closed the specific cause; it did nothing about the reporting gap, which is a defect on its own: any future outage of this feed, for any reason at all, would have been exactly as invisible.dp_exchange_core0.1.50 closes the reporting gap in the contract itself:PollingFeed.start_link/1gained an:on_noticeoption, called with a%Core.Notice{kind: :coverage_change}the instant the feed crosses INTO delivering-nothing (severity: :warning) and a second time the instant it crosses back OUT (severity: :info, message naming how many consecutive failures preceded recovery). It fires once per transition, never once per failed tick and never once per sweep while an outage continues — an 86-symbol feed retrying every symbol every cycle does not turn one outage into a notice storm.:on_noticedefaults to a no-op, so the option existing upstream was not itself a fix; a venue has to wire it.This package now does:
Feed.start_link/1passeson_notice: fn notice -> send(parent, {:dp_exchange, :robinhood, notice}) end, the same shape and the same destination as the existingon_refusalwiring right next to it. The dependency floor moves to~> 0.1.50so this cannot compile against a Core that lacks the option. Correction, below: this entry originally said the fixedsubscriber:was the whole integration and thatsubscribe_notices/1was staying a documented no-op "for exactly that reason" — that reasoning did not survive a closer look at what a caller ofsubscribe_notices/1actually got, which was nothing, ever, from a different pid than the one named at boot. See the entry below, same day.Three new tests in
feed_test.exsprove the wiring end to end against a realFeedprocess and a forced-failingplug:, not againstPollingFeedin isolation (its own latching logic isdp_exchange_core's to cover): a single failed fetch on this venue's one-symbol-per-request feed already crosses the threshold, so the warning notice is deterministic on the very first tick; repeated failures across several more ticks do not produce a second notice; and a plug that fails twice then succeeds produces exactly oneseverity: :info"has resumed delivering after 2 consecutive failures" notice afterward.retry_attempts: 0is set in these tests specifically —HttpClient's default retry backoff (up to ~3s per failed attempt) would otherwise make the notice arrive well after a boundedassert_receive, for a reason having nothing to do with the behaviour under test.subscribe_notices/1registered a caller and threw the registration away — a same-day defect in theon_noticewiring above, not a separate incident. The facade answered:okunconditionally and never touched the feed at all: a consumer callingDpExchange.Robinhood.subscribe_notices(to: monitoring_pid)got:okback and then nothing, ever, because the only pid the feed ever sent aCore.Noticeto was the fixed:subscribernamed atstart_link/1. That distinction matters more now that a real notice — the coverage-outage pair above — actually travels this path: a monitoring process kept separate from the data-consuming one, which is an ordinary shape for a consumer to choose, silently received none of it.DpExchange.Robinhood.Feedgained a genuinenotice_subscribersregistry — the same shapedp_exchange_schwab's ownFeedalready uses for its Streamer and fallback-poll notices — rather than declaring the single-fixed-subscriber design permanent.Core.PollingFeedwas not changed and was not fought: it still injects exactly onesink, oneon_refusaland oneon_noticefunction, by design (see its own moduledoc on why that shape is deliberate); the fan-out that turns "one recipient" into "a set of recipients" lives one layer up, in this module, which is the layer that actually knows who is registered. Doing that required turningFeedinto aGenServerin its own right — it used to simply be thePollingFeedprocess, registered under this module's name, with nowhere of its own to keep a set.PollingFeednow runs as an unnamed child thatFeedholds a reference to, the same relationship Schwab's ownFeedalready has with its Streamer and fallback poll.DpExchange.Robinhood.subscribe_notices/1now calls through to that registry and answers{:error, :feed_not_started}when the feed is not running, matchingsubscribe/2andupdate_symbols/2rather than the blanket:okit answered before regardless of whether anything was listening. Registration is additive: the fixed:subscriberfromstart_link/1keeps receiving notices exactly as before, so an existing consumer that never callssubscribe_notices/1sees no change.A second, smaller fix rode along:
send/2to an unregistered atom raises, so ato:given as a registered name that later died would have crashed this feed on its next notice.fan_out/2resolves every recipient — the fixed subscriber included — before sending, and skips one that no longer resolves, the same fix already shipped indp_exchange_schwabanddp_exchange_coinbasefor the identical shape (DpCryptoManagement issue #15).A new test in
robinhood_test.exsregisters a subscriber through the facade call itself, not by passing:subscribertoFeed.start_link/1directly, drives the feed into the same delivering-nothing state theon_noticetests above use, and asserts the notice actually reaches that facade-registered pid — the exact call the false:okused to accept and discard.coverage_by_kind/1implemented —dp_exchange_core0.1.48's optional callback, wired for family-wide consumer tooling even though this venue cannot reproduce the defect the callback exists to catch.coverage/1reports what is observed arriving, truthfully, but collapses every kind of payload into one boolean — on a venue streaming several kinds behind one subscription, that hid a dark channel behind a healthy one for days (Coinbase'slevel2-vs-tickerincident,dp_exchange_core'sVenue.coverage_by_kind/1moduledoc has the full writeup). Robinhood streams exactly one kind,:top_of_book, delivered by poll —Feed's fetcher isRest.get_top_of_book/3, which returns exclusivelyCore.Types.TopOfBook.t(), neverCore.Types.Quote.t(), because this venue has no last-trade endpoint at all. So the honest, structurally-derived answer is a single-key map,%{top_of_book: coverage(opts)}— not detecting a discrepancy that cannot occur here, but giving the family's tooling the same shape every venue answers. Wired onFeed, the facade andFake; the dependency floor moves to~> 0.1.48so this cannot compile against a Core that lacks the callback. New tests assert a delivering symbol appears under:top_of_book, the union invariant againstcoverage/1holds, the reported kind is declared incapabilities().streamable, and the map carries exactly one key — and the conformance suite's assertion group 15 ("coverage by kind"), previously skipped for every venue that had not adopted the callback, now runs against this package and passes.list_instruments/1is implemented — it was one query away, not a new endpoint.get_symbols/1already walked every page oftrading_pairsand discarded everything butsymbol;quantization/3already read the richer fields off the same rows. This reuses the same walk and mapsasset_code/quote_codestraight toCore.Instrument'sbaseandquote— never parsed back out of the canonical symbol string, matching howdp_exchange_coinbasebuilds the same struct. Every row is:spot, the only instrument type this venue's trading-pairs endpoint lists. Moved out of@not_ported, whose comment had called this "reads only the symbols" — true ofget_symbols/1, never a reason the richer fields couldn't be read too.capabilities/0now declares it:experimentalinstead of:unsupported.
Fixed
An empty
trading_pairs/best_bid_askpage was read as the venue stating a symbol does not exist, and it is not that — DpCryptoManagement's issue #25, measured on the reporting consumer's own production node.first_result/1turned any 200 response whoseresultsarray happened to be empty into{:refused, :not_listed}, andCore.PollingFeed's own contract reports a refusal exactly once and never retries it — so a transient empty page, indistinguishable at the HTTP layer from "genuinely not listed," became a permanent catalog verdict. Measured: 83 refusals held on one deployment, 56 of them{:refused, :not_listed}for pairs that answer normally on the very next call —BTC-USD,ETH-USD,LTC-USD,LINK-USDandDOGE-USDamong them. Clearing only those 56 took that consumer's collection scope from 5 pairs to 63, 62 of them fresh within 60 seconds. 92% of this venue's collection was suppressed by an inference the venue never made.first_result/1now returns{:error, :empty_result}for an empty page — retryable, the same shape a 500 already produces — while{:refused, :not_listed}stays exactly where the venue actually says so: a 400/401/403/404 carrying a body, handled byrefusal/2on the HTTP status rather than on the shape of a 200. The other 27 of the 83 held refusals were genuine venue statements this way ({:venue_error, 400, "Invalid symbol: ALGO-USD"}) and are unaffected.get_top_of_book/3andquantization/3, the two callers, both change;get_symbols/1's pagination walk never went throughfirst_result/1and was never affected. New tests inrest_test.exs,defensive_branches_test.exsandrobinhood_test.exsfail against the prior code and pass against this one, including one that runsFeedend to end against a perpetually-empty page and asserts no{:refused, ...}message ever reaches the subscriber.
Documentation
usage-rules.md'stime_in_forcesection still taught the pre-C7 vocabulary after the code moved past it. Thegfw/gfmfix below extendedcapabilities().supported_time_in_forceto all four of the vendor's documented values and updatedRest's own moduledoc, butusage-rules.md— the file that actually ships inside the Hex tarball and is what a consuming agent reads — still said "this package supports:gtcand:day" and describedgfw/gfmas decoding tonilfor "a value this package has no atom for yet," which stopped being true the moment Core0.1.45landed. A consuming agent reading only this file would have believed two of the four vendor values it could actually place were unsupported. Rewritten to name all four and to carry the one-releasenilhistory forward as what it now is — closed, not current.Audited
README.mdandusage-rules.mdend to end against live execution rather than by eye:capabilities().endpointsconfirmsget_price/2 => :unsupportedand every other maturity the docs claim;subscribe/2's delivered struct was confirmed againstFeed's poll (Rest.get_top_of_book/3constructs%Core.Types.TopOfBook{}) and againstfeed_test.exs's ownassert_receive; and all fourtime_in_forcevalues were round-tripped live throughplace_order/3and a decoding response, none dropped. Theget_price/2/ ask-fallback docs this audit was chartered to re-check (R1 indp_exchange_core's2026-09-05_family-wide-defect-sweep.md) were already correct — no staleQuote-delivery or ask-fallback text remained.Also found and fixed, unrelated to
time_in_force:README.md's own usage example calledsubscribe(["BTCUSD"], to: self()), but the real facade'ssubscribe/2reads no:tooption at all — onlyFake.subscribe/2(a test double) honours one. On the real venue the subscriber is fixed once, at supervision start, viasubscriber:in the child spec (usage-rules.md's own example already did this correctly). The README example ran without error but silently did not do what it implied — ato:a reader would reasonably expect to redirect delivery per call had no effect. Rewritten to matchusage-rules.md's pattern:subscriber: self()on the child spec, plainsubscribe(["BTCUSD"])on the call.
Fixed
gfwandgfmnow round-trip too, closing the one gap left open by the entry below. Those two of the vendor's four documentedtime_in_forcevalues decoded tonilfor a single release, because Core's vocabulary had no atom for "good for week" or "good for month". They were deliberately left asnilrather than invented locally or mapped to a nearest-match value — a wrong atom on a real order is worse than an absent one, and declaring support this package could not honour would have been the same untrue claim as the empty list it replaced, pointing the other way.dp_exchange_core0.1.45 added:gfw/:gfm, so the gap is closed: all four values the vendor's enum documents now decode, andcapabilities/0declares all four. The dependency floor moves to~> 0.1.45so this cannot compile against a Core that lacks them. A new test walks the vendor's whole enum and asserts each value both decodes to a real atom and appears in the declaration — so a future vendor addition with no Core atom fails a test rather than quietly becomingnilon a live order.time_in_forceis wired, both directions — it is a real vendor field this package wrongly claimed absent. Confirmed against Robinhood's own OpenAPI schema:AddOrderV2.limit_order_config,.stop_loss_order_configand.stop_limit_order_config(the request side) and the matchingOrderResponseconfig objects (the response side — whatGET/POST /api/v2/crypto/trading/orders/actually return) all carrytime_in_force, enum["gtc", "gfd", "gfw", "gfm"].to_order/1hardcodednilwith a comment asserting the venue publishes none;order_config/2never built the key; andcapabilities/0leftsupported_time_in_forceat its empty default, hiding the gap a second time.order_config/2now acceptsopts[:time_in_force]of:gtcor:day(Core's existing atom for the venue'sgfd, "good for day") onlimit,stop_lossandstop_limitorders — the three the vendor's schema carries the field on;market_order_confighas no such field, so a market order never sends one. Anything this package cannot send is refused locally as{:error, {:unsupported_time_in_force, tif}}rather than silently dropped, which would have placed an order under an instruction the venue never received.to_order/1decodes the venue'sgtc/gfdback to the same atoms;gfw/gfmdecode tonilbecause Core'stime_in_forcevocabulary has no atom for either yet — Core is being extended with both in the same defect-sweep batch this fix belongs to, but this package cannot use them until that version reaches Hex (tracked indp_exchange_core'sdocs/design/2026-09-05_family-wide-defect-sweep.md§3).capabilities/0now declaressupported_time_in_force: [:gtc, :day].The
trading_test.exsfixture that assertedtime_in_force == nilunder a comment encoding the same wrong assumption as the code was rewritten to the vendor's real response shape, and five regression tests were added against it.v2's own fee fields were discarded —
to_order/1hardcodedfee: nilon the exact endpoint this package calls v2 in order to get fee data from.V2CryptoOrder(Robinhood's own schema for what the v2 order endpoints return) carriesfee_chargedandestimated_fee_remaining, both real numeric fields.to_order/1now decodesfee: decimal(row["fee_charged"]).fee_currencystaysnil— the vendor's schema states no currency for the figure, and assuming the pair's quote asset would be this package's own convention standing in for the venue's word, which this family's fail-closed rule refuses.estimated_fee_remaininghas no slot onTypes.Orderand is not decoded, with a comment saying why rather than inventing one.Also recorded, not fixed:
get_accounts/2reads only the first page ofV2AccountsResponse, which carries the samenext/previouscursorsget_symbols/2deliberately walks. Left un-walked as a documented decision inRest.get_accounts/2's own doc — one account per credential is this venue's common case, and walking would be complexity against a case never observed — rather than an undocumented inconsistency.
Documentation
usage-rules.mdandREADME.mdno longer teach the exact behaviour that caused DpCryptoManagement's issue #21.usage-rules.mdsaidsubscribe/2deliveredCore.Types.Quote(it deliversTopOfBookand always has, since theget_price/2fix below), carried aget_price/2usage example that crashes against the current{:error, :not_supported}return, and had a whole "The price is the ask" section describing the ask-fallback that caused issue #21 as if it were current behaviour.README.mdcarried the same brokenget_price/2example. Both are rewritten:get_price/2is documented as unsupported with the incident named directly — so a reader hits the explanation before re-filing #21 —get_top_of_book/2is documented as the real market-data call, andsubscribe/2is documented as deliveringTopOfBookover the internal REST poll. This file ships inside the Hex tarball and is what a consuming agent reads; perdp_exchange_core's ownCLAUDE.md, "it is not optional and it is not the README."usage-rules.md's "Timestamps come from the venue, or the call fails" section was wrong — a missing venue timestamp does not failget_top_of_book/2, and perCore.Types.TopOfBook's own contract it should not.Rest.top_of_book_time/1already swallowed a missing or unparseable timestamp intovenue_time: nil, whichrest_test.exsalready asserted; the doc was stale prose from before theQuote→TopOfBookmigration, where:timestampwas a required field. No code changed; the section is rewritten to say what the code actually does and why that is correct.
Added
Fakewired toCore.FakeInjection— DpCryptoManagement's issue #14, reference implementation for the family. 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/2andquantization/1support per-symbol targeting, andget_symbols/1,get_balances/2,get_accounts/2,place_order/3,cancel_order/3,get_order/3,get_orders/2andmarket_status/1support whole-call injection.authenticated/1also honoursFakeInjection.credentials_bypassed?/1, letting a wiring-only test skip the venue-faithful{:refused, :missing_credentials}default without changing it for anyone who doesn't callbypass_credentials/1.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.quantization/1is implemented. It had sat in@not_portedwith a comment already half-answering the question DpCryptoManagement filed (issue #5 againstdp_exchange_core): "trading_pairspublishes min/max order size and increments per pair" — true, and nothing read them.get_symbols/1extracts onlysymbolfrom each row and discards the rest.Verified against Robinhood's own OpenAPI schema before wiring anything:
V2TradingPaircarriesasset_increment,quote_increment,max_order_sizeandmin_order_amount.min_order_sizeis absent from the schema, despite different prose — besideestimated_price— naming it as if it existed.quantization/1'smin_quantityisnilrather than a guess built frommin_order_amount, which is a cash minimum, not a units one.
Fixed
get_price/2is now:unsupported— the ask-fallback removal earlier in this file left it permanently non-functional, and DpCryptoManagement's issue #21 is the live consequence: 154 consecutive failures, 0 successes, every poll cycle since boot.best_bid_ask— the only quote-adjacent endpoint this venue serves — carries onlybid_inclusive_of_sell_spread/ask_inclusive_of_buy_spread, never a trade price. Confirmed no last-trade endpoint exists anywhere on the venue's documented nine-operation surface (docs/reference/robinhood/negative-claims.md: "No public trade tape"), and thatestimated_priceis not a substitute — its own doc already said so ("Not a quote and not a fill"), and it needs a side and quantity picked for it, which is fabrication with extra steps.quoted_price/1requiredrow["price"], a field this response shape never carries, so removing the ask fallback (the correct call — seeCore.Types.Quote's own moduledoc, which now names this exact incident as whyQuotecarries no bid or ask at all) left nothing honest for it to ever return.get_price/2now returns{:error, :not_supported}unconditionally, moved intovenue_does_not_serve/0.Rest.get_price/3,quoted_price/1and the now-unusedrequired_decimal/2are removed rather than left dead.capabilities().streamablechanges from[:quotes]to[:top_of_book]—:top_of_bookwas always a real, precedenteddata_kind(Gemini already declares it) and this venue's whole "streaming" was already a REST poll internally, soFeed's poll now callsget_top_of_book/3instead of the brokenget_price/3and deliversCore.Types.TopOfBookinstead ofCore.Types.Quote— live bid/ask keeps flowing, honestly labelled, rather than the venue's whole quote stream going dark to avoid re-fabricating a trade price.Fakeupdated to match on both counts.Feednever actually reached blocking (acquire/3) rate limiting, despite its own moduledoc documenting exactly why it needs it — DpCryptoManagement's issue #16.:rate_limit_blocking— the optionCore.HttpClient.check_rate_limits/1reads to chooseacquire/3over fail-fastcheck/3— was missing from bothFeed.start_link/1's andRest.request_opts/1's own forwarded-options allowlists, so no caller could ever turn it on: every poll fell through tocheck/3regardless, and the exact failure the moduledoc describes (87 of 87 symbols delivering dropping to 8 of 87 in one cycle) reproduced live. Both allowlists now include it;Feed.start_link/1also defaults it totrue— a poll's whole reason to exist is this venue's rate limit, so a slower cycle rather than a missing price is the only correct default for it.Rest's own allowlist does not default it, since a direct one-offget_price/2call goes through the same code and fail-fast may be exactly what that caller wants.Decimal.new/1raised on a non-numeric price string — the same defect class filed againstdp_exchange_webullas DpCryptoManagement's issue #3. Auditing every copy of the raising pattern in this package found it here too, inrest.ex'sdecimal/1. Fixed withDecimal.parse/1, requiring the whole string be consumed — the idiom already established elsewhere in this family (chain_strike/1,ws_decode.ex).The lenient fix alone would have introduced a second, quieter defect: a malformed required field silently becoming
nilinstead of raising, which@enforce_keysdoes not catch.get_price/3now refuses the quote instead ({:error, {:invalid_decimal, :price, value}}), rather than delivering aQuotewith a fabricated-lookingnilin the field this venue's own usage-rules call the whole point of the endpoint.
Documentation
Every negative this package makes is audited —
docs/reference/robinhood/negative-claims.md, fifteen claims with the source and date behind each. Robinhood publishes five documentation pages in total and all five were read, which is what makes these negatives stronger than most: the corpus is small enough to exhaust.Fourteen hold. One was wrong, and it is the interesting one:
get_fees/2,get_transfers/2,get_trade_history/2andget_rate_limit_status/2sat in the "not ported" list — the one that means the venue serves this and we have not got to it. The venue serves none of them. That mislabel points the opposite way to a false:unsupported: it invents work that cannot be done, and quietly implies an endpoint the vendor does not publish. They now sit invenue_does_not_serve/0.docs/reference/robinhood/endpoint-inventory.mdmarks every operation implemented. It had recorded the family's sharpest coverage gap — "this package cannot trade a venue that can be traded" — and that gap is closed: all nine documented operations ship, on v2.usage-rules.mdcovers the v2 surface: the account number v1 did not need, the three prices and which one accounts for size, the order-config key named after the order type,client_order_idas an idempotency key, why a cancellation returns an open order, and whyholdisnil.
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.
Changed
convert/4andget_trade_volume/2(Core 0.1.22) are declared unsupported. The venue publishes neither a one-step conversion nor the two-step quote/commit pair, and no account-volume report. Summing fills here would be this package's arithmetic rather than the venue's ledger, which is the number its fee tiers actually come from.Core 0.1.21's three new callbacks are declared, and none of them exists here. Read from the venue's v2 reference, 2026-09-01: the crypto order surface is four calls — list, place, get, and cancel-one. There is no preview, no amend, no bulk cancel and no position-closing endpoint.
preview_replace/4,cancel_all_orders/2andclose_position/3returnnot_supported, and the enumeration behind that is indocs/reference/robinhood/.
Fixed
An ask is no longer used as a trade price.
get_price/3readprice || ask_inclusive_of_buy_spread, so a response carrying no traded price produced a quote whosepricewas the ask — a resting order, not an execution. Every value was real, so nothing looked wrong; only the meaning was. A response with no traded price now returns{:error, :no_trade_price_in_response}.This is a behaviour change for any consumer that was receiving those quotes: where a quote previously arrived carrying an ask, an error now arrives instead. That is the intended direction — a stop or a position value computed from an ask is wrong by the width of the spread, and worst exactly when the book is thin.
The test suite had asserted the old behaviour as intended, including a test named "the price is the ASK when the venue sends no separate price". It now asserts the opposite, and fixtures carry a traded price deliberately inside the spread and equal to neither side.
Changed
get_symbols/1calls/api/v2/crypto/trading/trading_pairs/. v2's response is the sameresults+nextshape, so this is a path change only.get_price/3stays on v1 deliberately. v2'sbest_bid_askdocuments its response as{"results": [{"symbol", "bid", "ask"}]}— top of book and nothing more. It carries no traded price and no timestamp, both of whichCore.Types.Quoteenforces. Representing top-of-book is a contract question for Core, not a path swap, and v1 remains documented and current in the meantime.
Added
The whole v2 surface — accounts, holdings, estimated price, the four order calls, and the market-data pair migrated from v1.
This package could not trade a venue that can be traded.
place_order/3was declared:unsupportedon a broker whose documentation publishes it — the sharpest single consequence of the coverage gap anywhere in this family, and it is closed.account_numberis a required query parameter in v2 on holdings, on the order list, on one order and on placing one, where v1 took none and answered for the credential's own account. A call without it is not a smaller answer, it is a rejection, so each refuses with{:error, {:account_number_required, :robinhood}}before a request is made —get_accounts/2is where the number comes from.estimated_pricemoved frommarketdatatotradingbetween the versions, and a package pointed at the old path gets a 404 that reads like an outage. It is the third price on this venue and the only one that accounts for size: notget_price/2's last trade and notget_top_of_book/2's top of book. Several quantities can go in one request, which is how a caller sees the slope rather than three points taken at three times.client_order_idis generated when the caller does not supply one, and it is an idempotency key. Re-sending the same one returns the original order rather than placing a second, so a retry of a request whose response was never seen should pass the same id — which is whyopts[:client_order_id]exists.An order's configuration goes under a key named after its own type —
market_order_config,limit_order_configand so on — and this package builds that key from the type rather than taking it from the caller: a config under the wrong key is silently ignored and the order is placed with none. A limit without a price, or a stop-limit without a stop, is refused by field name before the request.cancel_order/3returns an order whose status is:open. The venue acknowledges the request and reports no outcome, and telling a caller the order is gone invites a second order for the same exposure.get_order/3says whether the cancel took.Holdings keep the total and the tradable amount apart — the difference is a balance sitting in an open order — and
holdisnilbecause the venue publishes no such figure. Subtracting would state a number it never did.A state this package does not recognise maps to
nil, never the nearest: a caller branching on:filledmust not be handed it for a word that merely looked close.
Changed
best_bid_askandtrading_pairsmoved to v2, which is what D5 makes the surface. Both functions already existed and both were on v1, which is why their coverage boxes stayed open. A test now asserts that no/api/v1/path remains in the code: a path is the one thing in an HTTP call that cannot be verified by reading the response, and the v1 paths still work.Core dependency moves to
~> 0.1.35, and twelve further callbacks are declared absent with the reason. This is a crypto brokerage with no funding API: the vendor's crypto trading documentation publishes nine endpoints and none of them is a payment method, a transfer, an allowlist, a network list or a transaction ledger — money reaches the account through the Robinhood application, which needs a person. Checked against all five of the vendor's documentation pages on 2026-09-01.get_trades/2,get_auction_imbalance/2andget_volume_profile/3are declared unsupported. Read from the venue's v2 reference, 2026-09-01: the crypto surface is best bid/ask, estimated price, accounts, holdings, orders and trading pairs — no tape. A crypto book trades continuously, so there is no opening or closing auction to have an imbalance in, and the venue publishes no volume-at-price split. Not "unimplemented": there is nothing to implement.First release. Quotes and the catalogue behind
DpExchange.Core.Venue, with a feed. 108 tests including Core's 28 conformance assertions, passing first run.First release. Quotes and the catalogue behind
DpExchange.Core.Venue, with a feed. 108 tests including Core's 28 conformance assertions, passing first run.Ed25519 request signing, verified by checking that a signature this package produces verifies under the key the venue's own seed format derives. The signed payload is
api_key <> timestamp <> path <> method <> body, wherepathincludes the query string — the easiest thing to get wrong, and it fails as an unhelpful 401.A private key that is not the base64 32-byte seed the venue issues is refused with
{:invalid_private_key, …}rather than producing a signature the venue rejects silently.
This venue has no streaming API, and that is the point
subscribe/2is served by a REST poll throughCore.PollingFeed. What a consumer receives is identical to a socket venue's;coverage/1reports:internal_pollso the difference is visible as what is arriving, never as how.- Before the facade, that absence travelled upward: the collection layer kept a poll set and decided which venues were exempt, and an operations page described these pairs in terms of a socket the venue does not have — sending readers hunting a streaming fault that cannot exist.
Declared honestly rather than left to be discovered
credential_benefit: :required— every call is signed, quotes included.historical_timeframes: []— the venue publishes no candle endpoint at all. An empty list is the honest answer; a populated one with an:unsupportedendpoint behind it would be a declaration disagreeing with itself.- No order book, no volume.
volumeisnil, never0. venue_does_not_serve/0separates what the venue does not offer from what this package has not ported. Both answer{:error, :not_supported}, but only one of them might ever change.
Fixed, relative to the adapter this replaces
- A bar or quote with no venue timestamp now fails rather than being stamped with the
local clock. The prior decoder ended with
|| DateTime.utc_now(), which is the fourth venue in this family found carrying that same substitution. - The catalogue walk cannot loop. A cursor walk trusts the venue to stop saying "next";
if it ever points at a page already fetched, the caller hung forever with no error while
the venue took a signed request every few milliseconds. Now
{:error, {:pagination_loop, path}}. Found because a test hung.