DpExchange.Webull.Subscription (DpExchangeWebull v0.4.52)

Copy Markdown View Source

The HTTP half of subscribing — internal.

Webull steers an MQTT stream with REST calls. POST /market-data/streaming/subscribe names the session_id that the MQTT connection registered as its client id, and the broker begins publishing to that session.

Split across two protocols like this, the failure modes are unusual and worth naming:

  • A 200 here does not mean data is arriving. It means the venue accepted the request. Whether anything is published depends on the MQTT session being up and carrying the same id. That is why coverage/1 reports only what arrived — on this venue, "asked", "accepted" and "delivering" are three different moments.
  • A session id mismatch fails silently in the most expensive way: the HTTP call succeeds, the broker publishes to a session nobody is listening on, and the socket sits connected and idle. There is no error anywhere. One generated id, used for both, is the only defence.

Every call is signed — this venue has no anonymous endpoints.

sub_types is uppercase (SNAPSHOT, QUOTE, TICK) and is not the MQTT topic namespace. The subscribe request body's subtype field and the MQTT topics a connected session receives on (quote, snapshot, tick — lowercase, see streaming-api.md) look like the same vocabulary and are not: they are two different fields on two different protocols. Sending the lowercase topic names here got every subscribe rejected HTTP 417 UNSUPPORTED_SUB_TYPE — DpCryptoManagement's issue #19, filed right after #18 unblocked the request enough to reach this validation for the first time.

["SNAPSHOT", "QUOTE"] is confirmed live — the same pair the prior in-repo client accepted for months and issue #19 measured directly. TICK joined the default so a plain subscribe/2 delivers Core.Types.Trade the same way it already delivers Quote and TopOfBook, with no venue-shaped option a consumer has to learn — but its inclusion here is read from streaming-api.md's topic table, not yet measured against the live venue the way the other two were. If the venue answers TICK differently from what the table promises, that will surface as Feed's existing generic-subscribe-failure handling — see its moduledoc — the same as any other refusal this module hands back.

INVALID_SYMBOL names the offending symbols, and this module hands them back

Rejection here is per-request, not per-symbol: one symbol the venue's streaming category does not carry fails the entire batch, and the venue answers with {"error_code" => "INVALID_SYMBOL", "message" => "The symbols does not exist in the category. [SYM1, SYM2, ...]"} — measured from Feed's own resubscribe logs (DpCryptoManagement's issue #24): 17 of one consumer's 342 symbols named this way, every 60-second resubscribe tick, forever, because nothing downstream could act on the answer. Before this, that whole response collapsed into the generic {:exchange_error, :webull, "HTTP 417: ..."} string below — a caller could log it, and could not pattern-match a single symbol out of it.

invalid_symbols/1 parses the bracketed list out of message and this function converts every entry back to CANONICAL form before it returns — no venue-shaped symbol string is allowed to escape this module. Feed (not this module) decides what happens to a rejected symbol; this module's only job, same as :oversubscribed below, is to make the venue's answer something a caller can act on rather than only read.

A message the parser cannot attribute to any symbol is not treated as naming zero symbols. It falls through to the same opaque {:exchange_error, ...} shape a 417 with no recognisable list already produced — inventing an empty exclusion list from a rejection nobody could attribute would look like "nothing was rejected" to Feed, which is the one interpretation this response never supports.

Summary

Functions

Starts publication for symbols on the MQTT session registered under session_id.

Stops publication for symbols.

Functions

subscribe(session_id, symbols, opts)

@spec subscribe(String.t(), [String.t()], keyword()) :: :ok | {:error, term()}

Starts publication for symbols on the MQTT session registered under session_id.

An empty symbol list is :ok without a request: asking the venue to subscribe to nothing spends a call from a budget this venue is already the tightest on.

unsubscribe(session_id, symbols, opts)

@spec unsubscribe(String.t(), [String.t()], keyword()) :: :ok | {:error, term()}

Stops publication for symbols.