DpExchange.Gemini.Rest (DpExchangeGemini v0.1.30)

Copy Markdown View Source

Gemini's REST surface — internal. The facade's market-data callbacks are served here.

Every endpoint below was measured against the live venue on 2026-08-28. Where the measurement disagreed with Gemini's documentation, the measurement won and the divergence is recorded in docs/reference/gemini/.

The candle window is fixed and every parameter is ignored

/v2/candles/{symbol}/{time_frame} takes no bounds. limit, start and end are accepted and discarded — three requests differing only in those returned byte-identical responses. Each width serves a fixed window:

Width sentBars≈ span
1m14401 day
5m20157 days
15m134314 days
30m143930 days
1hr146361 days
6hr36792 days
1day3641 year

So a range is honoured by filtering here, and a range the window cannot cover is an error rather than a short answer. Handing back 364 daily bars to a caller who asked for five years is the family's named failure mode in its quietest form: every value real, only the meaning wrong.

Neither ticker carries a quote timestamp, so the venue's own clock is used

/v1/pubticker returns a timestamp, but it is inside the volume object — it stamps the 24-hour volume window, updates about once a minute, and is not when the bid and ask were true. /v2/ticker carries no timestamp at all. Using either as the quote time would be a substitution of exactly the kind this family exists to stop, and the host adapter does something worse: parse_timestamp(nil) returns DateTime.utc_now(), so a quote with no venue time gets the client's clock and looks perfectly fresh.

This package uses the HTTP Date response header — the venue's own statement of when it served the answer, which bounds the quote's age and is not our clock. When that header is absent the request fails with {:error, :missing_venue_timestamp} rather than returning a quote whose freshness cannot be stated.

Summary

Functions

Base URL, overridable per process for tests through Core.Config.

Risk statistics for a perpetual — GET /v1/riskstats/{symbol}.

Funding for a perpetual — GET /v1/fundingamount/{symbol}.

A foreign-exchange reference rate for pair at at/v2/fxrate/{symbol}/{timestamp}.

Candles for a symbol and canonical timeframe, filtered to range.

Every pair with its last price and 24-hour change, in one call.

A price-level snapshot for one symbol.

Best bid, best ask and last trade for one symbol.

What each provider pays for staking each asset — GET /v1/staking/rates.

Every spot symbol the venue lists, canonical.

Best bid and ask for symbol — the top of the book, not a traded price.

Recent public trades for symbol/v1/trades/{symbol}.

Symbols currently carrying a promotional fee — GET /v1/feepromos.

When the next funding calculation lands — GET /v1/nextfundingtimestamp/{symbol}.

The increments and minimum the venue will actually accept for a symbol.

Canonical timeframes this venue serves, shortest first.

Functions

base_url(opts \\ [])

@spec base_url(keyword()) :: String.t()

Base URL, overridable per process for tests through Core.Config.

get_contract_stats(symbol, opts)

@spec get_contract_stats(
  String.t(),
  keyword()
) ::
  {:ok, DpExchange.Core.Types.ContractStats.t()}
  | {:error, term()}
  | {:refused, term()}

Risk statistics for a perpetual — GET /v1/riskstats/{symbol}.

Public. Three prices, and none of them is the other. mark_price is what Gemini marks positions and computes liquidations against; index_price is the external reference it is derived from; and neither is what the contract last traded at, which is get_price/2. A position can be liquidated at a mark the market never printed, and that is why the two are separate fields rather than one.

Open interest arrives twice — in contracts and in notional — and neither substitutes for the other across instruments with different contract sizes.

venue_time is nil: this endpoint publishes no timestamp of its own, and stamping the local clock would make a stale response indistinguishable from a current one.

get_funding(symbol, opts)

@spec get_funding(
  String.t(),
  keyword()
) ::
  {:ok, DpExchange.Core.Types.Funding.t()}
  | {:error, term()}
  | {:refused, term()}

Funding for a perpetual — GET /v1/fundingamount/{symbol}.

Public: funding is a property of the contract, not of an account.

Settled and estimated are different facts and stay in different fields. amount is funding that has happened at a funding time that has passed; estimatedFundingAmount is the venue's projection for the next one and moves continuously until it settles. A real response carries -1.50991 beside -2.10595 — 40% apart — which is how wrong a caller reading "the funding" would be.

The sign is the venue's and is carried through unchanged. It means direction between longs and shorts, and normalising it here would assert a convention Gemini did not state.

Both timestamps travel: fundingTimestampMilliSecs is when this one settled and nextFundingTimestamp is when the next one lands. A caller holding across that instant pays or receives at it.

get_fx_rate(pair, at, opts)

@spec get_fx_rate(String.t(), DateTime.t(), keyword()) ::
  {:ok, DpExchange.Core.Types.FxRate.t()}
  | {:error, term()}
  | {:refused, term()}

A foreign-exchange reference rate for pair at at/v2/fxrate/{symbol}/{timestamp}.

This is not a rate the venue trades at. Gemini's own documentation: "Gemini does not offer foreign exchange services. This endpoint is for historical reference only and does not provide any guarantee of future exchange rates." The number comes from a third party the venue names in provider, which this package carries as Types.FxRate's :source:provider stays :gemini, the venue relaying it.

Requires the Auditor role, which the vendor states on the endpoint.

Fourteen pairs are served and they are all …USD; a pair outside the list is refused here rather than sent, because the venue's 404 for an unsupported pair reads the same as one for a bad timestamp.

at is the instant, sent as milliseconds.

get_historical_prices(symbol, timeframe, range, opts)

@spec get_historical_prices(String.t(), String.t(), keyword(), keyword()) ::
  {:ok, [map()]} | {:error, term()} | {:refused, term()}

Candles for a symbol and canonical timeframe, filtered to range.

range accepts :start and :end as DateTimes. Both are optional; with neither, the venue's whole fixed window is returned.

Refuses rather than truncating:

  • an unknown width → {:error, {:unsupported_timeframe, width}}
  • a :start older than the window can reach → {:error, {:range_unavailable, …}}

get_market_overview(opts)

@spec get_market_overview(keyword()) :: {:ok, map()} | {:error, term()}

Every pair with its last price and 24-hour change, in one call.

/v1/pricefeed is the only endpoint here that describes the whole catalogue at once, which is what makes an overview affordable — the alternative is one request per symbol across 346 symbols, which is not an overview, it is a rate-limit incident.

get_order_book(symbol, opts)

@spec get_order_book(
  String.t(),
  keyword()
) ::
  {:ok, DpExchange.Core.Types.OrderBook.t()}
  | {:error, term()}
  | {:refused, term()}

A price-level snapshot for one symbol.

Each level carries the venue's own timestamp, so unlike a quote there is nothing to derive: the book's time is the newest level's time.

get_price(symbol, opts)

@spec get_price(
  String.t(),
  keyword()
) ::
  {:ok, DpExchange.Core.Types.Quote.t()} | {:error, term()} | {:refused, term()}

Best bid, best ask and last trade for one symbol.

Timestamped from the venue's Date response header — see the module doc for why not from the payload.

get_staking_rates(opts)

@spec get_staking_rates(keyword()) ::
  {:ok, [DpExchange.Core.Types.StakingRate.t()]}
  | {:error, term()}
  | {:refused, term()}

What each provider pays for staking each asset — GET /v1/staking/rates.

Public: the schedule is the same for everyone, so no credential is involved.

The nesting was read backwards, and the fixture agreed with the bug

Measured live 2026-09-05: the response is {"<provider-uuid>": {"ETH": {...}, "SOL": {...}}} — the outer key is a provider UUID and each provider holds a map keyed by asset symbol. This package had it inverted: asset was read from the outer key and provider_id from the inner one, so every StakingRate it built carried an upcased UUID as its asset and a real asset symbol as its provider. Gemini's own OpenAPI names the nesting exactly this way too — StakingRateResponse's "Provider UUID Keys" hold a StakingRateProvider's "Currency Symbol Keys" — so the mistake was checkable without a live call, and it wasn't checked: the test fixture was written keyed the same wrong way, which is exactly why a swapped pair of fields survived. The fix is keyed the other way and the fixture now uses the shape captured from the live response.

Each row also names its own field for the notional cap — depositUsdLimit — which this package read as depositLimitUsd, a field the venue does not send. Every row's :deposit_limit_usd was silently nil. The same live payload proved both bugs at once, so both are fixed together.

Three numbers, and only two of them survive. Gemini publishes rate in basis points, ratePct as a percentage and apyPct as an annualised percentage — the first two differ by a factor of a hundred and the third by compounding as well. StakingRate carries percentages only, both named for what they are, because a contract carrying "the rate" invites a caller to be wrong by 100× and be plausible either way.

A row publishing only rate is converted (basis points ÷ 100). A row publishing neither percentage leaves :rate_pct nil rather than deriving one, and :apy_pct is never derived from :rate_pct at all — that needs a compounding frequency the venue did not state, and assuming one is inventing a number.

Both levels are walked so a provider is addressable; Types.StakingBalance carries the matching breakdown, and redeeming from the wrong provider redeems at the wrong rate.

get_symbols(opts)

@spec get_symbols(keyword()) :: {:ok, [String.t()]} | {:error, term()}

Every spot symbol the venue lists, canonical.

Perpetuals are excluded. They are real instruments and the venue lists them alongside spot pairs, but this package declares supported_instrument_types: [:spot], and a perpetual has no canonical BASE-QUOTE form — emitting one would invent a spot pair that does not exist.

get_top_of_book(symbol, opts)

@spec get_top_of_book(
  String.t(),
  keyword()
) ::
  {:ok, DpExchange.Core.Types.TopOfBook.t()}
  | {:error, term()}
  | {:refused, term()}

Best bid and ask for symbol — the top of the book, not a traded price.

Same /v1/pubticker/{symbol} payload as get_price/2: the venue returns the last trade and the top of the book together, and this splits them into the two types that say which is which. bid and ask used to ride along on the Quote, which Core.Types.Quote no longer has fields for.

The payload carries no sizes, so bid_size and ask_size stay nil — not published, and not zero. venue_time comes from the Date header for the same reason get_price/2's timestamp does; observed_at is when this package read it.

get_trades(symbol, opts)

@spec get_trades(
  String.t(),
  keyword()
) ::
  {:ok, [DpExchange.Core.Types.Trade.t()]}
  | {:error, term()}
  | {:refused, term()}

Recent public trades for symbol/v1/trades/{symbol}.

This is the tape, not get_trade_history/2. That returns the credential's own fills; this returns everyone's executions.

type is the taker's side, and it is the opposite of the resting order's

The venue is explicit: "buy means that an ask was removed from the book by an incoming buy order". So :buy here says a buyer lifted the offer. A package that read it as the maker's side would invert every entry on the tape while every number stayed real.

Broken trades are excluded unless asked for

The venue publishes broken on each print and hides them by default itself. This does the same and opts[:include_broken] opts in: a busted trade did not stand, and its price in a series becomes a phantom high or low in every range and volatility figure built on it.

opts[:since] narrows the window — the venue takes it as timestamp, with since_tid as the alternative and since_tid wins where both are given, which is the venue's own precedence rather than one chosen here. opts[:limit] is the venue's limit_trades.

This endpoint reaches seven calendar days, and 90 days with a timestamp; the venue states both. A caller asking for more gets what the venue serves, which is why the window is worth knowing rather than discovering from a short list.

list_fee_promos(opts)

@spec list_fee_promos(keyword()) ::
  {:ok, [map()]} | {:error, term()} | {:refused, term()}

Symbols currently carrying a promotional fee — GET /v1/feepromos.

Not get_fees/2. That is the schedule applying to this credential; this is the public list of symbols where the venue is charging something other than its published schedule. A caller computing cost from the schedule alone is wrong for exactly these symbols.

An empty list means the venue is running no promotions, which is a real state and not an error.

next_funding_timestamp(symbol, opts)

@spec next_funding_timestamp(
  String.t(),
  keyword()
) :: {:ok, DateTime.t()} | {:error, term()} | {:refused, term()}

When the next funding calculation lands — GET /v1/nextfundingtimestamp/{symbol}.

Public, and the venue answers with a bare integer, not an object: milliseconds since the epoch and nothing around it. get_funding/2 carries the same value alongside the amounts; this exists because a caller that only needs the schedule should not have to read a funding amount to get it.

A body that is not an integer is {:error, :unexpected_response_shape} rather than a nil timestamp — "the venue said something else" and "there is no next funding" are different answers, and the second would be remarkable on a perpetual.

quantization(symbol, opts)

@spec quantization(
  String.t(),
  keyword()
) :: {:ok, map()} | {:error, term()} | {:refused, term()}

The increments and minimum the venue will actually accept for a symbol.

From /v1/symbols/details/{symbol}, which is also the source behind the venue's own published minimums table — that page states it fetches this endpoint live.

tick_size is the base-asset increment and quote_increment the price increment. They are not interchangeable and the names do not say so: for btcusd, tick_size is 1.0e-8 BTC while quote_increment is 0.01 USD.

timeframes()

@spec timeframes() :: [String.t()]

Canonical timeframes this venue serves, shortest first.