DpExchange.Gemini.Private (DpExchangeGemini v0.1.30)

Copy Markdown View Source

Gemini's authenticated endpoints — internal. Balances, orders and trade history.

The boundary, precisely

The host authenticates. This module makes authenticated requests with what the host hands it. Those are different jobs and the difference is the whole design: a caller passes credentials into every one of these functions, they are used to sign that one request, and nothing is kept. This module never obtains a credential, never stores one, never refreshes one, and never decides which authentication scheme applies — see Auth, which refuses to guess.

Which means trading works exactly as the facade intends: the host does auth, calls place_order/3 with credentials, and gets a Core.Types.Order back.

Gemini has no market orders, and this package will not fake one

From the venue's own page:

The API doesn't directly support market orders because they provide you with no price protection. Instead, use the "immediate-or-cancel" order execution option, coupled with an aggressive limit price (i.e. very high for a buy order or very low for a sell order), to achieve the same result.

That advice is not something a package may take on a caller's behalf. "An aggressive limit price" means inventing a number the caller did not supply and sending it to a live exchange as a real order. How aggressive? Ten percent through the book? Fifty? The package cannot know, the caller never said, and the difference is money.

So order_type: :market is {:error, {:unsupported_order_type, :market}} — the venue does not serve it, and the nearest thing requires a price only the caller can choose. A caller who wants that behaviour asks for it explicitly, with their own limit:

%{order_type: :limit, time_in_force: :ioc, price: my_aggressive_price, }

This is the family's named failure mode in its most expensive form. Every other instance in this codebase costs a wrong number in a chart; this one costs a fill at a price nobody chose.

Order execution options are mutually exclusive

If you specify more than one option (or an unsupported option) in the options array, the exchange will reject your order.

So time_in_force maps to exactly one option, or none:

time_in_forceGemini optionMeaning
:gtc (default)nonefills what it can, rests the remainder on the book
:post_only / :maker_or_cancelmaker-or-canceladds liquidity only, cancels if it would take
:iocimmediate-or-canceltakes what it can, cancels the rest
:fokfill-or-killfills entirely or cancels entirely

No option may be combined with a stop-limit order.

A cancelled order is not a failed request

MOC, IOC and FOK orders that do not fill come back 200 with "is_cancelled": true. That is the venue answering successfully; the order simply did not rest. It maps to status: :cancelled on a {:ok, order}, never to an error — a caller that treated it as a failure would retry an order the venue already handled.

Auth failures are refusals, not errors

Measured 2026-08-28 against the demo environment: an unauthenticated POST to any private endpoint returns 401 MissingSecurityHeaders. Gemini's own error table documents MissingApikeyHeader at 400, so the documented codes and the live ones disagree — the fourth documentation divergence found on this venue.

Either way 400, 401 and 403 are permanent for the request as sent: retrying the identical bytes cannot succeed. They are {:refused, reason}. A caller whose token expired refreshes it and calls again with new credentials, which is a different request — not a retry of this one.

Summary

Functions

Registers a bank account — /v1/payments/addbank, or /v1/payments/addbank/cad for a Canadian one.

Cancels open orders in bulk, at the scope the caller states.

Cancels a clearing order — POST /v1/clearing/cancel.

Cancels one order by the venue's order id.

Commits a quote by its quoteId, moving the assets.

Confirms a clearing order — POST /v1/clearing/confirm. This executes a trade.

Wraps or unwraps in one call — the venue's /v1/wrap/{symbol}.

Creates a subaccount — POST /v1/account/create.

Submits a broker-facilitated clearing order — POST /v1/clearing/broker/new.

Creates a bilateral clearing order — POST /v1/clearing/new.

What the venue would charge to withdraw amount of asset over network to address/v2/withdraw/{network}/{ticker}/feeEstimate.

The funding amount report as a spreadsheet — /v1/fundingamountreport/records.xlsx.

The funding payment report as JSON — /v1/perpetuals/fundingpaymentreport/records.json.

The funding payment report as a spreadsheet — /v1/perpetuals/fundingpaymentreport/records.xlsx.

The perpetuals margin account — POST /v1/margin.

The account's own record of itself — name, type, and the roles the key carries.

Every currency the account holds, with what is available and what is on hold.

One clearing order's state — POST /v1/clearing/status.

A fresh deposit address for asset on network/v1/deposit/{network}/newAddress.

The fee tier this account trades at, from /v1/notionalvolume.

The spot margin account summary — POST /v1/margin/account.

Margin interest rates for every borrowable asset — POST /v1/margin/rates.

Every balance, each also valued in one notional currency — /v1/notionalbalances/{currency}.

One order's current state.

Orders for this account — resting by default, closed with history: true.

Open positions — POST /v1/positions.

The roles this API key carries — POST /v1/roles.

Staked positions, one per asset — POST /v1/balances/staking.

Movements in and out of staked positions — POST /v1/staking/history.

Rewards accrued over a window — POST /v1/staking/rewards.

Past fills for a symbol.

The account's own traded volume, as the venue aggregates it — /v1/tradevolume.

Everything that moved on this account — /v1/transactions.

Transfers in and out of the account.

Every subaccount in the group — POST /v1/account/list.

The addresses this account may withdraw to on network/v1/approvedAddresses/account/{network}.

Broker clearing orders — POST /v1/clearing/broker/list.

Clearing orders this account is party to — POST /v1/clearing/list.

Clearing trades — POST /v1/clearing/trades.

What Gemini charged this account for holding assets — /v1/custodyaccountfees.

Funding payments credited to or debited from this account — POST /v1/perpetuals/fundingPayment.

The networks an asset moves over, or the assets a network carries.

The funding sources this account can move fiat through — /v1/payments/methods.

What a spot order would do to this account's margin — POST /v1/margin/order/preview.

Quotes a conversion between two assets, holding a rate the caller may then commit.

Exchanges a refresh token for a new access token — POST https://exchange.gemini.com/auth/token.

Removes address from the allowlist for network/v1/approvedAddresses/{network}/remove.

Renames a subaccount — POST /v1/account/rename.

Asks the venue to add address to the withdrawal allowlist for network/v1/approvedAddresses/{network}/request.

Revokes an access token — POST /v1/oauth/revokeByToken.

Stakes amount of assetPOST /v1/staking/stake.

Confirms the credentials reach the venue, using its own heartbeat endpoint.

Moves amount of asset between two accounts at this venue — /v1/account/transfer/{currency}.

Redeems amount of a staked assetPOST /v1/staking/unstake.

Withdraws amount of asset over network to address/v2/withdraw/{network}/{ticker}.

Functions

add_payment_method(details, credentials, opts)

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

Registers a bank account — /v1/payments/addbank, or /v1/payments/addbank/cad for a Canadian one.

Two endpoints, because the details differ by country. A US account is a routing and account number; a Canadian one adds an institution and transit number. opts[:country] selects, defaulting to the US endpoint — and a country this venue has no endpoint for is refused rather than sent to the wrong one, where the fields would be read as the other country's and the account registered wrong.

The venue verifies out of band. A successful response starts that; it does not finish it, and the method is not usable until the venue says so — which list_payment_methods/2 reports.

cancel_all_orders(credentials, opts)

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

Cancels open orders in bulk, at the scope the caller states.

Gemini publishes both scopes as separate endpoints:

:session  ->  /v1/order/cancel/session
:account  ->  /v1/order/cancel/all

opts[:scope] is required. The account scope reaches orders no API key placed — including ones a person entered through the web interface, which the venue says explicitly — and picking it for a caller who meant the session would cancel work nobody asked about. Gemini's own documentation recommends the session scope; that is guidance for the caller, not licence to choose here.

Returns the venue's own two lists. A non-empty rejected is not a failure of this call — the venue answered, and some of those orders were already gone. Reporting an error would tell a caller nothing was cancelled when most of it was.

cancel_clearing_order(clearing_id, credentials, opts)

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

Cancels a clearing order — POST /v1/clearing/cancel.

Only an unconfirmed order can be cancelled; once both sides have confirmed there is a trade, and a trade is not cancellable. The venue's result and details both travel, because details is where it says why a cancel did not take.

cancel_order(credentials, order_id, opts)

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

Cancels one order by the venue's order id.

commit_conversion(id, opts)

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

Commits a quote by its quoteId, moving the assets.

/v1/instant/execute. A quote past its window does not fill at the quoted rate — see Core.Types.Conversion, whose expires_at exists for exactly this. Ask Conversion.expired?/2 before committing; the venue is still the authority on whether a commit succeeds.

confirm_clearing_order(clearing_id, request, credentials, opts)

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

Confirms a clearing order — POST /v1/clearing/confirm. This executes a trade.

The venue re-asks for every term. symbol, amount, price and side are required alongside the clearing_id, and this package does not fill any of them in from the order it is confirming: the point of re-stating them is that the confirming side says what it believes it is agreeing to, and a package that read them back from the venue would confirm whatever the venue had, which is the one thing the check exists to prevent.

side here is the confirming party's own side, which is the opposite of the side the order was created with.

convert(from, to, amount, opts)

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

Wraps or unwraps in one call — the venue's /v1/wrap/{symbol}.

This is convert/4's one-step form and not the Instant pair. There is no quote to accept: the venue executes at its own price and reports the rate in the result. A caller that must see a price first uses quote_conversion/4 instead.

The direction comes from the two assets, exactly as it does for Instant, and it refuses the same way when neither orientation is determinable — pass opts[:symbol] and opts[:side] to say which.

Returns a Conversion already :settled. It has happened.

create_account(name, credentials, opts)

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

Creates a subaccount — POST /v1/account/create.

name is a display name and the venue answers with a different string. It returns account, a kebab-cased shortname derived from the name — spaces to hyphens, symbols removed, lower-cased — and that shortname is what every other endpoint's account parameter takes. A caller that kept the name it sent would address the wrong thing, or nothing.

opts[:type] is "exchange" or "custody", and the venue's own default when it is omitted is exchange. This package does not send one: choosing between an exchange account and a custody account for a caller who did not is choosing what the account can do.

Requires the Administrator role.

create_broker_clearing_order(request, credentials, opts)

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

Submits a broker-facilitated clearing order — POST /v1/clearing/broker/new.

Not create_clearing_order/3: a broker order names both counterparties and the broker is neither of them. opts[:source_counterparty_id] and opts[:target_counterparty_id] are both required, and so is opts[:expires_in_hrs] — the venue marks it required here where it is optional on the bilateral form.

side is assigned to the source, and the opposite side goes to the target. Passing the two counterparties the wrong way round produces a valid order in which each side is trading the direction the other meant.

The venue answers AwaitSourceTargetConfirm: both parties still have to confirm.

create_clearing_order(request, credentials, opts)

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

Creates a bilateral clearing order — POST /v1/clearing/new.

This is not place_order/3. A clearing order does not go to the book: it is one half of a trade agreed directly with a named counterparty, and it does nothing until that counterparty confirms it. A caller that treated a successful response as a fill has a position it does not have.

symbol, amount, price and side are required by the venue and are not defaulted. opts[:counterparty_id] names the other side; opts[:expires_in_hrs] bounds how long the offer stands.

is_confirmed on the response is the field that matters. false means the trade has not happened; the order sits until the counterparty confirms it or it expires.

estimate_withdrawal_fee(asset, network, amount, credentials, opts)

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

What the venue would charge to withdraw amount of asset over network to address/v2/withdraw/{network}/{ticker}/feeEstimate.

The address is part of the estimate, not decoration: fees differ by destination on some networks, so an estimate for one address does not hold for another.

This moves no funds. withdraw/6 does.

funding_amount_report(symbol, credentials, opts)

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

The funding amount report as a spreadsheet — /v1/fundingamountreport/records.xlsx.

Returns the venue's bytes unparsed, as {:ok, binary}. This package ships no spreadsheet reader and will not grow one: a parsed cell is a number this package chose from a layout the venue can change without notice, and the file is what the venue actually issued.

symbol is required by the venue. opts[:from] and opts[:to] must be given together or not at all — the venue makes each mandatory if the other is present, and sending one alone is refused here rather than silently returning a differently-bounded report.

funding_payment_report(credentials, opts)

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

The funding payment report as JSON — /v1/perpetuals/fundingpaymentreport/records.json.

The query string is part of what is signed. Gemini's private GETs put the full path, query string included, in the signed request field; signing the bare path produces a valid signature over the wrong string, and the venue reports that as a credential problem rather than a parameter one. This builds the query once and uses the same string in both places.

opts[:from] and opts[:to] are dates, opts[:rows] a count. The venue's own default is 8760 rows — a year of hourly funding — and this package does not send one, because a page size chosen here would silently truncate a report the caller asked for in full.

funding_payment_report_file(credentials, opts)

@spec funding_payment_report_file(
  map(),
  keyword()
) :: {:ok, binary()} | {:error, term()} | {:refused, term()}

The funding payment report as a spreadsheet — /v1/perpetuals/fundingpaymentreport/records.xlsx.

The same bytes-not-cells rule as funding_amount_report/3, and the same account scope as funding_payment_report/2. Takes no symbol: a funding payment belongs to the account, not to one contract.

get_account_margin(credentials, opts)

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

The perpetuals margin account — POST /v1/margin.

Collateral, leverage, buying and selling power, and the estimated liquidation price, which get_positions/2 does not publish. A caller judging how much room is left reads it here.

Returned as the venue's own map. Its eleven fields divide margin four ways — by position, by open order, by buy side and by sell side — and a struct that kept only a total would drop the split a caller sizing its next order needs.

get_accounts(credentials, opts)

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

The account's own record of itself — name, type, and the roles the key carries.

get_balances(credentials, opts)

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

Every currency the account holds, with what is available and what is on hold.

get_clearing_order(clearing_id, credentials, opts)

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

One clearing order's state — POST /v1/clearing/status.

Read is_confirmed, not status. The status string is the venue's own description and the boolean is the fact: an order that is not confirmed has not traded, whatever the description says.

get_deposit_address(asset, network, credentials, opts)

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

A fresh deposit address for asset on network/v1/deposit/{network}/newAddress.

The network is not optional and must not be guessed. An address generated for the wrong chain still looks like an address; funds sent to it on another chain are gone. list_networks/2 is how a caller learns which networks this venue credits for an asset.

opts[:label] names the address at the venue. opts[:legacy] asks for a legacy P2SH-P2PKH Litecoin address, which is the venue's own flag and defaults to false.

memo_required is nil, not false. Some networks — Solana, XRP, Cosmos — need a destination tag or the deposit is unattributable, and this endpoint's response does not say whether this one does. false would be a claim that no memo is needed; nil says this package does not know, and a caller must check the network before sending.

get_fees(credentials, opts)

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

The fee tier this account trades at, from /v1/notionalvolume.

Returned as the venue states it — basis points, per maker/taker, alongside the notional volume that determined the tier. Nothing is converted to a rate, because the venue's own units are what a caller will reconcile against.

get_margin_account(credentials, opts)

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

The spot margin account summary — POST /v1/margin/account.

Not get_account_margin/2: that one is the perpetuals account. These are two margin systems on one venue, and their fields are named differently on purpose — this one nests every amount as %{"currency" => _, "value" => _} where the perpetuals one sends bare decimals in dollars.

Returned as the venue's own map for that reason. Flattening the currency off an amount is how a caller ends up adding a BTC number to a USD one.

get_margin_rates(credentials, opts)

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

Margin interest rates for every borrowable asset — POST /v1/margin/rates.

Three rates per currency, and they are not three ways of saying one thing. The venue publishes borrowRate hourly, borrowRateDaily as hourly × 24 and borrowRateAnnual as daily × 365, and all three travel: a caller that took the hourly rate for an annual one would be out by four orders of magnitude, and the number would still look like a rate.

lastUpdated is milliseconds, and it matters — a borrow rate is a moving quote, not a schedule.

get_notional_balances(credentials, currency, opts)

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

Every balance, each also valued in one notional currency — /v1/notionalbalances/{currency}.

Not get_balances/2 in another unit. The amount is the venue's ledger; the amountNotional beside it is Gemini's own valuation of that quantity, at a rate Gemini chose and does not publish here. Rows are returned as the venue sends them so the two cannot be read as one number.

The currency is a path segment, not a parameter. Gemini documents usd; anything else is sent as given and the venue answers for itself, because a package that allowed only the documented one would be wrong the day a second is added.

get_order(credentials, order_id, opts)

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

One order's current state.

get_orders(credentials, opts)

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

Orders for this account — resting by default, closed with history: true.

Two endpoints, not one with a filter. /v1/orders returns what is still on the book; /v1/orders/history returns what is not. A caller asking for "orders" without saying which gets the resting ones, the set that can still change.

History accepts symbol: and limit: (the venue's limit_orders, default 50, max 500) and a since: DateTime. The venue's own default applies where the caller gives none — this does not substitute one of its own, because a page size chosen here would silently become the caller's answer.

get_positions(credentials, opts)

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

Open positions — POST /v1/positions.

Gemini sends a negative quantity for a short, and Types.Position refuses to carry one: :quantity is the size, always positive, and :side says which way. A sign convention is a fact about one venue's JSON, not about the market, and a package that passed it through would hand a caller a position that is exactly backwards while every number in it stays plausible.

notional_value is negative for shorts too, and it is kept as the venue sent it — that one is a signed value rather than a magnitude with a direction beside it, and flipping it would change what the number means.

Realised and unrealised P&L stay apart. One has happened; the other is a mark-to-market opinion that may never be realised.

liquidation_price is nil here: /v1/positions does not publish one. That does not mean the position is safeget_account_margin/2 publishes estimated_liquidation_price for the account, which is where a caller must look.

symbol is read through SymbolFormat, not carried raw. The venue's own example response sends it lowercase ("btcgusdperp"), the same case /v1/symbols uses — this used to pass row["symbol"] straight onto the struct unchanged, so a real position arrived as symbol: "btcgusdperp" beside to_order/1's and every other reader's uppercase form. Nothing here failed loudly: the test fixtures that exist all wrote "BTCGUSDPERP" by hand, and none of them ever asserted on position.symbol at all, so the venue's real casing was never exercised. Perpetuals take the same :nomatch path through CanonicalPair.to_canonical/2 that produces the uppercased, unsplit form SymbolFormat's own moduledoc documents — to_canonical_symbol/1 is the same conversion to_order/1 already applies to a symbol from the same family of endpoints.

get_roles(credentials, opts)

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

The roles this API key carries — POST /v1/roles.

Returns the venue's own booleans: isAuditor, isFundManager, isTrader. Auditor cannot be combined with the others, and Fund Manager and Trader can — which is why three booleans rather than one role.

This is the call that answers "will the venue let this key do that", and asking it is cheaper than discovering a missing role from a refused withdrawal.

get_staking_balances(credentials, opts)

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

Staked positions, one per asset — POST /v1/balances/staking.

Three amounts, kept apart. A real response carries balance: 10, available: 0, availableForWithdrawal: 10 — the whole position is redeemable and none of it is tradable. A caller reading a single "available" would size an order against ten and place it against zero, which is why StakingBalance refuses to collapse them.

A missing state is nil, not zero. A venue that did not report one has not said it is none, and this package will not say it for it.

Zero-balance rows are kept. The host adapter this package replaces dropped them, which makes "the venue reports no position in ETH" and "the account holds nothing in ETH" the same answer; they are not.

get_staking_history(credentials, opts)

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

Movements in and out of staked positions — POST /v1/staking/history.

A redemption is a process, not an event. Rows carry amount, amountPaidSoFar and amountRemaining, and the three differ for most of a redemption's life while the asset unbonds. All three travel; nil on the last two means the venue does not report progress, not that the operation is complete.

:type is the normalised atom and :venue_type keeps Gemini's own word — Deposit, Redeem, Interest and others. A normalisation that loses the original cannot be audited when it turns out to be wrong, and an unrecognised word maps to :other rather than to the nearest one that fits.

get_staking_rewards(credentials, opts)

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

Rewards accrued over a window — POST /v1/staking/rewards.

The window is part of the value. The same number is a good day or a poor quarter depending on it, so opts[:since] and opts[:until] are sent to the venue and the bounds the venue reports back travel on the struct. Where the venue reports none, they stay nil rather than being filled in from the request — the venue is free to clamp a window, and a package that echoed the ask would report a period that was never served.

:apy_pct is the rate at accrual, which is not what get_staking_rates/1 reports today. That is what lets a caller reconcile a reward against the rate that produced it.

get_trade_history(credentials, opts)

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

Past fills for a symbol.

Gemini requires a symbol here — there is no all-symbols variant — so a caller asking for everything is asking for one request per symbol, and it is theirs to decide whether to.

opts[:since] accepts a DateTime, converted to the venue's own unit — milliseconds, per its own request examples (timestamp: 1591084414000) — the same conversion every other filtered endpoint in this module uses. This used to reach maybe_put/3 instead, which stringifies whatever it is handed rather than converting it: a DateTime became "2026-08-28 17:00:01Z" on the wire, a shape the venue's timestamp field does not parse, so the filter silently failed to narrow anything rather than erroring — the same bug get_orders/2's history_params/1 already carries the fix for. opts[:limit] had the same defect for limit_trades, sent as "100" instead of the documented integer.

get_trade_volume(credentials, opts)

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

The account's own traded volume, as the venue aggregates it — /v1/tradevolume.

One row per symbol per day, with the maker and taker breakdown the venue's fee tiers are computed from. Not get_trade_history/2 summed: this venue requires a symbol on every fills request, so reproducing this means one request per symbol per period, and the answer would still be this package's arithmetic against the venue's ledger.

Rows come back as the venue sends them. The fields differ enough between venues that a normalised struct would be mostly nil, and a caller reading buy_maker_notional wants the venue's number under the venue's name.

get_transactions(credentials, opts)

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

Everything that moved on this account — /v1/transactions.

Wider than get_trade_history/2 and wider than get_transfers/2: fees, interest, credits and adjustments alongside deposits and fills. Rows are the venue's own, because the kinds do not share a shape.

opts[:since] and opts[:limit] narrow it, in the venue's own names.

Summing this is not a balance. get_balances/2 is the authority; this explains it.

get_transfers(credentials, opts)

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

Transfers in and out of the account.

Calls /v2/transfers. The v1 path this package used until the D6 migration is absent from Gemini's published OpenAPI document, and v2's own description says why: "The v1 transfers endpoint is being retired. This v2 endpoint is the recommended" replacement.

The three parameters are unchanged — currency, timestamp, limit_transfers — so this was a path swap and nothing more. v2 additionally accepts network, account and show_completed_deposit_advances, and each returned transfer now carries a network field naming the chain. None of that is surfaced here yet; the rows are passed through.

list_accounts(credentials, opts)

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

Every subaccount in the group — POST /v1/account/list.

The venue caps this at 500 and does not paginate. limit_accounts is both the maximum and the default, so a group with more than 500 subaccounts returns a truncated list with nothing to say it was truncated. This package sends no limit unless asked, and states the cap here because there is no cursor to follow.

Each row's account is the kebab-cased shortname other endpoints address by; name is the display name. counterparty_id is None on a custody account — the venue's own string, not nil.

list_approved_addresses(credentials, opts)

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

The addresses this account may withdraw to on network/v1/approvedAddresses/account/{network}.

An address on this list is not necessarily usable yet. The venue reports status: "pending-time" for one still inside its time lock, and a withdrawal to it is refused. Core.Types.ApprovedAddress.usable?/2 answers that, and returns nil where the venue gave a pending status with no activation time — unknown, not "ready".

opts[:network] selects; there is no all-networks variant, because the venue keeps a list per network.

list_clearing_brokers(credentials, opts)

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

Broker clearing orders — POST /v1/clearing/broker/list.

Separate from list_clearing_orders/2 because the rows are a different shape: a broker order names a source and a target counterparty and a source_side, where a bilateral order names one counterparty and one side. Merging the two would leave a caller reading side on a row that has none.

list_clearing_orders(credentials, opts)

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

Clearing orders this account is party to — POST /v1/clearing/list.

Every filter is optional and none is defaulted: opts[:symbol], opts[:counterparty] (which takes an id or an alias), opts[:side], and four timestamp bounds — expiration_start, expiration_end, submission_start and submission_end.

Expiration and submission are different windows. An order submitted last week can expire tomorrow, and filtering on the wrong one returns a real list that is not the one asked for.

list_clearing_trades(credentials, opts)

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

Clearing trades — POST /v1/clearing/trades.

Orders that completed, where list_clearing_orders/2 shows what is outstanding. Rows are camelCase here and snake_case there; the venue's own keys are kept either way rather than normalised into one shape that matches neither response.

opts[:limit] maps to the venue's limit_per_account — default 100, maximum 300 — and opts[:since_nanos] to timestamp_nanos, which is nanoseconds, not the milliseconds every other Gemini timestamp uses.

list_custody_fees(credentials, opts)

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

What Gemini charged this account for holding assets — /v1/custodyaccountfees.

Custody fees come straight out of the balance with no trade behind them, so a consumer reconciling balances against fills alone finds a gap this is the only explanation for.

An empty list means nothing was charged in the window asked for. It does not mean the account holds nothing in custody, and it does not mean Gemini does not charge — an account with no custody balance and an account billed nothing this period return the same thing.

opts[:since] and opts[:limit] page it, under the venue's own parameter names.

list_funding_payments(credentials, opts)

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

Funding payments credited to or debited from this account — POST /v1/perpetuals/fundingPayment.

Not get_funding/2. That is the contract's rate; this is what this account actually paid or received. A caller reconciling a balance needs the second, and computing it from the first plus a position size is this package's arithmetic rather than the venue's ledger.

Rows are the venue's own. Each carries actionCredit or Debit — beside a positive quantity, so the direction is in the action and not in the sign. Normalising it into a signed number here would drop the venue's own word for it.

The venue notes instrumentSymbol is attached only to records from 16 April 2024 onwards; older rows have none, and this package does not fill one in.

list_networks(asset, opts)

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

The networks an asset moves over, or the assets a network carries.

Call this before get_deposit_address/3. That endpoint takes a network, and a wrong one produces an address on a chain this venue does not credit — funds sent there are gone.

Two directions and two endpoints, both authenticated GETs:

list_networks("USDC", credentials: creds)              GET /v2/network/USDC
list_networks(nil, network: "…", credentials: creds)   GET /v2/networks/{network}/assets

Both directions were dead until 2026-09-05, in two different ways. The asset direction used to be documented "Public" and delegate to Rest.networks_for_asset/2, which sends no credentials — that is Rest's whole design. Measured live: an unauthenticated GET /v2/network/BTC returns 401 MissingSecurityHeaders, and the vendor's OpenAPI requires apiKeyAuth, signatureAuth and payloadAuth on it, so this direction could never succeed for any consumer. The network direction had its own, independent bug: it POSTed to /v2/networks/{network}/assets, and the vendor documents that route as GET (operationId: getAssetsForNetwork) — there is no POST form. Both now go through signed_get/3, the helper this module already used for exactly this request shape elsewhere.

Both are scoped to the credential. The vendor requires the Fund Manager or Auditor role and states the network direction returns "only the assets where your account has deposit and withdraw access enabled". So an empty answer means this account cannot move anything on that network, not that the network carries nothing — a caller reading it as a description of the network would draw the wrong conclusion from a true response.

list_payment_methods(credentials, opts)

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

The funding sources this account can move fiat through — /v1/payments/methods.

Rows are the venue's own. A method being listed is not the same as being usable: a bank account added through add_payment_method/2 sits pending verification, and the status is in the row.

place_order(credentials, request, opts)

@spec place_order(map(), map(), keyword()) ::
  {:ok, DpExchange.Core.Types.Order.t()} | {:error, term()} | {:refused, term()}

Places an order.

request is a map carrying at least :symbol, :side, :quantity and :price. :order_type defaults to :limit; :time_in_force defaults to :gtc. A :client_order_id is passed through when given and is strongly recommended by the venue.

Refuses rather than substituting:

  • order_type: :market — the venue serves none, and the documented workaround needs a limit price only the caller can choose
  • an unknown :time_in_force — the venue rejects an unsupported option outright
  • a missing price on a type that requires one

preview_margin_order(request, credentials, opts)

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

What a spot order would do to this account's margin — POST /v1/margin/order/preview.

Places nothing. It returns the account's margin statistics before and after the hypothetical order, as preorder and postorder, and a caller reads the difference.

symbol, side and type are required by the venue and are not defaulted here. The fourth parameter depends on the first three and the venue states which: amount for a limit order or a market sell, totalSpend for a market buy, and price for a limit order. Sending the wrong one is refused up front rather than sent — a preview computed against a quantity the caller did not mean is a number that looks right.

quote_conversion(from, to, amount, opts)

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

Quotes a conversion between two assets, holding a rate the caller may then commit.

This is Gemini's Instant pair, /v1/instant/quote then /v1/instant/execute. The venue states the price, the quantity, the fee and a maxAgeMs, and holds that rate for the window — typically 60 seconds. Nothing has moved until commit_conversion/2.

Which of the two assets is the pair, and why this refuses more often than you expect

The venue takes a symbol and a side, not a from/to pair, and the two are not interchangeable: totalSpend is CCY2 on a buy and CCY1 on a sell. So DAI -> BTC is buy BTCDAI spending DAI, and BTC -> DAI is sell BTCDAI spending BTC.

Deriving that needs to know which of the two is the pair's quote side, and this venue quotes in crypto as well as fiatSymbolFormat.quotes/0 lists BTC, ETH, SOL and FIL alongside USD and the stablecoins. So for USD -> BTC both assets are quote currencies, both orientations parse, and only the venue's catalogue says which pair exists.

It refuses with {:ambiguous_conversion, from, to} rather than picking one, and that includes the common USD -> BTC. Choosing wrongly spends the wrong asset, which is a real loss rather than a wrong-looking number, and this package will not resolve it by fetching a catalogue behind the caller's back.

So pass opts[:symbol] and opts[:side]. Derivation is the convenience for the case where exactly one side is a quote currency, not the main path.

The expiry comes from the venue's maxAgeMs measured from the response's own Date header, not from the local clock. A quote whose window is computed against a clock the venue does not share is a quote that expires at the wrong time.

refresh_access_token(client_id, refresh_token, opts)

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

Exchanges a refresh token for a new access token — POST https://exchange.gemini.com/auth/token.

This is credential use, not consent. The browser redirect that obtains the first authorization code belongs to the host and is not here; refreshing a token the host already holds is the same category as Schwab's Auth.refresh/2, and a package that could not do it would leave a consumer unable to keep a session alive.

A different host from every other endpointexchange.gemini.com, not api.gemini.com — and a form body rather than Gemini's signed payload. It is the same URL the host's initial code exchange posts to, separated only by grant_type, which is why the package/host split cannot be read off a path.

The response carries a new refresh token and the old one stops working. A caller that stores the access token and keeps the old refresh token has a session that ends at the next refresh.

client_secret is sent only when given: the venue documents it for confidential clients and says public clients must not send it.

remove_approved_address(network, address, credentials, opts)

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

Removes address from the allowlist for network/v1/approvedAddresses/{network}/remove.

Generally immediate where addition is not: the venue is slow to widen what funds may reach and quick to narrow it.

rename_account(credentials, opts)

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

Renames a subaccount — POST /v1/account/rename.

Two different things can be renamed and they are not the same field. opts[:name] is the display name; opts[:shortname] is the kebab-cased account string every other endpoint addresses by. Changing the second changes how the account is addressed, and a caller with a stored shortname will stop finding it.

Either or both; neither is {:error, :nothing_to_rename} rather than a call that changes nothing and reports success. The venue returns only the fields that changed.

opts[:account] names which subaccount to rename and is required on a master key.

request_approved_address(network, address, label, credentials, opts)

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

Asks the venue to add address to the withdrawal allowlist for network/v1/approvedAddresses/{network}/request.

A successful response is not permission to withdraw. The venue holds a new entry under a time lock and reports it as pending-time until the lock lifts; a withdrawal to it before then is refused. Read the list back with list_approved_addresses/2 and check ApprovedAddress.usable?/2, which answers nil while the venue states no activation time.

revoke_access_token(credentials, opts)

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

Revokes an access token — POST /v1/oauth/revokeByToken.

Only reachable with an OAuth token, not with an API key: the endpoint revokes the token that authenticates the call. A credential map without access_token is refused here rather than sent, because an API-key-signed call would revoke nothing and report success shape.

Once revoked the token cannot be used again, and neither can any request already in flight that had not reached the venue.

stake(asset, amount, credentials, opts)

@spec stake(String.t(), Decimal.t(), map(), keyword()) ::
  {:ok, DpExchange.Core.Types.StakingTransaction.t()}
  | {:error, term()}
  | {:refused, term()}

Stakes amount of assetPOST /v1/staking/stake.

This moves funds. The decision belongs to the consumer; this package carries it out and reports what the venue said.

opts[:provider_id] names the provider. It is required, because the same asset can be staked with several at different rates and picking one here would stake at a rate the caller never chose. Missing it is {:error, :missing_provider_id} before a request is made.

test_connection(credentials, opts)

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

Confirms the credentials reach the venue, using its own heartbeat endpoint.

A real round trip rather than a guess: /v1/heartbeat is authenticated, so a success proves the key, the signature and the nonce mode are all right. It also resets the session's cancel-on-disconnect timer, which is a side effect worth knowing about — on a key provisioned with Requires Heartbeat, calling this keeps open orders alive.

transfer_internal(asset, amount, transfer_opts, credentials, opts)

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

Moves amount of asset between two accounts at this venue — /v1/account/transfer/{currency}.

Not withdraw/5. Nothing leaves the venue and no chain is involved, so there is no address, no network and no network fee. opts[:from] and opts[:to] are the venue's own account names and both are required: a transfer with one end missing is not a transfer, and defaulting either would move funds between accounts the caller did not name.

unstake(asset, amount, credentials, opts)

@spec unstake(String.t(), Decimal.t(), map(), keyword()) ::
  {:ok, DpExchange.Core.Types.StakingTransaction.t()}
  | {:error, term()}
  | {:refused, term()}

Redeems amount of a staked assetPOST /v1/staking/unstake.

Returns immediately; the redemption does not complete immediately. The returned transaction carries :amount_remaining, non-zero for as long as the asset is unbonding. A caller treating the return value as settled will spend an asset it does not have yet.

opts[:provider_id] is required for the same reason it is on stake/4: redeeming from the wrong provider redeems at the wrong rate, and a default gives the caller no way to notice.

withdraw(asset, network, amount, address, credentials, opts)

@spec withdraw(String.t(), String.t(), Decimal.t(), String.t(), map(), keyword()) ::
  {:ok, DpExchange.Core.Types.Withdrawal.t()}
  | {:error, term()}
  | {:refused, term()}

Withdraws amount of asset over network to address/v2/withdraw/{network}/{ticker}.

This moves funds and cannot be undone. Everything below exists because of that.

A retry without an idempotency key withdraws twice

The venue accepts clientTransferId, "a unique UUID for idempotent withdrawals. If provided, duplicate requests with the same clientTransferId will not create additional withdrawals." It is optional at the venue and not optional here: this generates one when the caller gives none.

A withdrawal request that times out has an unknown outcome — the funds may already be moving. Without a key, the safe-looking response (retry) is the one that sends the money again. opts[:client_transfer_id] lets a caller supply its own so a retry across a process restart is still the same request.

The memo is required on some networks and this package cannot tell you which

The venue: "Required for certain networks that use memos (e.g., Solana, XRP, Cosmos)." It publishes no machine-readable list, so this does not guess one. A withdrawal to an exchange address on a memo network without one is credited to nobody and is generally not recoverable.

opts[:memo] is passed through. opts[:memo_required] is a caller's assertion, not a lookup: passing true with no memo is refused here rather than sent.

Three preconditions the venue states

  1. The account has an approved address list
  2. The destination is already on itlist_approved_addresses/2, and note that an address can be present and still time-locked
  3. The API key carries the Fund Manager role

None can be checked from here without spending a request, and all three fail at the venue with a message; they are stated so a caller can check them before it gets there.