DpExchange.Webull.QuoteProto (DpExchangeWebull v0.1.1)

Copy Markdown View Source

Minimal proto3 decoder for the three Webull streaming payloads this package consumes.

Webull's MQTT feed publishes protobuf, not JSON. The prior adapter's message handler called Jason.decode/1, so even once its connection and subscription were repaired every payload would still have been discarded as malformed — three bugs stacked, only the last of which was visible.

Why hand-rolled rather than the protobuf package

The schema is six messages of nothing but string and repeated <message>, so exactly one wire type matters — length-delimited (2) — plus varints for lengths and field headers. No floats, no zigzag, no packed repeated fields. Taking a dependency and a codegen step to read that is more machinery than the thing it reads.

The venue's own definitions, verbatim, are in docs/reference/webull/streaming-api.md.

Every field is a string, which is the reason this venue's numbers are trustworthy: no float ever enters, so nothing is rounded on the way in. Values go straight to Decimal.new/1 at the boundary that needs them.

Unknown fields are skipped, not fatal

Proto3's own compatibility rule, and the thing that keeps a venue adding a field from taking the socket down. Wire types this schema never uses — varint, 64-bit, 32-bit — are parsed only far enough to step over them.

A truncated or unparseable tail ends the walk with whatever was read rather than raising. This runs in the socket process, and one malformed frame must not cost the connection.

Repeated versus scalar, and a bug this decoder used to have

A wire walk cannot tell a scalar field from a repeated one — only the schema knows. So every repeat accumulates into a list, and the reader decides which it wanted.

The adapter this was ported from accumulated the same way but read fields directly, with a is_binary(value) guard. Its comment said scalar fields "keep the LAST occurrence, which is proto3's rule" — but the code produced a list for a scalar sent twice, the guard then failed, and the entire message was rejected. Legal-on-the-wire input, dropped silently.

Here scalar/1 takes the last occurrence, which is proto3's rule, and repeated/1 keeps the whole list. The comment and the code now agree.

Summary

Types

Field number to decoded value. Repeats accumulate in wire order.

Functions

Field number to value, repeats in wire order. Exposed for tests and for Socket.

Decodes a Quote — book levels — into the venue's own best bid and ask.

Decodes a Snapshot — the payload carrying a usable last price and volume.

Decodes a Tick — a single trade.

The scalar value of a field: the last occurrence, per proto3.

Types

field_map()

@type field_map() :: %{required(non_neg_integer()) => term()}

Field number to decoded value. Repeats accumulate in wire order.

Functions

decode_message(binary)

@spec decode_message(binary()) :: field_map()

Field number to value, repeats in wire order. Exposed for tests and for Socket.

decode_quote(payload)

@spec decode_quote(binary()) :: {:ok, map()} | :error

Decodes a Quote — book levels — into the venue's own best bid and ask.

The repeated fields arrive in book order, so the first entry of each side is the best level. No mid is computed here. A mid is a decision about what a price means, and it belongs with the caller that also knows whether both sides are present.

decode_snapshot(payload)

@spec decode_snapshot(binary()) :: {:ok, map()} | :error

Decodes a Snapshot — the payload carrying a usable last price and volume.

Quote carries book levels and Tick carries individual trades; this is the one a price collector wants.

decode_tick(payload)

@spec decode_tick(binary()) :: {:ok, map()} | :error

Decodes a Tick — a single trade.

scalar(fields, field_number)

@spec scalar(field_map(), non_neg_integer()) :: binary() | nil

The scalar value of a field: the last occurrence, per proto3.

A wire walk cannot tell a scalar from a repeated field, so a scalar sent twice arrives as a list. Reading it with an is_binary guard — as the prior adapter did — rejects the whole message for input the specification calls legal.