Robinhood Crypto's REST surface — internal.
Every call is signed, including the quotes
There is no anonymous endpoint here. best_bid_ask needs the same Ed25519 signature as
an order, which is why this venue declares credential_benefit: :required and why
get_price/2 takes credentials.
There is no get_price/3 here, and that is deliberate
best_bid_ask returns a bid and an ask — the prices a taker would actually get — and
never a trade price. This module used to fill a quote's price from the ask when the
venue sent none. Core.Types.Quote's own moduledoc now names that incident directly as
the reason Quote carries no bid or ask at all: a package filling price from ask "is
exactly what one of them did." Removing the fallback was correct and left nothing here for
get_price/3 to honestly return — DpCryptoManagement's issue #21. The facade declares
get_price/2 :unsupported accordingly. bid and ask are both real and both carried,
through get_top_of_book/3.
v2's field names are not v1's, and this cost a working quote
v1's best_bid_ask (BidAskPrice) publishes bid_inclusive_of_sell_spread and
ask_inclusive_of_buy_spread, plus a computed price (their midpoint — still not a trade
price) and a timestamp. v2's best_bid_ask (V2BestBidAsk) is a different schema:
three fields only — symbol, bid, ask. No spread-inclusive names, no price, no
timestamp at all. This module calls v2 but, for one release, decoded v1's field names
against it — every real poll got 200 OK with a well-formed body and silently decoded
bid: nil, ask: nil, venue_time: nil every time, because row["bid_inclusive_of_sell_spread"]
is never present on a v2 row. Confirmed against the vendor's own OpenAPI document,
docs.robinhood.com/crypto/trading/, 2026-09-06: V2BestBidAskResponse.results is an
array of V2BestBidAsk, and V2BestBidAsk's only properties are symbol, bid, ask.
Reads row["bid"] / row["ask"] now. venue_time stays nil for this endpoint — not a
parse failure, but the honest answer to a field v2 never sends.
No candles, no order book, no volume
Robinhood Crypto publishes no historical-candle endpoint, no order book, and no volume on
the quote. Those are :unsupported — and that is the venue's shape, not a gap in this
package. Declaring them so lets a consumer route that work elsewhere instead of
discovering an empty series.
Summary
Functions
Base URL, overridable for tests.
Cancels an order — POST /api/v2/crypto/trading/orders/{order_id}/cancel/.
The crypto trading account — GET /api/v2/crypto/trading/accounts/.
Crypto holdings — GET /api/v2/crypto/trading/holdings/.
An execution estimate — GET /api/v2/crypto/trading/estimated_price/.
One order — GET /api/v2/crypto/trading/orders/{order_id}/.
Orders on one account — GET /api/v2/crypto/trading/orders/.
Every tradable pair, canonical.
Best bid and ask for symbol — the top of the book, not a traded price.
Best bid and ask for every symbol in symbols — one signed request, not one per
symbol.
Every tradable pair as a Core.Instrument — base, quote, instrument type and status —
from the same paginated trading_pairs endpoint get_symbols/2 already walks.
Places an order — POST /api/v2/crypto/trading/orders/. This moves funds.
Rounds a price and quantity to what the venue will actually accept, from the same
trading_pairs endpoint get_symbols/2 already calls.
Functions
Base URL, overridable for tests.
@spec cancel_order(map(), String.t(), keyword()) :: {:ok, DpExchange.Core.Types.Order.t()} | {:error, term()} | {:refused, term()}
Cancels an order — POST /api/v2/crypto/trading/orders/{order_id}/cancel/.
A POST, not a DELETE, and it takes no account number where every other order call does.
v2's cancel response is a full V2CryptoOrder, decoded the same way get_order/3 and
place_order/3 decode theirs — confirmed against the vendor's own OpenAPI document,
2026-09-06: 200 on /api/v2/crypto/trading/orders/{id}/cancel/ is
application/json against $ref: V2CryptoOrder, the identical schema get_order/3
reads. This function used to discard that body and return a fabricated stub with
status: :open hardcoded regardless of what the venue actually said — correct for v1's
cancel endpoint (text/plain, "Cancel request was submitted for order {id}", genuinely
no outcome), wrong for the v2 endpoint this module actually calls, which reports the
order's real state (open if the cancel is still in flight, canceled once it lands,
or filled/partially_filled if a fill won the race). Read that state rather than
assume it.
The crypto trading account — GET /api/v2/crypto/trading/accounts/.
The account number this returns is a parameter on almost everything else. v2 takes
account_number as a query parameter on holdings, on the order list, on one order, and on
placing one — where v1 took none. A caller that skipped this call has nothing to address
those with.
Returned as the venue's own map.
Deliberately does not walk next/previous, unlike get_symbols/2.
V2AccountsResponse carries the same cursor fields the trading-pairs response does, so
the shape supports paging. This does not follow it: one account per credential is this
venue's common case (a crypto brokerage account is singular by design), the risk of a
truncated result silently reads as "the credential's one account" either way, and walking
here would be undischarged complexity against a case that has never been observed. This is
a recorded decision, not an oversight — revisit if a credential is ever seen with more than
one page.
@spec get_balances( map(), keyword() ) :: {:ok, [DpExchange.Core.Types.Balance.t()]} | {:error, term()} | {:refused, term()}
Crypto holdings — GET /api/v2/crypto/trading/holdings/.
opts[:account_number] is required by v2 and refused here when missing rather than
sent: v1 took none and answered for the credential's own account, so a call without one
is a v1 habit that v2 will not honour.
Three quantities, kept apart. The venue publishes total_quantity,
quantity_available_for_trading and — where it holds any — an amount that is neither: a
balance in an open order is real and is not tradable. Types.Balance carries the total
and the available separately for that reason, and the difference is what is on hold.
opts[:asset_codes] narrows to particular assets; without it the venue returns all of
them.
@spec get_estimated_price( String.t(), String.t(), String.t() | [String.t()], map(), keyword() ) :: {:ok, map()} | {:error, term()} | {:refused, term()}
An execution estimate — GET /api/v2/crypto/trading/estimated_price/.
This endpoint moved between versions: v1 served it under marketdata, v2 under
trading. A package pointed at the v1 path gets a 404 that reads like an outage.
Not a quote and not a fill. It is what the venue estimates a given quantity would
execute at now, which is a different number from get_top_of_book/3's top of book —
the second price on this venue, and the only one that accounts for size. There is no
third: this venue publishes no last trade at any endpoint, which is why the facade's
get_price/2 is :unsupported (see this module's moduledoc).
side is the venue's own bid, ask or both. Several quantities can be asked at once:
the venue takes them comma-separated, and asking for 0.1,1,10 in one request is how a
caller sees the slope rather than three points taken at three times.
@spec get_order(map(), String.t(), keyword()) :: {:ok, DpExchange.Core.Types.Order.t()} | {:error, term()} | {:refused, term()}
One order — GET /api/v2/crypto/trading/orders/{order_id}/.
opts[:account_number] is required by v2.
@spec get_orders( map(), keyword() ) :: {:ok, [DpExchange.Core.Types.Order.t()]} | {:error, term()} | {:refused, term()}
Orders on one account — GET /api/v2/crypto/trading/orders/.
opts[:account_number] is required by v2. opts[:created_at_start] and the venue's other
filters are passed through under its own names, and none is defaulted — a start date
chosen here would return a real list of orders over a window the caller did not ask about.
This does not page. The venue returns a cursor and get_symbols/2 walks one for the
catalogue; an order list is a different case — a caller filtering by date wants the page
it asked for, and following the cursor silently would fetch a history it did not.
opts[:cursor] continues where the caller decides to.
@spec get_symbols( map(), keyword() ) :: {:ok, [String.t()]} | {:error, term()} | {:refused, term()}
Every tradable pair, canonical.
Calls /api/v2/crypto/trading/trading_pairs/ (D5). The endpoint paginates, so this
walks it. v2's response shape is identical for this purpose — results rows carrying
symbol, and a next cursor — which is why this half of the v2 migration was safe to
make and the quote half was not; see this module's moduledoc.
Measured by the prior adapter on 2026-08-05 against v1: 86 symbols, every one quoted in USD — as seen by that credential. Listings can differ by account tier, so a consumer holding a different key may see a different catalogue, and the figure has not been retaken against v2.
@spec get_top_of_book(String.t(), map(), keyword()) :: {:ok, DpExchange.Core.Types.TopOfBook.t()} | {:error, term()} | {:refused, term()}
Best bid and ask for symbol — the top of the book, not a traded price.
Reads v2's best_bid_ask, the only quote-adjacent endpoint this venue serves. v2's
response (V2BestBidAsk) is three fields: symbol, bid, ask — not v1's
spread-inclusive names, and no timestamp. Carried as sent rather than adjusted back to
a raw book.
This is the whole of what best_bid_ask gives: no trade price. See the moduledoc on why
there is no get_price/3 reading this same payload, and on the v1/v2 field-name defect
this function used to carry.
One symbol, one signed request. See get_top_of_book_bulk/3 for the repeatable-symbol
form this endpoint also serves, which Feed uses as its normal path and falls back to
this function per symbol only when the bulk call is itself refused.
@spec get_top_of_book_bulk([String.t()], map(), keyword()) :: {:ok, [DpExchange.Core.Types.TopOfBook.t()]} | {:error, term()} | {:refused, term()}
Best bid and ask for every symbol in symbols — one signed request, not one per
symbol.
best_bid_ask's symbol query parameter is documented as repeatable —
?symbol=BTC-USD&symbol=ETH-USD — and V2BestBidAskResponse.results is a plain array of
V2BestBidAsk rows, one per symbol the venue answered for. Confirmed against the
vendor's own OpenAPI document, docs.robinhood.com/crypto/trading/, 2026-09-06. See
docs/design/ideas/bulk-best-bid-ask-fetch.md for why this package did not use it
sooner, and Feed for how a whole-batch refusal is handled without guessing at venue
semantics the vendor's document never states.
A results array shorter than symbols is not this function's problem to solve.
Every row present is mapped; every symbol absent from results simply is not in the
returned list — the caller (Core.PollingFeed, via Feed) already treats "asked for but
not in the response" as uncovered-and-retried, never as a refusal, and this function does
nothing that would turn silence into a statement (see first_result/1 below for the same
principle on the single-symbol path, and DpCryptoManagement issue #25 for what mistaking
the two costs).
An empty symbols list returns {:ok, []} without a network call: the venue's own
document does not say what a best_bid_ask request carrying no symbol parameter at all
does, and there is nothing to ask for.
@spec list_instruments( map(), keyword() ) :: {:ok, [DpExchange.Core.Instrument.t()]} | {:error, term()} | {:refused, term()}
Every tradable pair as a Core.Instrument — base, quote, instrument type and status —
from the same paginated trading_pairs endpoint get_symbols/2 already walks.
get_symbols/2 extracts only symbol and discards the rest; this reads asset_code
and quote_code off the same rows for base and quote, never parsed back out of the
canonical symbol string. Every row is :spot — Robinhood Crypto's trading-pairs
endpoint lists no other instrument type.
@spec place_order(map(), map(), keyword()) :: {:ok, DpExchange.Core.Types.Order.t()} | {:error, term()} | {:refused, term()}
Places an order — POST /api/v2/crypto/trading/orders/. This moves funds.
client_order_id is generated here when the caller does not supply one, and it is an
idempotency key. Re-sending the same one returns the original order instead of placing a
second; a caller retrying a request whose response it never saw should pass the same id
rather than let a new one be made, which is why the option exists.
The order's configuration goes under a key named after its own type — market takes
market_order_config, limit takes limit_order_config, and so on. This package builds
that key from the type rather than taking it from the caller: a config under the wrong key
is silently ignored by the venue and the order is placed with none.
symbol, side, order_type and a quantity are required. The quantity goes in as
asset_quantity — the venue's own field — and a limit order also needs limit_price.
@spec quantization(String.t(), map(), keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Rounds a price and quantity to what the venue will actually accept, from the same
trading_pairs endpoint get_symbols/2 already calls.
get_symbols/2 extracts only symbol from each row and discards the rest —
asset_increment, quote_increment, max_order_size and min_order_amount are real
fields on V2TradingPair (Robinhood's own OpenAPI schema, docs.robinhood.com), not
invented here. min_order_size is absent from the schema itself despite being named in
the prose beside estimated_price ("quantity must be between min_order_size and
max_order_size as defined in our Get Crypto Trading Pairs endpoint") — the vendor's
own documentation names a field its own schema does not define. Carried as nil rather
than guessed at from min_order_amount, which is a cash minimum, not a unit minimum.