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.
Cancels an order.
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 symbol — GET /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.
Places an order.
Previews an order without placing it.
Prices an amendment to a working order without making it.
What the venue will actually accept for symbol — GET /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
@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.
@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.
@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.
@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.
@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.
@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.
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.
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.
@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.
@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.
@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.
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.
@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.
@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.
@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.
@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.
@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_uuid — the 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.
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.
@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".
@spec get_order_book( String.t(), keyword() ) :: {:ok, DpExchange.Core.Types.OrderBook.t()} | {:error, term()} | {:refused, term()}
The order book for symbol — GET /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.
@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.
@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.
@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.
@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.
@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, _}.
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.
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.
Every product Coinbase lists, as canonical symbols.
@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 -> 404Without 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.
@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.
@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.
@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.
@spec granularities() :: [String.t()]
Every timeframe Coinbase serves, shortest first.
@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.
@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.
@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.
@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.
@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.
@spec max_candles() :: pos_integer()
The most candles Coinbase will return for one request. A hard boundary, not a hint.
@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.
@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".
@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.
What the venue will actually accept for symbol — GET /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.
@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.
@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.
@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.
@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.
@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.
@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.
@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.