Gemini, behind the DpExchange facade.
⚠️ EXPERIMENTAL
This package has not run in production. While it is 0.x the API may change without
a major version — pin all three segments. Maturity is declared per endpoint
through capabilities/0; do not read this banner as your check.
This module is the entire public API of this package. Transport, signing, session handling and supervision are internal, and there is nothing here that returns them.
What is specific to Gemini, and what a caller can therefore rely on
Nine candle widths, and the venue's own documentation names three of the original
seven wrong. The accepted set is 1m 5m 15m 30m 1h 6h 1d 1w 1M in canonical form —
1w and 1M (the venue's own 1mo) joined 2026-09-08, found live, after this package
had shipped with only the original seven. Gemini's documentation lists values that its
API rejects; this package sends what the venue accepts, measured. Asking for a width
Gemini does not serve — 2h, 4h, 12h — is an error, never the nearest one.
The candle window is fixed and unbounded requests are refused, for seven of the nine
widths. Gemini ignores start, end and limit entirely and returns a fixed window
per width, from 1 day of one-minute bars to a year of daily ones (about 4.6 years of
weekly, about 9.6 years of monthly). This package filters to your range, and refuses
with {:error, {:range_unavailable, …}} when your range starts before the window can
reach — rather than handing back a shorter period that reads as a complete answer. 1w
and 1M are the two exceptions: neither has a fixed-seconds width the shared
DpExchange.Core.Timeframe vocabulary will compute one for, so the pre-flight refusal
does not fire for them — the real rows the venue returns are still filtered against your
range afterward, so a range outside the true window still comes back empty rather than
wrong, just without the named boundary. See DpExchange.Gemini.Rest's moduledoc.
Quote.venue_time comes from the venue's clock, or the call fails — so on this venue it
is never nil. Neither Gemini ticker publishes a quote time: /v1/pubticker's only
timestamp stamps its 24-hour volume window, about a minute stale. This package uses the
venue's HTTP Date header and returns {:error, :missing_venue_timestamp} when it is
absent. It never substitutes the local clock.
Gemini publishes no rate-limit headers. Measured 2026-08-28: a response carries
date, x-request-id and x-envoy-upstream-service-time, and nothing else. It does
publish its limits, in prose, which is better — and capabilities/0 carries all
three GCRA parameters from the venue's own page rather than from a guess.
The demo environment is first-class here
Gemini runs a full exchange with test funds — its own words — where automated bots simulate order-book activity and new accounts are credited $100,000 USD, 1,000 BTC and 20,000 each of ETH, BCH, ZEC and LTC. Point this package at it with one option:
children = [{DpExchange.Gemini, environment: :sandbox}]
{:ok, quote} = DpExchange.Gemini.get_price("BTC-USD", environment: :sandbox)Both REST and the WebSocket follow the setting. :production is the default, and
deliberately so: a wrong default fails in only one direction. Meaning demo and getting
production sends a real order to a real exchange; meaning production and getting demo
gives obviously-wrong prices — the demo book is frequently crossed, and a frame
captured 2026-08-28 carried a bid of 68169.88 against an ask of 64886.32.
Selecting it once for a whole process tree, rather than per call, goes through
DpExchange.Core.Config — which resolves per process, so one async test can point at
demo without redirecting every test running beside it.
live?/1 answers the question directly, resolving opts the same way every call here
does: DpExchange.Gemini.live?(environment: :sandbox) is false.
You authenticate; this package signs
Account and trading are implemented, and credentials arrive as arguments — used to sign that one request and not kept:
{:ok, balances} = DpExchange.Gemini.get_balances(credentials, [])
{:ok, order} = DpExchange.Gemini.place_order(credentials, request, [])What stays yours is credential storage and the choice of scheme. Gemini offers an
API key pair and a full OAuth 2.0 authorization-code flow — app registration, a
permanent client type, redirecting users to approve scopes, PKCE, and refreshing a
24-hour token — and which one an application uses is a decision about its users and its
deployment. Name it with auth_scheme: :api_key | :oauth; with it absent, whichever you
actually supplied is used. Credentials carrying both are refused rather than resolved,
because sending both header families is AmbiguousAuthentication at the venue.
This package never reads a credential from the environment or a vault.
Gemini serves no market orders, and this package will not invent one
capabilities/0 declares supported_order_types: [:limit, :stop_limit]. The venue's own
reasoning: market orders "provide you with no price protection". Its documented
workaround is an immediate-or-cancel order "coupled with an aggressive limit price" —
and that price is not one a package may choose for you. How aggressive is a question
only the caller can answer, and the answer is money.
So order_type: :market is {:error, {:unsupported_order_type, :market}}. Ask for the
behaviour explicitly, with your own number:
%{order_type: :limit, time_in_force: :ioc, price: my_price, …}Your API key's nonce mode is also something only you know. Gemini provisions keys in
either time-based or incremental mode, and the two need differently-shaped nonces —
seconds versus a strictly increasing value, with no single value satisfying both. The
default is :time_based, the venue's own recommendation; pass
nonce_mode: :incremental if that is how your key was made. A mismatch fails loudly on
the first request.
Supervision
Add it to your own tree. Nothing starts on load — a consumer that has not asked for Gemini must not find a socket open.
children = [{DpExchange.Gemini, []}]
{:ok, quote} = DpExchange.Gemini.get_price("BTC-USD", [])
:ok = DpExchange.Gemini.subscribe(["BTC-USD"], to: self())
Summary
Functions
Registers a bank account. opts[:country] selects the endpoint — US or CA.
Cancels open orders in bulk. opts[:scope] is required — :session or :account.
Cancels an unconfirmed clearing order.
Commits a quoted conversion by its quoteId.
Confirms a clearing order. This executes a trade.
Converts in one call — the venue's wrap endpoint.
coverage/1, split by which of this venue's two streamable kinds is arriving.
Creates a subaccount. opts[:name] is required.
Submits a broker-facilitated clearing order between two named counterparties.
Creates a bilateral clearing order. Not place_order/3 — nothing reaches the book.
What the venue would charge to withdraw. Requires opts[:address].
The funding amount report as a spreadsheet — the venue's bytes, unparsed.
The funding payment report as JSON.
The funding payment report as a spreadsheet — the venue's bytes, unparsed.
The perpetuals margin account — collateral, leverage, and the estimated liquidation price.
One clearing order's state.
Risk statistics for a perpetual — mark, index and open interest.
A fresh deposit address for asset on network.
Funding for a perpetual — settled, projected, and when the next one lands.
A foreign-exchange reference rate for pair at at.
The spot margin account summary — a different margin system from the perpetuals one.
Margin interest rates for every borrowable asset.
Every balance, each also valued in one notional currency.
Gemini publishes no per-method read.
Open positions on the perpetuals book.
The roles this API key carries.
Staked positions, one per asset.
Movements in and out of staked positions.
What each provider pays for staking each asset.
Rewards accrued over a window.
The account's own traded volume, one row per symbol per day.
Recent public trades — the tape.
Everything that moved on this account — wider than fills and wider than transfers.
Every subaccount in the group.
The addresses this account may withdraw to on opts[:network].
Broker clearing orders — a different row shape from list_clearing_orders/1.
Clearing orders this account is party to.
Clearing trades — the orders that completed.
What Gemini charged this account for holding assets.
Symbols currently carrying a promotional fee.
Funding payments this account actually paid or received.
The networks an asset moves over, or the assets a network carries.
The funding sources this account can move fiat through.
Whether the environment opts resolves to moves real money.
When the next funding calculation lands, on its own.
Not supported. Gemini places one order per request.
What a spot order would do to this account's margin. Places nothing.
Not supported. Gemini publishes no endpoint answering what an order would cost.
Quotes a conversion — Gemini's Instant quote. See
DpExchange.Gemini.Private.quote_conversion/4, including when it refuses to guess the
direction.
Exchanges a refresh token for a new access token. Credential use, not consent.
Removes address from the allowlist for network.
Renames a subaccount.
Not supported. Gemini has no atomic replace; a caller cancels and re-places.
Asks the venue to allowlist address on network.
Revokes an access token. Only reachable with an OAuth token, not an API key.
Stakes amount of asset. This moves funds.
Moves funds between two accounts at this venue. Not withdraw/5.
Redeems amount of a staked asset. Returns before the redemption completes.
Endpoints the venue does not serve, as distinct from ones this package has not ported.
Moves funds. Withdraws amount of asset over network to address.
Functions
Registers a bank account. opts[:country] selects the endpoint — US or CA.
See DpExchange.Gemini.Private.add_payment_method/3: the venue verifies out of band, so
a successful response does not make the method usable.
Cancels open orders in bulk. opts[:scope] is required — :session or :account.
See DpExchange.Gemini.Private.cancel_all_orders/2, including why there is no default.
@spec cancel_clearing_order( String.t(), keyword() ) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Cancels an unconfirmed clearing order.
See DpExchange.Gemini.Private.cancel_clearing_order/3. A confirmed order is a trade, and
a trade is not cancellable.
Commits a quoted conversion by its quoteId.
@spec confirm_clearing_order(String.t(), map(), keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Confirms a clearing order. This executes a trade.
See DpExchange.Gemini.Private.confirm_clearing_order/4. The venue re-asks for every term
and this package fills none of them in — the confirming side says what it believes it is
agreeing to, which is the whole point of the check.
Converts in one call — the venue's wrap endpoint.
Not a shorthand for quote_conversion/4 then commit_conversion/2: there is no rate
held, and the caller learns the price from the result.
@spec coverage_by_kind(keyword()) :: %{ required(DpExchange.Core.Capabilities.data_kind()) => %{ required(DpExchange.Core.Venue.symbol()) => DpExchange.Core.Venue.route() } }
coverage/1, split by which of this venue's two streamable kinds is arriving.
Why this exists — the general failure mode
coverage/1 is truthful about whether anything is arriving for a symbol, but it
answers with one boolean regardless of what is arriving. That collapse is invisible
exactly when it matters most: a venue streaming several distinct kinds of data can have
one kind healthy and another dark for the same symbol, and coverage/1 reports
:stream either way, because it counts any payload against that symbol's one slot in
the map. "One kind dark, another healthy" and "everything healthy" produce the same
answer.
DpExchange.Core.Venue.coverage_by_kind/1 records the incident that forced this open:
Coinbase's level2 (order book) channel delivered over 11,000 frames for 406 symbols
while ticker (quotes) was dark for all but 5, and coverage/1 answered :stream for
all 406 symbols — correctly, by its own definition, and uselessly for anyone trying to
tell the two facts apart.
Why it applies here too, even though this venue's delivery is not Coinbase's
Gemini has no separate channel per kind — one @bookTicker stream carries both of this
venue's declared kinds, :quotes and :top_of_book (see DpExchange.Gemini.Socket).
But the two are still independent facts about a symbol: a bookTicker frame always
produces a TopOfBook when it parses, and produces an accompanying Quote only when
that same frame also carries a last-traded price. A symbol can quote continuously —
its book updating on every level change — while never trading, so :top_of_book stays
healthy for it and :quotes never appears at all. Under coverage/1 alone that symbol
reads identically to one trading actively: :stream either way. Sharing one wire
underneath both kinds does not prevent the collapse coverage_by_kind/1 exists to
undo; it just changes which mechanism produces the two independent facts.
What this reports, and what it is not
Each DpExchange.Core.Types.Quote and DpExchange.Core.Types.TopOfBook this package
has actually delivered, grouped by which struct it was — never by channel name, never
by what was subscribed. :quotes and :top_of_book are the only keys, matching
capabilities().streamable, and each is present even when nothing of that kind has
arrived yet, mapped to an empty map — absence here is :not_covered, the same as it is
in coverage/1.
Not a replacement for coverage/1: a caller asking "is anything at all arriving for
this symbol" still gets a straight answer from that. Not a per-channel report: this
venue's one WebSocket stream must never leak across this facade, by kind or otherwise.
See DpExchange.Core.Venue.coverage_by_kind/1 for the invariant Core's conformance
suite checks whenever a venue exports this callback.
Creates a subaccount. opts[:name] is required.
See DpExchange.Gemini.Private.create_account/3. The venue answers with a kebab-cased
shortname, and that is what every other endpoint's account parameter takes — not the
name that was sent.
@spec create_broker_clearing_order( map(), keyword() ) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Submits a broker-facilitated clearing order between two named counterparties.
See DpExchange.Gemini.Private.create_broker_clearing_order/3. side is assigned to the
source, and passing the counterparties the wrong way round produces a valid order in
which each side trades the direction the other meant.
@spec create_clearing_order( map(), keyword() ) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Creates a bilateral clearing order. Not place_order/3 — nothing reaches the book.
See DpExchange.Gemini.Private.create_clearing_order/3. is_confirmed on the response is
the field that matters: false means the trade has not happened.
What the venue would charge to withdraw. Requires opts[:address].
Moves no funds. See DpExchange.Gemini.Private.estimate_withdrawal_fee/5.
@spec funding_amount_report( String.t(), keyword() ) :: {:ok, binary()} | {:error, term()} | {:refused, term()}
The funding amount report as a spreadsheet — the venue's bytes, unparsed.
The funding payment report as JSON.
See DpExchange.Gemini.Private.funding_payment_report/2 — the query string is part of
what is signed.
@spec funding_payment_report_file(keyword()) :: {:ok, binary()} | {:error, term()} | {:refused, term()}
The funding payment report as a spreadsheet — the venue's bytes, unparsed.
See DpExchange.Gemini.Private.funding_payment_report_file/2.
The perpetuals margin account — collateral, leverage, and the estimated liquidation price.
See DpExchange.Gemini.Private.get_account_margin/2. get_positions/1 publishes no
liquidation price; this is where it lives.
@spec get_clearing_order( String.t(), keyword() ) :: {:ok, map()} | {:error, term()} | {:refused, term()}
One clearing order's state.
See DpExchange.Gemini.Private.get_clearing_order/3. Read is_confirmed, not status.
Risk statistics for a perpetual — mark, index and open interest.
Public. See DpExchange.Gemini.Rest.get_contract_stats/2: mark and index are separate
prices with separate meanings, and neither is what the contract last traded at.
A fresh deposit address for asset on network.
See DpExchange.Gemini.Private.get_deposit_address/4 — in particular why memo_required
comes back nil rather than false.
Funding for a perpetual — settled, projected, and when the next one lands.
Public. See DpExchange.Gemini.Rest.get_funding/2: the settled amount and the venue's
estimate are different facts and stay in different fields.
A foreign-exchange reference rate for pair at at.
See DpExchange.Gemini.Rest.get_fx_rate/3 — including why this is not a rate the venue
trades at, and why the source is carried separately from the provider.
The spot margin account summary — a different margin system from the perpetuals one.
Margin interest rates for every borrowable asset.
See DpExchange.Gemini.Private.get_margin_rates/2. Three rates per currency, and taking
the hourly one for the annual is an error of four orders of magnitude that still looks
like a rate.
Every balance, each also valued in one notional currency.
See DpExchange.Gemini.Private.get_notional_balances/3. The quantity is the ledger; the
notional figure beside it is Gemini's valuation of that quantity, and reconciling a
position uses get_balances/2 instead.
Gemini publishes no per-method read.
/v1/payments/methods returns the whole set and there is no path taking a method
identifier. Filtering the listing here would answer with a snapshot while looking like a
read, which is exactly the difference get_payment_method/3 exists to draw.
Open positions on the perpetuals book.
See DpExchange.Gemini.Private.get_positions/2. Gemini sends a negative quantity for a
short and this returns a positive size with an explicit :side — a sign convention is a
fact about one venue's JSON, not about the market.
The roles this API key carries.
See DpExchange.Gemini.Private.get_roles/2. Three booleans rather than one role, because
Fund Manager and Trader combine and Auditor does not combine with anything.
Staked positions, one per asset.
See DpExchange.Gemini.Private.get_staking_balances/2. Three amounts, kept apart: the
whole position can be redeemable and none of it tradable.
Movements in and out of staked positions.
See DpExchange.Gemini.Private.get_staking_history/2. A redemption's progress travels
with it; nil is "not reported", not "complete".
What each provider pays for staking each asset.
Public. See DpExchange.Gemini.Rest.get_staking_rates/1 — percentages only, both named,
and :apy_pct never derived from :rate_pct.
Rewards accrued over a window.
See DpExchange.Gemini.Private.get_staking_rewards/2. The window travels with the number,
because the same number is a good day or a poor quarter without it.
The account's own traded volume, one row per symbol per day.
Recent public trades — the tape.
See DpExchange.Gemini.Rest.get_trades/2, including why type is the taker's side and
why broken trades are excluded unless asked for.
Everything that moved on this account — wider than fills and wider than transfers.
See DpExchange.Gemini.Private.get_transactions/2; summing it is not a balance.
Every subaccount in the group.
Venue-specific. See DpExchange.Gemini.Private.list_accounts/2 — the venue caps this at
500 and does not paginate.
The addresses this account may withdraw to on opts[:network].
See DpExchange.Gemini.Private.list_approved_addresses/2 — an address on the list can
still be time-locked.
Broker clearing orders — a different row shape from list_clearing_orders/1.
Clearing orders this account is party to.
See DpExchange.Gemini.Private.list_clearing_orders/2. Expiration and submission are
different windows.
Clearing trades — the orders that completed.
See DpExchange.Gemini.Private.list_clearing_trades/2. opts[:since_nanos] is
nanoseconds, unlike every other timestamp on this venue.
What Gemini charged this account for holding assets.
See DpExchange.Gemini.Private.list_custody_fees/2. An empty list is "nothing charged in
this window", never "no such fee".
Symbols currently carrying a promotional fee.
See DpExchange.Gemini.Rest.list_fee_promos/1 — not get_fees/2, which is the schedule
applying to a credential.
Funding payments this account actually paid or received.
See DpExchange.Gemini.Private.list_funding_payments/2. Not get_funding/2, which is
the contract's rate rather than this account's ledger.
The networks an asset moves over, or the assets a network carries.
See DpExchange.Gemini.Private.list_networks/2 — in particular why the network→assets
direction is scoped to the credential and an empty answer does not describe the network.
The funding sources this account can move fiat through.
Whether the environment opts resolves to moves real money.
Resolves opts[:environment] through the same precedence as every call this package
makes — an explicit option, then DpExchange.Core.Config, then :production — and
answers with DpExchange.Gemini.Environment.live?/1. Meant as a check a caller makes of
itself before a money-moving call such as place_order/3 or withdraw/5, in the same
spirit as capabilities/0's own declaration: the default is production and that is the
direction where a wrong guess costs money, so a caller that wants to be certain asks
rather than assumes.
if DpExchange.Gemini.live?(opts) do
# confirm with the human before placing this order
end
@spec next_funding_timestamp( String.t(), keyword() ) :: {:ok, DateTime.t()} | {:error, term()} | {:refused, term()}
When the next funding calculation lands, on its own.
See DpExchange.Gemini.Rest.next_funding_timestamp/2. The venue answers with a bare
integer here; get_funding/2 carries the same value beside the amounts.
Not supported. Gemini places one order per request.
Its multi-order surface is cancel_all_orders/2 and the session-scoped cancels, which
destroy rather than create. A caller placing several here calls place_order/3 several
times and reconciles the outcomes itself.
@spec preview_margin_order( map(), keyword() ) :: {:ok, map()} | {:error, term()} | {:refused, term()}
What a spot order would do to this account's margin. Places nothing.
See DpExchange.Gemini.Private.preview_margin_order/3, including which of amount and
total_spend the venue requires for each order shape.
Not supported. Gemini publishes no endpoint answering what an order would cost.
It does publish POST /v1/margin/order/preview — a margin impact preview, returning
pre- and post-order risk statistics for a hypothetical spot order. That is a different
question, and answering the cost question with margin statistics is the nearby
substitute this family refuses. It is reachable on its own terms, as
preview_margin_order/2.
Declared through supports_order_preview: false, so a consumer routes around it rather
than discovering the refusal at call time.
Quotes a conversion — Gemini's Instant quote. See
DpExchange.Gemini.Private.quote_conversion/4, including when it refuses to guess the
direction.
@spec refresh_access_token(String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Exchanges a refresh token for a new access token. Credential use, not consent.
Venue-specific. See DpExchange.Gemini.Private.refresh_access_token/3 — a different host
from every other endpoint, and the response carries a new refresh token that replaces
the one sent.
Removes address from the allowlist for network.
Renames a subaccount.
id is the subaccount's shortname and name is its new display name. To change the
shortname itself — which changes how the account is addressed — pass
opts[:shortname]; see DpExchange.Gemini.Private.rename_account/2.
Not supported. Gemini has no atomic replace; a caller cancels and re-places.
That is not equivalent — it opens a window in which no order is live — which is why
supports_order_replace: false is a claim about risk rather than convenience.
Asks the venue to allowlist address on network.
A successful response is not permission to withdraw — see
DpExchange.Gemini.Private.request_approved_address/5.
Revokes an access token. Only reachable with an OAuth token, not an API key.
Venue-specific. See DpExchange.Gemini.Private.revoke_access_token/2.
Stakes amount of asset. This moves funds.
See DpExchange.Gemini.Private.stake/4. opts[:provider_id] is required — the same asset
stakes with several providers at different rates.
Moves funds between two accounts at this venue. Not withdraw/5.
opts[:from] and opts[:to] are required.
Redeems amount of a staked asset. Returns before the redemption completes.
See DpExchange.Gemini.Private.unstake/4. :amount_remaining is non-zero for as long as
the asset is unbonding.
Endpoints the venue does not serve, as distinct from ones this package has not ported.
Both answer {:error, :not_supported}, and a caller acts the same way on either — but
they mean different things to anyone deciding what to build next, so they are told apart
here rather than flattened into one list.
Every entry is recorded with its source and the date consulted in
docs/reference/gemini/negative-claims.md.
Moves funds. Withdraws amount of asset over network to address.
See DpExchange.Gemini.Private.withdraw/6 — in particular the idempotency key this
always sends, and the memo requirement this package cannot check for you.