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
@type field_map() :: %{required(non_neg_integer()) => term()}
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.
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.
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.
Decodes a Tick — a single trade.
@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.