DpExchange.Gemini.WsDecode (DpExchangeGemini v0.2.16)

Copy Markdown View Source

WebSocket frames into the contract's value types.

Pure functions; no socket. Three rules here are not obvious from the field names, and each produces a wrong-but-plausible answer if missed.

1. m is "whether the buyer is the maker" — the opposite of the taker's side

This venue reports the trade side two different ways on two transports:

REST  /v1/trades   `type` = the TAKER's side. "buy" means an ask was lifted.
WS    @trade       `m`    = whether the BUYER was the MAKER.

So m: true means the buyer was resting and the seller was the aggressor — aggressor: :sell. Carrying m straight through as a buy would invert every trade on the socket while agreeing with the REST field name, which is exactly how such a bug survives review.

2. Timestamps are nanoseconds, not milliseconds

E is documented as a nanosecond Unix timestamp, and the vendor notes the values exceed JavaScript's safe integer range. Reading one as milliseconds puts the event roughly fifty thousand years into the future; reading it as seconds is worse, because the result still looks like a date.

3. A depth frame is a diff, and the sequence range is how you know it is safe

depth and depthFast carry U..u — the range of update ids the frame covers. The vendor: "if a frame's U skips ahead of the last applied u, discard the book and resubscribe to resync." A package applying diffs without checking that gap builds a book that is silently wrong from the first dropped frame onward, and every price in it stays real. depth_gap?/2 is that check.

A quantity of zero in a diff removes the level rather than setting it to zero — the vendor says so, and a package storing the zero would keep a level nobody is quoting.

Summary

Functions

The bid and ask changes in a differential depth frame, as {price, quantity} levels.

Whether applying frame to a book last updated at last_applied would skip updates.

An OrderBook from a partial-depth snapshot (@depth5, @depth10, @depth20).

An OrderBookDelta from a {symbol}@depth / @depthFast differential frame.

A TopOfBook from a {symbol}@bookTicker frame.

A Trade from a {symbol}@trade frame.

Functions

depth_changes(frame)

@spec depth_changes(map()) :: %{bids: [{Decimal.t(), Decimal.t()}], asks: list()}

The bid and ask changes in a differential depth frame, as {price, quantity} levels.

A quantity of zero removes the level — the vendor says so — and is returned as-is rather than filtered, because the caller applying the diff is the one that must delete rather than store it. Filtering here would drop the deletion and leave a level nobody quotes standing forever.

depth_gap?(arg1, last_applied)

@spec depth_gap?(map(), integer() | nil) :: boolean()

Whether applying frame to a book last updated at last_applied would skip updates.

The vendor's rule: a frame's U must not skip ahead of the last applied u. true means discard the book and resubscribe — not "retry", because the missing updates are gone and the local book is already wrong.

nil for last_applied means nothing has been applied yet, which is never a gap.

to_order_book(frame, symbol, observed_at)

@spec to_order_book(map(), String.t(), DateTime.t()) ::
  {:ok, DpExchange.Core.Types.OrderBook.t()}

An OrderBook from a partial-depth snapshot (@depth5, @depth10, @depth20).

These frames carry lastUpdateId and absolute levels. lastUpdateId becomes the book's sequence, which is what lets a caller tell one snapshot from a later one — a snapshot with no sequence cannot be ordered against anything.

The frame carries no timestamp of its own, so observed_at is passed in and used; that is when the snapshot was seen, and the type has nowhere to claim otherwise.

to_order_book_delta(frame, symbol)

@spec to_order_book_delta(map(), String.t()) ::
  {:ok, DpExchange.Core.Types.OrderBookDelta.t()} | {:error, term()}

An OrderBookDelta from a {symbol}@depth / @depthFast differential frame.

Built on depth_changes/1, side-tagging each level the way OrderBookDelta.level/0 requires — bids first, then asks. The venue does not interleave the two sides by time within one frame, so concatenating them in that order loses no ordering information the frame itself carried.

sequence is the frame's u — the update id this diff advances the book to, the same value depth_gap?/2 compares the next frame's U against. A quantity of zero is carried through unresolved, exactly as depth_changes/1 and OrderBookDelta's own moduledoc both require: it means the level ceased to exist, and deciding that is the consumer's job, not this package's.

to_top_of_book(frame, symbol, observed_at)

@spec to_top_of_book(map(), String.t(), DateTime.t()) ::
  {:ok, DpExchange.Core.Types.TopOfBook.t()} | {:error, term()}

A TopOfBook from a {symbol}@bookTicker frame.

b/B are the bid and its size, a/A the ask and its size. E is the venue's own nanosecond timestamp, so unlike some venues in this family venue_time is real here.

to_trade(frame, symbol)

@spec to_trade(map(), String.t()) ::
  {:ok, DpExchange.Core.Types.Trade.t()} | {:error, term()}

A Trade from a {symbol}@trade frame.

aggressor is inverted from m: see the moduledoc. m: true (buyer is maker) means the seller lifted, so the aggressor is :sell.