DpExchange.Coinbase.Rest (DpExchangeCoinbase v0.1.27)

Copy Markdown View Source

Coinbase Advanced Trade REST — internal. Nothing here is public API; the facade is DpExchange.Coinbase.

Credentials choose the endpoint, they do not gate it

Coinbase serves the same market data two ways: /products/{id}/ticker is account-scoped and needs a Bearer JWT, /market/products/{id}/ticker is public. This module picks the authenticated path when it is given credentials and the public one when it is not.

That is the facade's rule made concrete — a caller passes credentials or does not, and reads the consequence from capabilities/0. It is also a fix for a real incident: the price-collection task did not pass credentials, hit the authenticated path anyway, and produced 315 Unauthorized warnings overnight on 2026-04-30.

Historical candles are public

/market/products/{id}/candles, no auth. The authenticated variant 401s on every backfill call, which is what it did until 2026-07-02.

This module cannot fabricate

There is no fallback path, no test-mode branch and no hardcoded price table. A request that fails returns an error. The adapter this was ported from had a generate_fallback_candles/4 that invented OHLC from a table of base prices; its error path was fixed in May 2026 after fabricated candles were traced to phantom profits in backtests, but the generator survived behind a node-wide test flag. It is not here in any form — see docs/reference/coinbase/reconciliation.md.

A caller wanting deterministic candles uses this package's fake, selected per process, which is a real implementation of the facade rather than a branch inside the live one.

Summary

Functions

Cancels the pending sweep — DELETE /cfm/sweeps.

Flattens an open position on symbol by having the venue place the closing order.

Commits a quoted conversion — POST /convert/trade/{trade_id}. This moves funds.

Creates a portfolio — POST /portfolios.

Deletes a portfolio — DELETE /portfolios/{portfolio_uuid}.

The venue's own account records, unnormalised.

The venue's own declared alias relationships between listed products, as a bidirectional map of canonical symbol to canonical symbol.

Every balance the credential can see, one per account.

A conversion's current state — GET /convert/trade/{trade_id}.

Which margin window the account is in now — GET /cfm/intraday/current_margin_window.

The fee schedule that applies to this credential — GET /transaction_summary.

The futures account's balances and margin — GET /cfm/balance_summary.

One futures position by product — GET /cfm/positions/{product_id}.

Historical candles for symbol at timeframe.

The account's intraday margin setting — GET /cfm/intraday/margin_setting.

What this API key is allowed to do — GET /key_permissions.

A bulk snapshot across every product Coinbase lists: price, 24h change, 24h volume, 24h high/low and status, one entry per canonical symbol.

One order by its venue id.

The order book for symbolGET /product_book.

Orders, most recent first.

One funding source by id — GET /payment_methods/{payment_method_id}.

One portfolio's full breakdown — GET /portfolios/{portfolio_uuid}.

Open futures positions — GET /cfm/positions.

The current price for symbol.

One product's full record, as the venue publishes it.

The venue's own clock — GET /brokerage/time.

Every product Coinbase lists, as canonical symbols.

Best bid and ask for symbol, with the sizes.

Past fills for the credential — GET /orders/historical/fills.

What this account has traded — GET /transaction_summary.

Recent public trades for symbol — the tape.

Every timeframe Coinbase serves, shortest first.

The venue's own futures position rows — GET /cfm/positions.

Pending and processing sweeps — GET /cfm/sweeps.

Every product Coinbase lists, with the fields get_symbols/1 discards: base, quote, instrument type and trading status.

The funding sources this account can move fiat through — GET /payment_methods.

The portfolios this credential can address — GET /portfolios.

The most candles Coinbase will return for one request. A hard boundary, not a hint.

Previews an order without placing it.

Prices an amendment to a working order without making it.

What the venue will actually accept for symbolGET /products/{product_id}.

Quotes a conversion — POST /convert/quote. Nothing moves.

Renames a portfolio — PUT /portfolios/{portfolio_uuid}.

Changes the price or size of a working order.

Schedules a sweep from the futures account to the spot one — POST /cfm/sweeps/schedule.

Sets the account's intraday margin setting — POST /cfm/intraday/margin_setting.

Whether the venue is reachable and the credential, if given, is accepted.

Moves funds between two of this account's portfolios — POST /portfolios/move_funds.

Functions

cancel_futures_sweep(credentials, opts)

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

Cancels the pending sweep — DELETE /cfm/sweeps.

Singular. The venue cancels the pending sweep and takes no id; a caller with a queue cannot choose which one this reaches. list_futures_sweeps/2 before and after is the only way to see what happened.

cancel_order(credentials, order_id, opts)

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

Cancels an order.

The venue has no single-order cancel, and the batch one refuses per order

Coinbase cancels through POST /orders/batch_cancel, which takes order_ids and answers with a results array — one entry per id, each with its own success and failure_reason. A batch of one is still a batch, so the HTTP call succeeding says nothing about whether the order was cancelled.

A caller asking to cancel one order gets one answer: the result for that id, or a refusal carrying the venue's reason. An order already filled or already cancelled comes back as a refusal rather than an :ok, because "I cancelled it" and "it was not there to cancel" are different facts and a caller retrying on the second is chasing nothing.

close_position(credentials, symbol, opts)

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

Flattens an open position on symbol by having the venue place the closing order.

This places an order. The venue works out the side and the size from the position it holds, which is the whole point: a caller doing get_positions/1 then place_order/3 sizes against the position as of its last read, and a position that moved in between leaves a residue or overshoots into a position the other way. Only the venue closes to exactly zero.

size is optional and partial-closes when given, in contracts, not base units — the venue's own wording. Omitted, the whole position goes.

The response envelope is /orders's, so a refusal arrives as a 200 with "success" => false and is returned as {:refused, …}.

The returned Order carries no side. The venue does not echo one and this package will not infer it: the side of a closing order is the opposite of a position whose direction was never read here, and guessing it is exactly the substitution this family refuses.

commit_conversion(credentials, trade_id, opts)

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

Commits a quoted conversion — POST /convert/trade/{trade_id}. This moves funds.

The venue re-asks for both accounts, and this package does not fill them in from the quote: opts[:from] and opts[:to] are required and refused when missing. Committing against accounts the caller did not name is how a conversion happens between the wrong two balances.

create_portfolio(credentials, opts)

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

Creates a portfolio — POST /portfolios.

opts[:name] is required and is not defaulted: an unnamed portfolio is one a caller cannot tell from another later, and the venue has no notion of a nameless one.

delete_portfolio(credentials, portfolio_uuid, opts)

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

Deletes a portfolio — DELETE /portfolios/{portfolio_uuid}.

This is irreversible from this package's side, and the venue refuses it while the portfolio holds funds or open orders — which is the venue's guard, not this one's. A caller emptying a portfolio first should use transfer_internal/4, and should read list_portfolios/2 afterwards rather than assume: the venue keeps deleted portfolios in the listing with deleted: true, because old orders still name them.

get_accounts(credentials, opts)

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

The venue's own account records, unnormalised.

Separate from get_balances/2 because an account is more than a number: it carries a uuid, a platform (CONSUMER, CFM_CONSUMER, INTX), whether it is ready to trade, and which portfolio it belongs to. A caller routing an order needs the uuid; a caller sizing one needs the balance. Collapsing them would lose the first.

With opts[:uuid] this reads the single account (GET /accounts/{account_uuid}); without, it pages the list as get_balances/2 does.

get_alias_map(opts)

@spec get_alias_map(keyword()) ::
  {:ok, %{required(String.t()) => String.t()}} | {:error, term()}

The venue's own declared alias relationships between listed products, as a bidirectional map of canonical symbol to canonical symbol.

Why this exists — measured live, 2026-09-05

Subscribing the streaming ticker channel to XLM-USDC and AVAX-USDC against wss://advanced-trade-ws.coinbase.com delivers every frame tagged XLM-USD and AVAX-USD — the venue's own subscription acknowledgement even echoes the rewritten names back ("ticker" => ["XLM-USD", "AVAX-USD"]), not the ones actually sent. This is not an accident of one pair: this same catalogue call, read on the same date, shows 112 of the first 114 USDC products carrying a non-empty alias naming their -USD counterpart —

{"product_id":"XLM-USDC", ..., "alias":"XLM-USD"}
{"product_id":"XLM-USD",  ..., "alias":"", "alias_to":["XLM-USDC"]}

— so a caller subscribed under the alias form receives nothing under the name it asked for while a name it never asked for floods in. DpExchange.Coinbase.Feed is what uses this map to attribute a delivered frame back to whatever the caller actually subscribed; see its moduledoc for the mechanism and the measured consumer impact.

Built from alias alone, not alias_to

Every aliased pair appears in the same catalogue response from both sides — the aliased row states alias, its target states the reverse via alias_to. Reading only alias and inserting both directions here is complete: nothing alias_to would add is missing, and reading only one field is one fewer place for the two to disagree.

{:error, _} is a real possibility, and callers must not guess through it

This is a bulk catalogue read like get_symbols/1, subject to the same failures — see Feed's moduledoc for what it does when this call fails: deliver under the venue's own id, same as before this map existed, plus a notice that attribution is degraded and why. Never a fabricated mapping.

get_balances(credentials, opts)

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

Every balance the credential can see, one per account.

The venue reports available_balance and hold; it does not report a total. The total here is their sum, which is arithmetic on two numbers the venue stated rather than an estimate — a balance of 1 BTC available with 0.5 held is 1.5 BTC, and there is no judgement in saying so. Where either is absent the total is nil rather than the other one alone, because "available, total unknown" and "total equals available" are different claims and only one of them is safe to size against.

The pagination is not optional

This endpoint pages at 49 by default and 250 at most, and a caller reading one page has some of its balances with nothing to say which are missing. A truncated balance list is the worst shape this family has: every number in it is real. So this follows cursor until has_next is false, bounded by @max_pages — a server that always says has_next would otherwise loop forever inside a facade call.

:timestamp is when the request was made. A balance has no venue event time; see Core.Types.Balance.

get_conversion(credentials, trade_id, opts)

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

A conversion's current state — GET /convert/trade/{trade_id}.

Both accounts are required query parameters here, which is unusual for a read and is the venue's own rule. They are refused when missing rather than guessed, because a read addressed with the wrong pair is not this trade.

get_current_margin_window(credentials, opts)

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

Which margin window the account is in now — GET /cfm/intraday/current_margin_window.

Carries end_time, which is when the current window closes, and two kill-switch flags. A kill switch being enabled means the venue has turned intraday margin off, and an account that believes it is on intraday margin while the switch is enabled has more leverage in its plan than in its account.

opts[:margin_profile_type] is the venue's own enum and is sent only when given.

get_fees(credentials, opts)

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

The fee schedule that applies to this credential — GET /transaction_summary.

Returns the venue's own map. fee_tier carries the maker and taker rates and the volume band they apply in; fee_tier_without_promotion carries the same before any promotion, and the two differ when one is running. Both travel: a caller computing cost from the promotional tier and reconciling against the standard one would find a gap it cannot explain, and the promotion can end between two calls.

The rates are per product type, and the filter is not defaulted. opts[:product_type] takes the venue's enum — SPOT, FUTURE, EQUITY and the rest — and without it the venue answers across all of them, which is its own default and not one this package picked.

goods_and_services_tax is carried where the venue sends it: a rate quoted INCLUSIVE and the same rate quoted EXCLUSIVE are different amounts of money.

get_futures_balance_summary(credentials, opts)

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

The futures account's balances and margin — GET /cfm/balance_summary.

Two accounts, and the summary names both. cbi_usd_balance is the spot account held with Coinbase Inc; cfm_usd_balance is the futures account held with Coinbase Financial Markets; total_usd_balance is the pair. Funds margin futures only from the second, and a caller sizing against the total is sizing against money that is not there.

Every amount arrives as %{"value" => _, "currency" => _, "cbrn" => _} and is returned that way. Flattening the currency off is how a caller adds two currencies together.

Carries liquidation_threshold and both liquidation_buffer_* fields, which is where a caller judging room reads — get_positions/2 publishes no liquidation price.

get_futures_position(credentials, product_id, opts)

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

One futures position by product — GET /cfm/positions/{product_id}.

The product id is the contract, expiry included — BIT-28JUL23-CDE, not BIT. A future is a different instrument each expiry, and a caller holding two months holds two positions.

get_historical_prices(symbol, timeframe, range, opts)

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

Historical candles for symbol at timeframe.

A timeframe Coinbase does not serve is an error, never the nearest width.

get_intraday_margin_setting(credentials, opts)

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

The account's intraday margin setting — GET /cfm/intraday/margin_setting.

Three values, and the venue's own names are kept: INTRADAY_MARGIN_SETTING_UNSPECIFIED, _STANDARD and _INTRADAY. UNSPECIFIED is not STANDARD — it is the venue declining to say, and mapping it to the safer-sounding one would assert a setting the account may not have.

get_key_permissions(credentials, opts)

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

What this API key is allowed to do — GET /key_permissions.

Three booleans, and can_transfer is the one that moves money. can_view, can_trade and can_transfer are separate permissions and a key routinely holds one or two; asking here is cheaper than discovering a missing one from a refused withdrawal.

Also carries portfolio_uuidthe portfolio the key is attached to — and its type. A key is scoped to a portfolio, so "the account's balance" through this key is that portfolio's, and this is where a caller finds out which.

get_market_overview(opts)

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

A bulk snapshot across every product Coinbase lists: price, 24h change, 24h volume, 24h high/low and status, one entry per canonical symbol.

Reads the same bulk endpoint get_symbols/1 does — the venue's per-product row already carries all of this, so there is no second request behind it.

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 by its venue id.

The venue wraps it as %{"order" => ...}. A response without that key is an unreadable answer rather than a missing order — the second would be a refusal, and telling them apart is what stops a caller treating a parse failure as "no such order".

get_order_book(symbol, opts)

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

The order book for symbolGET /product_book.

opts[:limit] bounds the levels per side; opts[:aggregation_price_increment] groups them, which is the venue's own word for it.

Both sides come back as the venue ordered them, unsorted here. A book's order is the venue's statement about its own matching, and re-sorting it would hide a venue that sent a crossed or out-of-order book — which is exactly the thing worth seeing.

timestamp is the pricebook's own time. A book the venue did not stamp is refused: a depth snapshot with the local clock on it cannot be told apart from a current one, and a stale book read as current is the most expensive kind of wrong number here.

get_orders(credentials, opts)

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

Orders, most recent first.

:status and :symbol in opts filter at the venue rather than here — a client-side filter over one page would silently drop matching orders that were on the next one.

This returns one page. The venue paginates with a cursor and this does not follow it, which is a limit worth stating rather than a total worth trusting: a caller reconciling positions against a truncated order list would find a difference it could not explain.

get_payment_method(credentials, id, opts)

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

One funding source by id — GET /payment_methods/{payment_method_id}.

This is a read; list_payment_methods/2 is a snapshot. A method's verification state changes without the account doing anything: a bank closes, a card expires, Coinbase suspends a rail. Selecting the row out of an earlier listing answers with whatever was true when that listing was taken, and moving fiat against it is what that produces.

A body without a payment_method key is {:error, :unexpected_response_shape} and never an empty map — "the venue answered something else" and "there is no such method" are different answers, and only the second is worth acting on.

get_portfolio_breakdown(credentials, portfolio_uuid, opts)

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

One portfolio's full breakdown — GET /portfolios/{portfolio_uuid}.

Not list_portfolios/2 narrowed to one. The listing names portfolios; this returns the balances, positions and margin inside one, which is a different and much larger answer. It comes back as the venue's own map because none of the contract's types is shaped for a whole portfolio at once.

opts[:currency] asks the venue to value the breakdown in one currency.

get_positions(credentials, opts)

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

Open futures positions — GET /cfm/positions.

These are CFM positions: futures margined in a separate account held with Coinbase Financial Markets, not the spot account held with Coinbase Inc. get_balances/2 reports the second and says nothing about the first.

:realised_pnl is nil, and that is not an omission

The venue publishes daily_realized_pnl — what this position realised today — and no lifetime figure. Types.Position's :realised_pnl means realised P&L on the position, and putting a daily number there would answer a different question with the same field name: a caller summing it across reads would count one day repeatedly, and a caller comparing it to avg_entry_price would be comparing a day to a lifetime.

The daily figure is real and is not discarded — list_futures_positions/2 returns the venue's own rows, where it keeps its own name.

:liquidation_price is nil too: this endpoint publishes none. get_futures_balance_summary/2 carries liquidation_threshold for the account.

get_price(symbol, opts)

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

The current price for symbol.

Returns {:refused, reason} when the venue does not carry the symbol — a permanent answer, distinct from a transient {:error, _}.

get_product(symbol, opts)

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

One product's full record, as the venue publishes it.

Separate from quantization/2 because a product carries more than its increments — the status, the display names, the 24-hour statistics — and a caller choosing a market wants those where a caller rounding an order does not.

get_server_time(opts)

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

The venue's own clock — GET /brokerage/time.

Public, and the reason it is worth reading: this venue signs requests with a JWT whose window is two minutes, so a host clock more than that out of step produces authentication failures that look like a credential problem. Comparing this to the local clock is how a reader tells the two apart.

Returns the venue's own map with iso and epochSeconds/epochMillis. It is not parsed into a DateTime and diffed here: the difference a caller cares about is against its own clock at the moment it asked, and computing it inside the package would hide the round trip in the number.

get_symbols(opts)

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

Every product Coinbase lists, as canonical symbols.

get_top_of_book(symbol, opts)

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

Best bid and ask for symbol, with the sizes.

This used to read the ticker, and the ticker has no sizes

get_top_of_book/2 called /products/{id}/ticker, which publishes best_bid and best_ask and nothing about how much is there — so bid_size and ask_size were nil on every response. That is an honest nil, and it was also avoidable: the venue publishes /best_bid_ask, whose pricebook carries the size at each level.

A price without a size is half a top of book. A caller sizing against the best bid needs to know whether there is 0.01 BTC there or 40, and nil gives it no way to ask.

/best_bid_ask takes product_ids and returns one pricebook per product; this asks for one and reads the first level of each side.

Unlike every sibling reader in this module, this one has no public form

Every other market-data function here branches between an authenticated path and a /market/... public one. This does not, because there is no public /market/best_bid_ask to branch to — verified live 2026-09-05:

GET /api/v3/brokerage/best_bid_ask?product_ids=BTC-USD         -> 401
GET /api/v3/brokerage/market/best_bid_ask?product_ids=BTC-USD  -> 404

Without credentials this returns {:refused, :missing_credentials} before sending anything. Sending the request anyway would come back as an opaque 401 that reads like a venue outage rather than what it is — a call that needed a credential it was not given.

An empty pricebooks array is silence, not a statement — DpCryptoManagement issue #25

This used to read a 200 with an empty pricebooks array as the venue naming this product not listed. Probed live 2026-09-06 against the closely related, unauthenticated /market/product_book (same "pricebook" data, one product per call instead of a batch): a product this venue has never listed answers 404 {"error":"NOT_FOUND","error_details": "valid product_id is required"}, and a product it delisted but still recognises (/market/products/{id} still answers 200) answers a DIFFERENT 404 {"error":"NOT_FOUND","error_details":"no pricebook found"} — never a 200 with an empty array, for either case, across every product checked. This venue's own convention for "no book" is a distinguishable non-2xx statement, not a quietly empty array inside a 200.

/best_bid_ask takes a list of product_ids and returns one pricebook per product it can answer for — a batch endpoint answering "nothing for this one" by omitting it from the array, rather than failing the whole request, is an entirely ordinary batch-API shape, and it collapses at least the two states above (never listed; listed but delisted) into one indistinguishable silence. Nothing here has ever measured that silence against a genuinely live, momentarily bookless product either — the two live-verifiable classes both show either a real book or the errors above. Reading that silence as not_listed is the same substitution dp_exchange_robinhood's issue #25 made of an empty results page: an unverified negative, standing in for a venue statement that this endpoint has no evidence of ever sending.

get_trade_history(credentials, opts)

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

Past fills for the credential — GET /orders/historical/fills.

Filters go to the venue rather than being applied here: opts[:order_id], opts[:symbol], opts[:start], opts[:end], opts[:limit]. A client-side filter over one page would silently drop matching fills that were on the next one.

trade_type is not decoration

Regular fills carry FILL; the venue also emits REVERSAL, CORRECTION and SYNTHETIC for adjusted ones. A reversal is not a trade that happened — summing a list that mixes them without looking produces a position and a cost basis that are both wrong, and both plausible.

Core.Types.Fill has no field for it, so this returns only FILL rows by default and opts[:trade_types] widens it, taking the venue's own strings. Silently returning all four under a type that cannot distinguish them would be the substitution this family refuses; refusing to return adjusted fills at all would hide corrections the venue made.

The response pages on cursor, and this follows it to @max_fill_pages.

get_trade_volume(credentials, opts)

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

What this account has traded — GET /transaction_summary.

This package claimed until 2026-09-01 that Advanced Trade does not aggregate it. That was wrong: the same endpoint that carries the fee schedule carries volume_breakdown per volume type, advanced_trade_only_volume, and coinbase_pro_volume beside it. The claim was made from the market volume endpoint's absence, which is a different question — get_market_overview/1 asks what everyone traded.

Returned as rows, one per volume_breakdown entry, with the venue's own volume_type intact. The three totals are not summed together: Advanced Trade volume is documented as non-inclusive of Pro, so adding them is right and adding either to the breakdown is double counting. They ride alongside as advanced_trade_only_volume and coinbase_pro_volume on each row rather than being folded in.

An empty breakdown is an account that has traded nothing in the venue's window — not an error, and not a venue that does not report.

get_trades(symbol, opts)

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

Recent public trades for symbol — the tape.

get_price/2 already reads this payload and keeps only the newest print. The ticker returns a trades array; a Quote has room for one price, so the rest were discarded at the boundary. This returns them.

Not get_trade_history/2, which is the credential's own fills. The tape is everyone's executions and has no order of yours behind it.

opts[:limit] is the venue's own, passed through. Coinbase publishes no bust flag on the ticker, so broken is false on every print — a venue with nothing busted reports nothing busted, which is the same answer, and opts[:include_broken] therefore changes nothing here.

granularities()

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

Every timeframe Coinbase serves, shortest first.

list_futures_positions(credentials, opts)

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

The venue's own futures position rows — GET /cfm/positions.

Unnormalised, and the reason to reach for it over get_positions/2 is daily_realized_pnl and expiration_time, neither of which Types.Position has a place for. A future expires; a perpetual does not, and the contract's type is shaped for the second.

list_futures_sweeps(credentials, opts)

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

Pending and processing sweeps — GET /cfm/sweeps.

A sweep moves funds out of the futures account and into the spot one. Rows carry status and scheduled_time: a listed sweep has not happened yet, and treating one as settled is treating money that is still margining a position as available.

An empty list means no sweep is pending. It does not mean none has ever run — this endpoint reports the queue, not the history.

list_instruments(opts)

@spec list_instruments(keyword()) ::
  {:ok, [DpExchange.Core.Instrument.t()]} | {:error, term()}

Every product Coinbase lists, with the fields get_symbols/1 discards: base, quote, instrument type and trading status.

Reads the same bulk endpoint get_symbols/1 does.

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 — GET /payment_methods.

Rows are Coinbase's own maps. A bank account, a card, a PayPal link and a fiat balance are different things carrying different fields, and one struct would drop whichever the caller needed.

A method being listed is not the same as being usable. Each row carries verified, allow_deposit and allow_withdraw, and they disagree with each other routinely — a method verified for deposit is not thereby verified for withdrawal. A caller filtering on presence picks one the venue will refuse.

Unlike /accounts, this endpoint is not paged: the venue returns the set.

list_portfolios(credentials, opts)

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

The portfolios this credential can address — GET /portfolios.

A portfolio is an address, not a value. Balances, orders and positions are asked of one, and "the account's BTC balance" is not a well-formed question on a venue that has them.

opts[:portfolio_type] filters by the venue's own enum where a caller gives one; nothing is sent otherwise, because a filter this package chose would hide portfolios the caller did not ask to hide.

deleted rides on the row and is not filtered out here. A deleted portfolio is still returned by the venue and still holds history; dropping it would make an id that appears in an old order look like an id that never existed.

max_candles()

@spec max_candles() :: pos_integer()

The most candles Coinbase will return for one request. A hard boundary, not a hint.

place_order(credentials, request, opts)

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

Places an order.

Coinbase names the order type and the time-in-force together, and not every pair exists

order_configuration is a map with exactly one key, and that key names both at once: limit_limit_gtc, market_market_ioc, stop_limit_stop_limit_gtd. The facade carries :order_type and :time_in_force separately, so this is a cross-product — and the product is sparse. There is no limit_limit_ioc, no market_market_gtc, no stop_limit_stop_limit_ioc.

A pair the venue does not name is an error, not the nearest key. Sending {:limit, :ioc} as limit_limit_fok would place an order that fills-or-kills where the caller asked for immediate-or-cancel, and every field in the request would look right. That is the §0 substitution with money behind it.

client_order_id is required by the venue and generated here when absent

Coinbase requires it, and it is the venue's idempotency key: re-sending the same id returns the original order rather than placing a second. A caller that supplies one gets that protection; a caller that does not gets a UUID and no protection across retries, which is worth knowing rather than being quietly given.

preview_order(credentials, request, opts)

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

Previews an order without placing it.

Takes the same request as place_order/3 and builds the same order_configuration, so a preview that succeeds is a preview of the order that would actually be sent. Building the request differently here — a simpler path, a defaulted field — would preview something else and report it as the order.

A preview carrying errs is a refusal, not a preview. The venue answers 200 with a populated error list for an order it would reject, and returning that as a successful preview would tell a caller its order is fine when the venue has already said it is not.

warning is passed through untouched and does not make this a refusal: a warning is the venue saying "this will execute, and you may not like how".

preview_replace(credentials, order_id, changes, opts)

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

Prices an amendment to a working order without making it.

/orders/edit_preview takes the same body as /orders/edit and answers with what the amended order would cost. The reason this is not preview_order/3 with an id: the venue prices the amendment against the resting order's own state, including whatever of it has already filled. Asking what a fresh order of the new size would cost is a different question with a different answer.

Accepts the same changes replace_order/4 does — :price and :quantity, at least one of them — and refuses anything else here rather than sending it and reading the venue's business error.

The response's errors array is the refusal. As with /orders/preview, an HTTP 200 carrying errors is the venue saying no; this returns {:refused, …} rather than an :ok a caller would read as a green light.

quantization(symbol, opts)

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

What the venue will actually accept for symbolGET /products/{product_id}.

The venue names four increments and this carries all of them, because they are not interchangeable: base_increment bounds the quantity and quote_increment the price, and a caller rounding a price to the base increment produces an order the venue rejects on a field it did not name.

base_min_size and quote_min_size are also both published, and are minima on different things — units of the base asset versus cash. A market order sized in cash is bounded by the second and a limit order in units by the first.

The public and private paths again

/market/products/{id} without a credential, /products/{id} with one. Same rule as the book and the candles: reading the public one while holding a credential silently forgoes whatever the authenticated view adds.

status is the venue's own word — online, delisted and so on — carried unmapped. A package that reduced it to a boolean would lose the difference between a product that is paused and one that is gone.

quote_conversion(credentials, from, to, amount, opts)

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

Quotes a conversion — POST /convert/quote. Nothing moves.

Returns status: :quoted. The rate is held for a window and commit_conversion/3 is the separate call that accepts it; a caller that never commits has done nothing but ask.

Coinbase names accounts by currency, not by uuid: from_account is "USD", not an account id. from and to are passed straight through as the venue's own account identifiers.

opts[:trade_incentive_metadata] carries the venue's fee-waiver object where a caller has one; nothing is invented for it.

expires_at is nil where the venue does not state one, and that is not "no expiry". A caller committing a lapsed quote can get a fill at the current rate rather than an error, which is the dangerous case: the operation looks like it succeeded and every number in it is real.

rename_portfolio(credentials, portfolio_uuid, name, opts)

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

Renames a portfolio — PUT /portfolios/{portfolio_uuid}.

The only thing this edits is the name. It does not move funds, close positions or change what the portfolio can do.

replace_order(credentials, order_id, changes, opts)

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

Changes the price or size of a working order.

This venue edits in place; it does not cancel and re-place. That distinction is the reason replace_order/4 is worth having at all: a cancel-then-place opens a window in which no order is live, and on a moving market that window is where the fill a caller wanted goes to someone else.

Coinbase accepts price and size only. Anything else in the request is refused rather than dropped — a caller trying to change the side or the time-in-force is describing a different order, and silently editing only the price would leave it holding one it did not ask for.

A 200 carrying success: false is a refusal, as everywhere else on this venue.

schedule_futures_sweep(credentials, opts)

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

Schedules a sweep from the futures account to the spot one — POST /cfm/sweeps/schedule.

This moves funds, and it is a schedule: the venue queues it and list_futures_sweeps/2 reports the queue. A successful response is not money in the spot account.

opts[:usd_amount] names the amount. Omitting it sweeps every available excess dollar — that is the venue's documented default, not this package's, and it is stated here because a caller that thought a missing amount meant "nothing" would move the lot.

set_intraday_margin_setting(credentials, setting, opts)

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

Sets the account's intraday margin setting — POST /cfm/intraday/margin_setting.

This changes how much leverage the account gets, on weekdays between 8am and 4pm ET excluding market holidays. It is a setting with money behind it: an account opted into intraday margin is margined differently for the rest of the session.

setting is required and is passed through as the venue's own string. There is no default: the venue's UNSPECIFIED is a value in the enum, and choosing it for a caller who did not would be setting the account to something it did not ask for.

test_connection(credentials, opts)

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

Whether the venue is reachable and the credential, if given, is accepted.

Two different questions, and this asks whichever it was given the means to. Without credentials it reads the public clock — reachability alone. With them it reads /key_permissions, which fails if the key is wrong and tells the caller what the key can do if it is right.

A credential that reaches the venue and is rejected comes back {:refused, _}, not {:ok, _}: an unreachable venue and an unaccepted key are different problems.

transfer_internal(credentials, asset, amount, opts)

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

Moves funds between two of this account's portfolios — POST /portfolios/move_funds.

Nothing leaves Coinbase. No chain, no address, no network fee. This is transfer_internal/4, not withdraw/5, and conflating them is wrong in both directions: a caller reaching for a withdrawal to rebalance between its own portfolios pays a network fee it did not need to, and one reaching for this expecting an external transfer sends nothing anywhere.

Both portfolio uuids are required and neither is defaulted. opts[:from] and opts[:to] name them. A move with one missing is not a move, and picking a default — the default portfolio, the first one listed — would shift funds between portfolios the caller never named. Missing either is {:error, :missing_portfolio} before a request is made.

The amount is sent as Coinbase's funds object: a string value and a currency, which is the shape the venue reads. Decimal.to_string(:normal) because scientific notation is not a number this venue accepts.