DpExchange.Robinhood (DpExchangeRobinhood v0.2.3)

Copy Markdown View Source

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.

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.

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

cancel_order(credentials, id, opts)

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.

coverage_by_kind(opts \\ [])

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

get_accounts(credentials, opts)

The crypto trading account — and the account number every other v2 call takes.

See DpExchange.Robinhood.Rest.get_accounts/2.

get_balances(credentials, opts)

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.

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 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/5the endpoint moved from marketdata to trading between v1 and v2.

get_order(credentials, id, opts)

One order. opts[:account_number] is required by v2.

See DpExchange.Robinhood.Rest.get_order/3.

get_orders(credentials, opts)

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.

list_instruments(opts)

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.

place_order(credentials, request, opts)

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.

place_orders(credentials, requests, opts)

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.

preview_order(credentials, request, opts \\ [])

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.

quantization(symbol, opts \\ [])

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.

quotes()

@spec quotes() :: [String.t()]

The quote currencies this venue settles in.

replace_order(credentials, id, request, opts \\ [])

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.

subscribe_notices(opts \\ [])

@spec subscribe_notices(keyword()) :: :ok | {:error, term()}

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.

venue_does_not_serve()

@spec venue_does_not_serve() :: [{atom(), arity()}]

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.