DpExchange.Gemini.Socket (DpExchangeGemini v0.1.30)

Copy Markdown View Source

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

connect_opts(opts)

@spec connect_opts(keyword()) :: keyword()

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.

start_link(opts)

@spec start_link(keyword()) :: {:ok, pid()} | {:error, term()}

streams(symbols, channel \\ :book_ticker)

@spec streams([String.t()], atom()) :: [String.t()]

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.

subscribe(socket, symbols, channel \\ :book_ticker)

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

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.

unsubscribe(socket, symbols, channel \\ :book_ticker)

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

Unsubscribes the connection from channel for each symbol.

Refuses the same two shapes subscribe/3 does, for the same reasons — see its doc.