DpExchange.Robinhood.Feed (DpExchangeRobinhood v0.2.3)

Copy Markdown View Source

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

child_spec(init_arg)

@spec child_spec(keyword()) :: Supervisor.child_spec()

Returns a specification to start this module under a supervisor.

See Supervisor.

coverage(feed)

@spec coverage(GenServer.server()) :: %{required(String.t()) => :internal_poll}

Which symbols are actually arriving. Observed, never intended.

coverage_by_kind(feed)

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

start_link(opts)

@spec start_link(keyword()) :: GenServer.on_start()

subscribe_notices(feed, opts \\ [])

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

update_symbols(feed, symbols)

@spec update_symbols(GenServer.server(), [String.t()]) :: :ok

Replaces the polled set.