DpExchange.Robinhood.Rest (DpExchangeRobinhood v0.2.3)

Copy Markdown View Source

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.

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(opts)

@spec base_url(keyword()) :: String.t()

Base URL, overridable for tests.

cancel_order(credentials, order_id, opts)

@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.

get_accounts(credentials, opts)

@spec get_accounts(
  map(),
  keyword()
) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}

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.

get_balances(credentials, opts)

@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.

get_estimated_price(symbol, side, quantity, credentials, opts)

@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.

get_order(credentials, order_id, opts)

@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.

get_orders(credentials, opts)

@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.

get_symbols(credentials, opts)

@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.

get_top_of_book(symbol, credentials, opts)

@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.

list_instruments(credentials, opts)

@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.

place_order(credentials, request, opts)

@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 typemarket 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.

quantization(symbol, credentials, opts)

@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.