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 sent | Bars | ≈ span |
|---|---|---|
1m | 1440 | 1 day |
5m | 2015 | 7 days |
15m | 1343 | 14 days |
30m | 1439 | 30 days |
1hr | 1463 | 61 days |
6hr | 367 | 92 days |
1day | 364 | 1 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, overridable per process for tests through Core.Config.
@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.
@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.
@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.
@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
:startolder than the window can reach →{:error, {:range_unavailable, …}}
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.
@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.
@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.
@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.
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.
@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.
@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.
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.
@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.
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.
@spec timeframes() :: [String.t()]
Canonical timeframes this venue serves, shortest first.