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
Seven candle widths, and the venue's own documentation names three of them wrong.
The accepted set is 1m 5m 15m 30m 1h 6h 1d in canonical form. 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. 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. 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.
Quote timestamps come from the venue's clock, or the call fails. Neither Gemini
ticker publishes a quote timestamp — /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.
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())