The venue's WebSocket connection — internal, and never named above the facade.
Speaks wss://ws.gemini.com, the API Gemini's current documentation describes.
This is not the API the host adapter uses, and the reasoning is in
docs/reference/gemini/websocket-api-replacement.md. In one line: the host's
api.gemini.com/v2/marketdata still answers, but it is absent from the vendor's current
documentation and from four years of its changelog, so a package published for other
people to depend on should not be built on it.
Protocol
Subscription is an RPC-shaped frame naming streams:
{"method":"subscribe","params":["btcusd@bookTicker","ethusd@bookTicker"],"id":1}and the venue acknowledges with {"id":1,"status":200}.
This package subscribes to @bookTicker and nothing else. That single stream carries
best bid, best ask and — where the book has traded — the last trade price, in one message
per change. It delivers Core.Types.TopOfBook on every frame that parses, and a separate
Core.Types.Quote only on the frames that also carry that last trade: the two are
independent facts and a bid is never dressed up as a price. See handle_message/2 for
the substitution that rule exists to stop.
Which is why there is no order-book machinery here
The host maintains a 182-line L2 book (gemini/l2_book.ex) whose entire purpose is to
reconstruct a mid price from l2_updates deltas, because the endpoint it connects to
offers no top-of-book message. This endpoint does. The book is not ported, and the
moduledoc of the file that is not ported records why it existed — a mid computed from a
single delta rather than the maintained book, which is the incident that created it.
A caller wanting depth calls get_order_book/2, which is a REST snapshot with the
venue's own per-level timestamps. Where a differential depth frame arrives instead — a
future @depth/@depthFast subscription, not one this socket requests today — it is
decoded into Core.Types.OrderBookDelta by WsDecode.to_order_book_delta/2 and forwarded
once, per frame. Never accumulated into a book here: see OrderBookDelta's own moduledoc
for why a distinct, non-snapshot-shaped type is what keeps that from happening by
construction rather than by discipline.
Event time is nanoseconds
The E field is nanoseconds since the epoch, not milliseconds. The difference is a
factor of a million: read as milliseconds, a 2026 timestamp lands in the year 58,000 and
every staleness check passes forever. Converted once, in WsDecode.nanosecond_time/1 —
every frame handler here reaches that through one of WsDecode's decoders rather than
parsing E a second time.
Summary
Functions
The websockex connection opts start_link/1 passes to WebSockex.start_link/4 —
:socket_connect_timeout and :socket_recv_timeout, defaulted to this module's own
budget (see the moduledoc) and overridable by opts.
The subscription addresses for symbols on channel.
Subscribes the connection to channel for each symbol.
Unsubscribes the connection from channel for each symbol.
Functions
The websockex connection opts start_link/1 passes to WebSockex.start_link/4 —
:socket_connect_timeout and :socket_recv_timeout, defaulted to this module's own
budget (see the moduledoc) and overridable by opts.
Exposed as its own function, rather than inlined, so the budget the moduledoc claims can
be pinned by a test without opening a real connection — start_link/1 itself cannot be
exercised against a fake transport, since websockex dials for real — and so a later
refactor cannot silently drop either the explicit values or the override path back to
websockex's own accidental defaults.
The subscription addresses for symbols on channel.
Exposed because a caller building a batch needs to know what it is about to ask for, and because a channel/symbol mismatch is an error worth seeing before the frame goes out rather than as silence afterwards.
Subscribes the connection to channel for each symbol.
channel defaults to :book_ticker, which is the only channel this socket delivered
before the AsyncAPI document was read. The address is built by WsChannels, not
concatenated here — the interval is part of the address for the …Fast and …Snapshot
channels, and a hand-assembled "{symbol}@depthFast" subscribes to nothing and produces
silence rather than an error.
A per-account channel takes no symbols: pass [].
A non-empty symbols against a channel that takes none is refused here, with
{:error, {:channel_takes_no_symbol, channel}}, rather than reaching streams/2 and
silently subscribing to nothing — see WsChannels.address/2's own moduledoc for the
shape of that failure. A channel WsChannels.requires_credential?/1 marks private is
refused too, with {:error, {:credential_required, channel}}: this socket never
authenticates its connection, so a private channel can only ever fail at the venue, and
telling a caller before the round trip is the whole reason that function exists.
Returns {:error, :send_timeout} rather than exiting when the socket will not accept the
frame — a caller can retry a batch, but it cannot recover from a linked exit it did not
expect.
Unsubscribes the connection from channel for each symbol.
Refuses the same two shapes subscribe/3 does, for the same reasons — see its doc.