This venue's feed — a REST poll, and nothing outside this module needs to know that.
Why a venue with no socket still has a feed
Robinhood Crypto exposes no streaming API. Under the shape this replaces, that fact travelled upward: the collection layer kept a poll set and decided which venues were exempt from it, and an operations page described Robinhood's pairs in terms of a socket it does not have and has never claimed — sending a reader hunting a streaming fault that cannot exist.
Behind a feed, the poll is an implementation detail. This module delivers the same
Core.Types.TopOfBook to the same subscriber as a WebSocket venue, so no consumer
branches on transport, and coverage/1 can report what the venue actually reports about
itself: these symbols are arriving.
Not Core.Types.Quote. This venue has no last-trade endpoint at all —
best_bid_ask carries only bid and ask — and DpCryptoManagement's issue #21 is what
happens when this polled Core.Types.Quote.price from the ask to paper over that: a
fabricated trade price masquerading as a real one. See DpExchange.Robinhood's
moduledoc on get_price/2. Bid and ask are both genuine, so that is what this polls and
delivers.
Per symbol, for now — not because there is no bulk endpoint
This runs Core.PollingFeed in its per-symbol :fetch mode, each symbol on its own
schedule — spread across the interval rather than swept in a burst. With 86 pairs, a
burst would put 86 signed requests into one instant of a budget this venue has already
proven sensitive to.
Correction, 2026-09-06: an earlier version of this note said the venue "publishes no
bulk-stats endpoint" and left it there. That is not quite what the vendor's own OpenAPI
document says. best_bid_ask genuinely carries no 24-hour statistics — that part holds —
but its symbol query parameter is documented as repeatable: ?symbol=BTC-USD&symbol= ETH-USD returns a results array covering every symbol asked for in ONE signed request.
Core.PollingFeed's own moduledoc names Robinhood as the intended user of its
:fetch_all mode for exactly this shape. This module does not use it yet: the vendor's
document does not say what a batched call does when one symbol in it is unlisted or
malformed, and PollingFeed's fetch_all path has no on_refusal-equivalent — a
{:refused, _} returned from :fetch_all does not match either clause
fetch_all_and_publish/1 handles and would crash this feed's process instead of
recording one refused symbol, which is a worse failure than today's per-symbol design for
the one case (a delisted symbol mixed into a live batch) this venue's rate limit already
makes likely. Adopting :fetch_all needs that answered against the live venue first —
which is tier-2 work, done by hand, never on a schedule — or a Core change giving
:fetch_all its own refusal path. Recorded as an idea, not implemented as a guess.
acquire, not check
A moduledoc worth carrying from the adapter this replaces. When rate limiting was first
switched on for this venue — it had never been enabled at all — Robinhood went from
87 of 87 symbols delivering to 8 of 87 in a single cycle. Not the venue throttling:
our own limiter refusing calls the venue was perfectly happy to serve, because check/3
answers "is there capacity right now" and a poll that finds none simply skips the symbol.
acquire/3 waits for capacity instead. A slower cycle rather than a missing price.
A silent outage says so, not only to the log
This is the venue Core.PollingFeed's "delivered NOTHING" warning was written about:
DpCryptoManagement's issue #21 is a wrong credential (ciphertext where a key belonged)
producing a fetch failure on every symbol, every cycle, for a whole deployment, with the
only trace a Logger.warning a human had to go grepping for. dp_exchange_core 0.1.50
gives PollingFeed.start_link/1 an :on_notice option for exactly this, and it is wired
here the same way on_refusal already is: forwarded into this process, then fanned out to
every registered notice subscriber (see below), so a coverage outage reaches more than a
log line nothing downstream reacts to. It fires once on the transition into
delivering-nothing (severity: :warning) and once on the transition back out
(severity: :info) — never per tick and never per sweep while the outage continues, so
an 86-symbol feed retrying every symbol every cycle does not turn one outage into a
notice storm.
Documenting that design was not the same as wiring it. :rate_limit_blocking —
the option Core.HttpClient.check_rate_limits/1 actually reads to choose acquire/3
over check/3 — was missing from this module's own forwarded-options allowlist, so no
caller could ever turn it on: every request fell through to check/3 regardless, and
the failure this section describes reproduced exactly, live (DpCryptoManagement's issue
#16). Forwarded now, and defaulted to true here specifically — not in Rest's own
allowlist, which a direct one-off get_top_of_book/2 call also goes through and where
fail-fast may be exactly what a caller wants. A poll is not a one-off call: this
module's whole reason to exist is the venue's rate limit, so acquire is the only
correct default for it.
A monitoring pid does not have to be the data subscriber
start_link/1's :subscriber is the single fixed pid that receives quotes, refusals
and (until this section) notices — set once, at supervision-tree boot, because this
venue's subscribe/2 takes no to: of its own; there is no per-call registry for
market data here, family-wide or otherwise. Notices are different on purpose:
subscribe_notices/2 — and DpExchange.Robinhood.subscribe_notices/1 above it — adds a
genuinely independent pid to notice_subscribers, so a monitoring process that never
wants a Core.Types.TopOfBook can still learn this feed went dark, without displacing
whoever is already registered to receive the quotes.
This module previously claimed that registry did not exist, on the reasoning that
Core.PollingFeed itself has no notion of more than one recipient — sink,
on_refusal and on_notice are each exactly one injected function, by design (see
Core.PollingFeed's own moduledoc). That is still true and is not being fought here:
the fan-out lives in THIS module, one layer up, exactly the way dp_exchange_schwab's
own Feed already fans its Streamer and fallback-poll notices out to more than one
registrant. PollingFeed still owns fetching, scheduling and its own "delivering
nothing" latch; this module owns nothing more than "who gets told," which is the part a
single injected function structurally cannot express. Turning this module into a
GenServer in its own right — where it used to simply be the PollingFeed process,
registered under this module's name — is the smallest change that gives it somewhere to
keep that set.
Summary
Functions
Returns a specification to start this module under a supervisor.
Which symbols are actually arriving. Observed, never intended.
coverage/1, split by kind — see DpExchange.Robinhood.coverage_by_kind/1 for why
the family wants this at all when Robinhood has nothing to split.
Registers opts[:to] (default: the caller) to receive this feed's own Core.Notice
traffic — currently the coverage-outage pair described in this module's moduledoc.
Replaces the polled set.
Functions
@spec child_spec(keyword()) :: Supervisor.child_spec()
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec coverage(GenServer.server()) :: %{required(String.t()) => :internal_poll}
Which symbols are actually arriving. Observed, never intended.
@spec coverage_by_kind(GenServer.server()) :: %{ top_of_book: %{required(String.t()) => :internal_poll} }
coverage/1, split by kind — see DpExchange.Robinhood.coverage_by_kind/1 for why
the family wants this at all when Robinhood has nothing to split.
Traceable to the actual struct, not assumed from the declared kind list: this feed's
fetch calls only Rest.get_top_of_book/3, wired in init/1 below, and that
function returns exclusively DpExchange.Core.Types.TopOfBook.t() — never
DpExchange.Core.Types.Quote.t() (see this module's own moduledoc on why not). Every
symbol coverage/1 reports therefore arrived through that one fetcher, so wrapping its
map under :top_of_book reports what was actually produced, not a guess.
@spec start_link(keyword()) :: GenServer.on_start()
@spec subscribe_notices( GenServer.server(), keyword() ) :: :ok
Registers opts[:to] (default: the caller) to receive this feed's own Core.Notice
traffic — currently the coverage-outage pair described in this module's moduledoc.
Additive, never a replacement: the fixed :subscriber given to start_link/1 keeps
receiving notices too, exactly as it did before this registry existed. A dead pid or an
unregistered name is skipped at delivery time rather than raised on — see fan_out/2.
@spec update_symbols(GenServer.server(), [String.t()]) :: :ok
Replaces the polled set.