Robinhood Crypto, behind the DpExchange facade.
⚠️ EXPERIMENTAL
This package has not run in production. While it is 0.x the API may change without a
major version — pin all three segments. Maturity is declared per endpoint through
capabilities/0; do not read this banner as your check.
This module is the entire public API of this package.
This venue has no streaming API, and you cannot tell
Robinhood Crypto publishes no socket. subscribe/2 is served by a REST poll inside this
package, and it delivers the same Core.Types.TopOfBook to the same subscriber as a
WebSocket venue would.
That is the sharpest test in the family of §6.0's claim that both endpoints always exist and a consumer never branches on transport. Before the facade, the 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 and has never claimed — sending readers hunting a streaming fault that cannot exist.
coverage/1 reports :internal_poll rather than :stream, which is the one place the
difference is visible — and it is visible as what is arriving, never as how.
Credentials are required for market data
Every Robinhood Crypto call is signed with an Ed25519 key, including the book. There is
no anonymous endpoint, so get_top_of_book/2 takes credentials:
{:ok, book} = DpExchange.Robinhood.get_top_of_book("BTC-USD", credentials: %{
api_key: "…", private_key: "…"
})You hold the credentials; this package signs one request with them and keeps nothing.
get_price/2 is :unsupported — this venue has no last-trade data at all
best_bid_ask is the only quote-adjacent endpoint this venue serves, and it carries only
a bid and an ask, never a trade price. This
package used to fill Core.Types.Quote.price from the ask when the venue sent none —
Quote's own moduledoc now names this incident directly as the reason Quote carries no
bid or ask at all. Removing that fallback was correct and left get_price/2 with no
honest number to return, ever: DpCryptoManagement's issue #21. There is no separate
last-trade endpoint to fall back to either — confirmed against the vendor's complete
nine-operation surface, see docs/reference/robinhood/negative-claims.md. bid and
ask are still real and live, through get_top_of_book/2 and the poll above.
No candles, no order book, no volume
The venue publishes none of them. get_historical_prices/4, get_order_book/2 and
volume are :unsupported — the venue's shape, not a gap here. Route that work elsewhere
rather than discovering an empty series.
Supervision
children = [{DpExchange.Robinhood, credentials: my_credentials(), symbols: ["BTC-USD"]}]
Summary
Functions
Cancels an order. A POST, not a DELETE, and it takes no account number.
coverage/1, split by kind — required family-wide so consumer tooling can call the
same function on every venue, even though this one has nothing to split.
The crypto trading account — and the account number every other v2 call takes.
Crypto holdings for one account.
An execution estimate for a given size.
One order. opts[:account_number] is required by v2.
Orders on one account. opts[:account_number] is required by v2.
Every tradable pair as a Core.Instrument — base, quote, instrument type and status.
Always :open. This venue's ONLY asset class trades continuously.
Places an order. This moves funds.
Not supported. Robinhood places one order per request.
Not supported. This venue publishes no order-preview endpoint.
Rounds a price and quantity to what the venue will actually accept.
The quote currencies this venue settles in.
Not supported. This venue has no atomic replace; a caller cancels and re-places.
Registers opts[:to] (default: the caller) for this venue's own notices — what the
feed is doing and what is going wrong with it, distinct from subscribe/2's market
data.
Endpoints the venue does not serve, as distinct from ones this package has not ported.
Functions
Cancels an order. A POST, not a DELETE, and it takes no account number.
See DpExchange.Robinhood.Rest.cancel_order/3 — the response is the venue's own
V2CryptoOrder, decoded the same way get_order/3's is, so the returned Order
reflects the venue's real state (:open if the cancel is still in flight, :cancelled
once it lands, or a fill if one won the race) rather than an assumed outcome.
@spec coverage_by_kind(keyword()) :: %{ required(DpExchange.Core.Capabilities.data_kind()) => %{ required(DpExchange.Core.Venue.symbol()) => DpExchange.Core.Venue.route() } }
coverage/1, split by kind — required family-wide so consumer tooling can call the
same function on every venue, even though this one has nothing to split.
Why this exists even where it cannot find anything
coverage/1 reports what is observed arriving, truthfully, but it collapses every
kind of payload into one boolean. On a venue streaming more than one kind behind a
single subscription, that hid a dark channel behind a healthy one for days — Coinbase
delivered order-book frames for hundreds of symbols while its last-trade channel sat
dark for all but a handful, and coverage/1 alone reported all of them healthy
because it counts any payload, regardless of kind. See
DpExchange.Core.Venue's moduledoc on DpExchange.Core.Venue.coverage_by_kind/1
for the incident in full.
Robinhood cannot reproduce that discrepancy, and this implementation does not pretend
otherwise. capabilities().streamable names exactly one kind, :top_of_book, and
this venue's feed delivers it by poll: Feed's fetcher is
Rest.get_top_of_book/3, which returns exclusively Core.Types.TopOfBook.t() —
never Core.Types.Quote.t(), because this venue has no last-trade endpoint at all
(see get_price/2 above). A single-key map is therefore the honestly derived shape
for a venue with exactly one delivery path, not a shortcut taken because there was
only one declared kind to wrap. Uniformity across the family is the point of
implementing this callback here — not detecting something that structurally cannot
happen on this venue.
The crypto trading account — and the account number every other v2 call takes.
Crypto holdings for one account.
See DpExchange.Robinhood.Rest.get_balances/2. opts[:account_number] is required by
v2 where v1 took none, and hold is nil because the venue publishes no such figure —
subtracting would produce a number it never stated.
@spec get_estimated_price( String.t(), String.t(), String.t() | [String.t()], map(), keyword() ) :: {:ok, map()} | {:error, term()} | {:refused, term()}
An execution estimate for a given size.
Venue-specific: the second of this venue's two prices, and the only one that accounts for
size — get_price/2 is :unsupported, so there is no third.
See DpExchange.Robinhood.Rest.get_estimated_price/5 — the endpoint moved from
marketdata to trading between v1 and v2.
One order. opts[:account_number] is required by v2.
Orders on one account. opts[:account_number] is required by v2.
See DpExchange.Robinhood.Rest.get_orders/2 — this does not follow the venue's cursor,
because a caller filtering by date wants the page it asked for.
Every tradable pair as a Core.Instrument — base, quote, instrument type and status.
See DpExchange.Robinhood.Rest.list_instruments/2: it walks the same paginated
trading_pairs endpoint get_symbols/1 already calls, reading asset_code and
quote_code off the same rows for base and quote rather than parsing them back out of
the canonical symbol string. Every row is :spot.
Always :open. This venue's ONLY asset class trades continuously.
Why this is answered locally, not fetched
asset_classes/0 is [:crypto] — the only asset class this package ever serves on
this venue — and crypto has no exchange-mandated trading session for market_status/1
to report on: there is no open, no close, no pre- or post-market, because there is no
market bell to ring in the first place. :open is therefore not this package's guess
at the venue's calendar, the substitution the family's own "fail closed" rule forbids —
it is the true and complete answer for the one asset class in scope, independent of
whatever the venue's API can or cannot tell this package.
Checked, not assumed. Robinhood publishes no market-status or trading-hours
endpoint at all — docs/reference/robinhood/endpoint-inventory.md records that every
one of the vendor's 9 documented operations is implemented here, and none of them is a
status or calendar call. There is nothing to call even if this answer needed one.
What would make this answer wrong
Two things, neither observable from this package today:
- This package ever serves a second asset class on this venue. The day
asset_classes/0stops being exactly[:crypto], this unconditional:openstops being a complete answer and must be revisited — seedp_exchange_webull, whose identical literal was wrong for four of its five asset classes. - A genuine trading suspension — the closest real-world analogue to a "closed"
market on a venue with no session calendar. Robinhood's crypto trading API
publishes no status feed this package could observe one on, so a live suspension
would surface here as failed order calls or a stalled poll, not as
market_status/1answering:closed— this callback cannot and does not claim to detect that. That is a different question from the one this callback answers (test_connection/2andget_rate_limit_status/2are the reachability ones), and conflating the two would be inventing a capability this package does not have.
Exempt from AdapterContract's credential gate, and why that is not special pleading
dp_exchange_core's assertion 17 skips this callback's credential check only when a
venue's asset_classes/0 is exactly [:crypto] — grounded in this callback's own
contract doc ("crypto venues answer :open"), not carved out per venue. See
DpExchange.Core.Venue's market_status/1 callback doc and AdapterContract's "17.
credential gate" comment for the argument, including why dp_exchange_schwab (not
crypto-only) stays gated on the identical callback.
Places an order. This moves funds.
See DpExchange.Robinhood.Rest.place_order/3. opts[:account_number] is required;
client_order_id is generated when the caller does not supply one and is an idempotency
key, so a retry of a request whose response was never seen should pass the same one.
Not supported. Robinhood places one order per request.
Its v2 order surface is four endpoints and none of them takes a list. A caller placing
several calls place_order/3 several times, and each carries its own client_order_id.
Not supported. This venue publishes no order-preview endpoint.
Declared through supports_order_preview: false, so a consumer routes around it rather
than discovering the refusal at call time.
Rounds a price and quantity to what the venue will actually accept.
See DpExchange.Robinhood.Rest.quantization/3 — in particular why min_quantity is
nil rather than a guess: the venue's own schema for this endpoint names no per-unit
minimum, despite a different page's prose naming one that does not exist on it.
@spec quotes() :: [String.t()]
The quote currencies this venue settles in.
Not supported. This venue has no atomic replace; a caller cancels and re-places.
That is not equivalent — it opens a window in which no order is live — which is why
supports_order_replace: false is a claim about risk rather than convenience.
Registers opts[:to] (default: the caller) for this venue's own notices — what the
feed is doing and what is going wrong with it, distinct from subscribe/2's market
data.
Backed by a real registry in DpExchange.Robinhood.Feed, not the single fixed
:subscriber given to the feed's start_link/1 at supervision-tree boot. That fixed
pid keeps receiving notices exactly as before — this adds a second, independent
recipient rather than replacing it, so a monitoring process distinct from the
data-consuming one can subscribe without stealing delivery from it.
{:error, :feed_not_started} when this venue's feed is not running, matching
subscribe/2 and update_symbols/2 rather than silently discarding the registration.
Endpoints the venue does not serve, as distinct from ones this package has not ported.
Both answer {:error, :not_supported}, and a caller acts the same way on either — but
they mean different things to anyone deciding what to build next, so they are told apart
here rather than flattened into one list.