All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Status: EXPERIMENTAL
Stated here rather than only per-release, because a reader arriving at a specific version needs it as much as one reading the top.
This package has not run in production. While it is 0.x the API may change without a
major version. Coverage is uneven by design: fakes and live public endpoints are well
covered, order placement and authenticated flows are not.
Whenever an endpoint moves to :proven, the entry that does it states the evidence —
what was run against the live venue, and when. "Marked proven" with no evidence is not an
acceptable changelog line.
[Unreleased]
Fixed
dp_exchange_corewas pinned to~> 0.1.48, butWsDecode.to_order_book_delta/2callsTypes.OrderBookDelta.new/1, which Core has only defined since0.1.53.OrderBookDelta.new/1is a plain function call, not a struct literal, so a resolution below 0.1.53 compiles with a warning rather than an error and only raisesUndefinedFunctionErrorthe first time adepth/depthFastframe decodes — the same failure shape already found and fixed inwebsockex's~> 0.4pin across this family (dp_exchange_webull0.2.20).~> 0.1.48passed every test here because CI always resolves the newest allowed Core (0.1.68, permix.lock); a consumer whose own dependency graph forced 0.1.48–0.1.52 would not. Raised to~> 0.1.53. Found in a family-wide audit of declared-vs-actual dependency floors.A prior commit ("Move to dp_exchange_core 0.1.68") bumped only
mix.lock's resolved version, not this floor — it correctly notedno_venue_contactand themarket_status/1exemption did not apply here, but themix.exsconstraint itself was never audited against what this package's own code already required.A new test in
ws_channels_test.exsasserts the resolved Core definesTypes.OrderBookDelta.new/1, so a future loosening of this pin without a matching code change fails here first, not only for a consumer.This corrects a mislabel introduced by the "
Fakewas not equivalent to the real path…" entry below:{:error, {:unsupported_auth_scheme, nil}}for genuinely absent credentials was the wrong label, even though moving that case out of:refusedwas correct. That entry fixed a real defect —Fakeanswering the venue's own permanent:refusedfor a credential that never left this package — but the replacement label claimed the wrong thing:nilhere is not a SCHEME the venue rejected, it is the absence of one, because nothing credential-shaped was supplied and no:auth_schemewas named.Auth.headers/5's catch-all could not tell "you asked for an unsupported scheme" apart from "you asked for nothing at all", so both fell through to the same:unsupported_auth_schemetuple. A caller keying off the label — asdp_crypto_managementdoes — read this as "Gemini does not support this authentication method" when the true condition was "no credential was given to sign with", the same shape every other venue in this family reports as{:missing_credentials, venue}.Auth.headers/5now answers{:error, {:missing_credentials, :gemini}}whenschemeisnil, before falling through to the catch-all —:unsupported_auth_schemeis now reserved for a scheme actually named (a caller's own:auth_scheme, since auto-detection never produces one this module does not implement) and for:ambiguous(both header families present), which really is a scheme this module declines to resolve.Fake'sauthenticated_venue_faithful/2mirrors the same split, so the fake still matches the real path exactly rather than re-diverging. Every test pinned to the old{:unsupported_auth_scheme, nil}shape (auth_test.exs,fake_parity_test.exs,fake_test.exs,fake_injection_test.exs,private_test.exs,clearing_test.exs,edge_cases_test.exs,gemini_delegation_test.exs) now asserts{:missing_credentials, :gemini}instead.The socket's own crash took the whole
Feeddown with it, silently discarding every subscription this feed had ever been given.ensure_socket/1callsSocket. start_link/1from insideFeed's own callback, which links the socket toFeedthe waystart_linkalways does.Feednever calledProcess.flag(:trap_exit, true), so a socket that exited abnormally — an exception inside a WebSockex callback, or anything that killed the socket pid directly — sent an untrappableEXITsignal along that link and crashedFeedtoo.DpExchange.Gemini.Supervisorthen restartedFeedfrom the staticoptsit was given at tree-start, which never carry a consumer's latersubscribe/3calls: a single socket crash cost every symbol this feed was ever asked for. Found by a 2026-09-07 supervision audit — proven by linking a real process into a runningFeedthe wayensure_socket/1does and killing it withProcess.exit(pid, :kill)(not:normal, which a non-trapping process ignores), which crashedFeedbefore this fix and does not after.Feednow traps exits. A crashed socket clearsstate.socketand resetsdelivering_by_kind(this venue has one socket carrying both streamable kinds, so a crash costs both), reports a:link_downCore.Notice, and immediately calls the sameresubscribe/1the periodic timer already uses — reconnecting and resendingwantedright away rather than waiting out the next 60-second tick. The periodic resubscribe itself also gained a matching fix: previously it did nothing at all whenstate.socketwasnil, even with symbols stillwanted— now it dials a fresh socket first when that happens, which is what actually lets a crashed connection recover if the immediate reconnect above did not succeed on the first try.A refused subscription reported
:coverage_changeinstead of:refusal. A non-200 subscribe acknowledgement is the venue's own word about a subscription it received and declined —Core.Notice's own moduledoc defines:refusalas "a symbol the venue will not carry", exactly this case, anddp_exchange_webull'sFeedalready reports the identical condition (INVALID_SYMBOL) as:refusal.:coverage_changeis the generic, unexplained resubscribe-failure shape this venue does not have here. Found by a cross-package audit comparing notice-kind usage for equivalent conditions across all five venues.child_spec/1did not declaretype: :supervisor, so OTP defaulted it to:worker— which also defaults:shutdownto5_000ms instead of:infinity. A consumer terminating this child gave the whole nested tree (socket, rate limiter, and everything under them) only five seconds to shut down gracefully before:kill, rather than letting it unwind on its own terms. Invisible to any single-package review, and found only by diffingchild_spec/1across all five venue packages against each other;dp_exchange_schwabwas the only one that already declared it.
Added
coverage_by_kind/1implemented —dp_exchange_core0.1.48's optional callback.coverage/1answers one boolean per symbol regardless of which of this venue's two streamable kinds produced it, which is the same collapse that hid Coinbase'slevel2/tickersplit behind a truthful:stream(DpCryptoManagement's issue #22). Gemini's@bookTickerstream carries both:quotesand:top_of_bookon one wire, but the two remain independent facts: a frame always yields aCore.Types.TopOfBookwhen it parses, and yields an accompanyingCore.Types.Quoteonly when that same frame also carries a last-traded price — so a symbol can quote continuously while never once trading, andcoverage/1alone cannot tell that apart from full health.Feednow derives kind strictly from the delivered struct's own type — never from a channel name or thewantedsubscription set — andcoverage/1's own map is derived from the same per-kind state, so the two cannot drift apart by construction.Fakereports:quoteshonestly (the only kind its in-memorysubscribe/2ever pushes) and:top_of_bookas a declared-but-empty key, matching the real adapter's key set without claiming delivery it does not simulate. Verified against this venue's actual delivery mechanics: a symbol delivering only a top-of-book update is realistic and is tested; the reverse (:quoteswith no:top_of_book) does not occur through the real socket, since aQuoteis only ever built alongside aTopOfBookfrom the same frame.Fakewired toCore.FakeInjection— DpCryptoManagement's issue #14. Every function with a real success path (not an unconditionalVenue.not_supported()) now checks a queued or always-set outcome first:get_price/2,get_top_of_book/2,get_order_book/2,get_trades/2,quantization/1,get_funding/2andget_contract_stats/2support per-symbol targeting; the remaining 34 functions with real logic — balances, orders, staking, conversion, transfers, withdrawal, custody and account management among them — support whole-call injection.authenticated/1also honoursFakeInjection.credentials_bypassed?/1, letting a wiring-only test skip the venue-faithful{:refused, :missing_credentials}default without changing it for anyone who doesn't opt in.subscribe/2,unsubscribe/2andupdate_symbols/2are deliberately not wired — each takes a symbol list in one call, which whole-call injection cannot express partial failure for. Follows the reference implementation shipped indp_exchange_robinhood.DpExchange.Gemini.live?/1— whether the environmentoptsresolves to moves real money, resolved through the same precedence every call here uses. Meant as a check a caller makes of itself before a money-moving call:live?(environment: :sandbox)isfalse;live?([])istrue, since:productionis the default. SurfacesDpExchange.Gemini.Environment.live?/1, which existed already but had no caller anywhere in this package's ownlib/— found byCore.AdapterContract's "16. internal wiring" assertion, checked against a localdp_exchange_corecheckout ahead of its next release (this package's own dependency pin stays at~> 0.1.48for this change). This is the case that assertion's own moduledoc calls out as legitimate public surface the facade never offered, not dead code.
Removed
WsChannels'sall/0, removed. Returned every channel name the venue's AsyncAPI document defines; nothing in this package's ownlib/ever called it —address/2andper_symbol/0both read the underlying@channelsattribute directly, and the conformance suite this package's tests do not run against itself. Found byCore.AdapterContract's "16. internal wiring" assertion. Breaking for anyone who called it directly; it was never reachable through the facade.WsChannelsTestnow checks the same twenty-two-channel-catalogue facts throughrequires_credential?/1, which every known channel answers and no test-only accessor was needed for.
Fixed
capabilities/0declaredhas_staking: falseandsupports_margin: falsewhile six staking endpoints and three margin endpoints were already:experimentalin the SAME declaration's endpoint map — a declaration contradicting itself, found in the 2026-09-06 documentation-accuracy sweep. This is a behaviour change for a consumer routing on either flag:has_stakingandsupports_marginare nowtrue, andmax_leverageisDecimal.new("5"), the venue's own published ceiling. The endpoints genuinely work:get_staking_rates/1— the one public endpoint in this set — was reprobed live againstapi.gemini.comand returned real provider rates; the other five staking endpoints and all three margin endpoints are authenticated and this repo holds no credentials to probe them with, so their inclusion rests on Gemini's own OpenAPI paths and response shapes, not a live call, andcapabilities/0'smeasured_againstsays so explicitly rather than implying otherwise. Gemini gates both features by account eligibility (Eligible Contract Participant status for margin, jurisdiction for staking assets) the same way it gates order placement by KYC tier — an account entitlement, not a statement that the venue or this package lacks the feature, so it does not belong in this declaration. Seeusage-rules.md's new section for the full account-eligibility caveat.Fakewas not equivalent to the real path on several classes of refusal — the "less capable is allowed, differently capable is not" rule was broken, not just the one reported instance. Found and fixed in the 2026-09-06 real/fake parity sweep, with pinned tests in the newfake_parity_test.exsso none of these can drift back silently:get_historical_prices/4's{:error, {:range_unavailable, tf, …}}omittedrequested:, whichRest.get_historical_prices/4always carries — a consumer's tier-1 test asserting the documented shape passed here and would have failed against the real venue.- The credential gate answered
{:refused, :missing_credentials}for every missing or malformed credential, everywhere inFake— around three dozen functions. The real path (Auth.headers/5, called fromPrivate.post/4) answers{:error, {:unsupported_auth_scheme, nil}}with no credentials at all and no scheme named,{:error, {:missing_credentials, scheme}}when a scheme is known but its fields are incomplete, and{:error, {:unsupported_auth_scheme, :ambiguous}}when both header families are present — never:refused, which the real adapter reserves for the venue's own 401/403 body.authenticated/2now mirrorsAuth.headers/5's exact decision tree, including a caller-namedauth_schemeopt overriding auto-detection, and every call site threadsoptsthrough to it. - Every "symbol not carried" refusal used the same invented
:not_listedatom, which appears nowhere in the real vocabulary. Each now matches the specific endpoint behind it::invalid_symbolforget_order_book/2,quantization/1andplace_order/3(Gemini's JSONInvalidSymbolreason, live-confirmed forquantization/1);{:unknown_reason, text}forget_price/2,get_top_of_book/2andget_historical_prices/4(measured live, plain-text 4xx bodies, not JSON). get_trades/2never refused an unlisted symbol at all — more capable thanRest.get_trades/2, the forbidden direction. It now refuses, measured live againstGET /v1/trades/{symbol}.place_order/3validatedorder_typeandtime_in_forceindependently, so it accepted combinations the venue's own "at most one execution option" rule refuses —order_type: :post_only, time_in_force: :fok, or any option on a:stop_limitorder.Private.order_wire/2is now a shared, exposed (@doc false) function bothPrivate.place_order/3andFake.place_order/3validate against, the same patternRest.refusal_reason/1already uses for the refusal vocabulary shared betweenRestandPrivate— one implementation rather than two copies that can drift.
A differential depth frame (
@depth/@depthFast) was delivered as the raw, undecoded venue JSON —{:depth_update, message}— instead of a value in the contract's own shape.WsDecode.depth_changes/1decodes exactly this frame into{price, quantity}levels and has done since the channel was added, butSocket'sdepthUpdatehandler never called it: it forwarded the raw map straight to subscribers.Core. AdapterContract's new "16. internal wiring" assertion — checked against a localdp_exchange_corecheckout ahead of its next release, since this package's own dependency pin stays at~> 0.1.48for this change — is what found the disconnect:depth_changes/1had no caller anywhere in this package's ownlib/. This channel is not requested by default (Feedonly ever asks for@bookTicker), so no consumer using this package as documented was affected today, but the handler was live and reachable the moment anything calledSocket.subscribe/3with:depthor:depth_fastdirectly, and would have handed that caller unparsed strings under an undocumented tuple shape rather thanDecimalvalues under a contract type. Now decoded through a newWsDecode.to_order_book_delta/2, built ondepth_changes/1, intodp_exchange_core'sCore.Types.OrderBookDelta— a type added to Core specifically so a venue streaming deltas has a non-accumulated shape to hand back, afterdp_exchange_coinbase'sSocketwas found rebuilding a full book in-process for exactly this reason (~22,800 bid levels for one symbol, measured on a consumer's live node).Socket'sbookTickerhandler duplicatedWsDecode.to_top_of_book/3's construction ofCore.Types.TopOfBookinline, rather than calling the decoder — a second implementation of the same decode, free to drift from the oneWsDecode's own tests actually exercise. Found the same way as the depth defect above:to_top_of_book/3had no caller inlib/, despite being fully written, documented and tested in isolation. Now called directly; no behavioural change, since both implementations decoded the same fields the same way.A per-account channel given a non-empty symbol list subscribed to nothing, silently, and reported success.
WsChannels.address/2already refuses this shape —{:error, {:channel_takes_no_symbol, channel}}— butSocket.streams/2's comprehension silently drops any address that fails to build, soSocket.subscribe(pid, ["BTC-USD"], :orders_account)built zero frames andsend_rpc/3's[]clause answered plain:ok.WsChannels.per_symbol/0had a matching defect: written, documented, and never called from anywhere inlib/.subscribe/3andunsubscribe/3now checkper_symbol/0before reachingstreams/2at all, answering{:error, {:channel_takes_no_symbol, channel}}up front. A channelWsChannels. requires_credential?/1marks private is refused the same way, with{:error, {:credential_required, channel}}— this socket never authenticates a connection, so a private channel could previously only ever fail at the venue, one round trip later, andrequires_credential?/1had exactly the same "no caller inlib/" defect asper_symbol/0. Neither of these channel shapes is reachable through the public facade today (Feedonly ever requests:book_ticker), so no consumer was affected.Environment.validate!/1carried its own literal[:production, :sandbox]guard — a second, hand-copied statement of exactly whatEnvironment.known/0already declares.known/0had no caller inlib/and could have drifted from the guard silently if either were updated alone;validate!/1now checks membership inknown()instead, so there is one place this package's set of recognised environments is written down.SymbolFormat.to_canonical_symbol/1andto_exchange_symbol/1read the@mappingmodule attribute directly, bypassingmapping/0— the same accessorquotes/0andcapabilities/0'ssupported_quotesalready read through. No behavioural change (mapping/0returns the same attribute), butmapping/0had no caller inlib/before this — its own moduledoc's claim that it exists "so the conformance suite can driveCanonicalPairwith it" was the whole reason, and a test is not a callerCore. AdapterContract's "16. internal wiring" assertion counts.A 404 on a symbol-scoped market-data GET was reported as a retryable error, not the permanent refusal it is. Measured live 2026-09-06:
GET /v1/pubticker/{symbol}andGET /v1/fundingamount/{symbol}both answer 404 for a symbol the venue does not carry —/v1/pubtickernames the condition in plain text ('X' does not have available data yet) — while every other status this module'sget_with_headers/2did not recognise fell to the generic{:error, {:exchange_error, …}}clause. That clause is the shape this family reserves for a failure worth retrying; a permanently unlisted symbol read as one forever. 404 now takes the same{:refused, reason}path as 400 on every symbol-scoped read (get_price/2,get_top_of_book/2,get_historical_prices/4,get_order_book/2,get_trades/2,get_funding/2,next_funding_timestamp/2,get_contract_stats/2,quantization/1).A refusal body that was not JSON collapsed to a bare
{:refused, :refused}, discarding the venue's only stated reason. Measured live 2026-09-06:/v2/candles/{symbol}/{width}'s 400 body is plain text ("Supplied value 'X' is not a valid symbol"), not the{"reason": …}shape every other refusal in this package carries.Rest.refusal/1andPrivate.refusal/1both pre-decoded the body through the same helper their 2xx success path uses, whose fallback for unparseable JSON is%{}— losing the text beforeRest.refusal_reason/1ever saw it, even though that function's own moduledoc states the opposite intent ("a reason NOT in that set keeps the venue's own words as data"). Both now pass the raw body torefusal_reason/1, which decodes it itself and keeps the text as{:unknown_reason, text}when it is not JSON; a genuinely empty body still degrades to the plain:refusedatom, since there is nothing in it worth keeping.get_trade_history/2's:sinceand:limitreached the venue asto_string/1output instead of the venue's own units. Every other filtered read inPrivate(get_orders/2withhistory: true,get_transactions/2,list_custody_fees/2,list_accounts/2, the staking history/reward reads) converts asince:DateTimeto Unix milliseconds before it goes on the wire —/v1/mytrades's own request examples confirm the unit (timestamp: 1591084414000).get_trade_history/2alone reachedmaybe_put/3instead, which stringifies whatever it is handed: aDateTimebecame"2026-08-28 17:00:01Z"on the wire, a shape the venue'stimestampfield does not parse, so the filter silently narrowed nothing rather than erroring or filtering correctly.:limithad the matching defect forlimit_trades, sent as"100"instead of the documented integer100. No test in this package's suite ever passed aDateTimetoget_trade_history/2's:since, which is how this shipped unnoticed. Now usesput_present/3andtimestamp_param/1, matchingget_orders/2'shistory_params/1.get_positions/2carried a position'ssymbolin whatever case the venue's response happened to use, instead of the canonical uppercase form every other reader in this package produces. The venue's own/v1/positionsexample sends"btcgusdperp"(lowercase, the same case/v1/symbolsuses);to_position/2passedrow["symbol"]straight onto the struct unchanged, so a real position arrived assymbol: "btcgusdperp"besideto_order/1's uppercase form for the identical family of endpoints. Every test fixture in this package wrote"BTCGUSDPERP"by hand and none of them ever asserted onposition.symbol, so the venue's actual casing was never exercised. Now reads the symbol throughSymbolFormat.to_canonical_symbol/1, the same conversionto_order/1already applies — a perpetual takes the:nomatchpath throughCanonicalPair.to_canonical/2and comes back uppercased and unsplit, matchingFake's"BTCGUSDPERP".The periodic resubscribe's own failure path was silent — the same shape of defect the resubscribe timer itself was built to close (G5, above).
resubscribe/1's{:error, reason}branch reached only aLogger.warning;grep -n "Notice.new(" lib/dp_exchange/gemini/feed.exmatched nothing in this file before this fix. A consumer whose reconnect kept failing to resubscribe — a venue outage, a stale socket the venue silently stopped honouring — had no facade-level way to learn it, the same "recovered from a quiet chart, or not at all" gap G5 exists to close for the reconnect itself.The discovery route is DpCryptoManagement's issue #21, the poll-feed sibling case:
dp_exchange_core'sCore.PollingFeedanswered nothing for hours while its own "delivered NOTHING" log line sat ungrepped, and its fix established thenotice_state: :ok | :deadlatch this package now borrows — aCore.Noticefires on the transition INTO failure and a recovery notice fires on the transition back OUT, never once per tick for as long as an outage lasts.dp_exchange_coinbase'sFeedestablished:coverage_changeas the kind for this family's sibling shape — a channel subscribe that exhausted its retries without ever becoming delivery — but its own notice there is one-shot, with no recovery counterpart, because that retry chain either succeeds silently or is left for the next unconditional cycle. This module's resubscribe runs forever on a fixed timer rather than a bounded retry chain, so the stricter,PollingFeed-shaped latch applies: a newresubscribe_notice_statefield tracks the last attempt's outcome, a:warningnotice fires once on the first failure after a success (or after boot), and an:inforecovery notice fires once on the first success after a failure. TheLogger.warningis unchanged and still fires on every failing tick — only a consumer-visibleNoticeis new, and it is deliberately quieter than the log beside it. A dead socket or an emptywantedset (nothing attempted) touches neither the log nor the latch, matching the pre-fix behaviour for that branch exactly.:rate_limit_blockingwas unreachable on every REST call this package makes — family-wide gap, DpCryptoManagement's issue #23.Core.HttpClient.check_rate_limits/1reads this option to chooseacquire/3(wait for capacity) over fail-fastcheck/3, and its own error message on a self-inflicted throttle tells a caller to set it — but no caller could, on this venue:Rest.request_opts/1andPrivate.request_opts/1both stripped it from their forwarded-options allowlist before it ever reachedCore.HttpClient. The same defect (dp_exchange_webull's issue #23,dp_exchange_robinhood's issue #16) audited across the rest of the family; this venue was one of four still carrying it.Both allowlists now forward
:rate_limit_blocking, proven with a recording rate limiter that records which ofacquire/3/check/3was actually called — not merely that the keyword survives the allowlist. Not defaulted anywhere in this package, unlikedp_exchange_webull'sFeedanddp_exchange_robinhood'sFeed: this venue's own periodic resubscribe (DpExchange.Gemini.Feed's unconditional 60s re-issue) sends WebSocket frames, not HTTP, so there is no rate-limited background replay here to justify choosing a default on a caller's behalf. A caller that wants blocking opts in explicitly.Decoding a venue refusal could exhaust the VM's atom table and kill the whole BEAM — family-wide defect sweep, G7.
refusal/1in bothRestandPrivatebuilt its result withString.to_atom(Macro.underscore(reason)), wherereasoncomes straight out of Gemini's own JSON error body. Atoms are never garbage collected and the table is finite (default ~1,048,576): a venue emitting unbounded distinct reasons — through error variety, a changed error format, anything this package does not control — mints a permanent atom every time and eventually takes down the entire node. These packages run inside a consumer's application, so that is the consumer's whole system, not just this venue.mix sobelowhad been reporting it asDOS.StringToAtomall along, and it was waved through twice in one day as a "pre-existing, unrelated, low-confidence warning". It was none of those three.Fixed by writing the recognised refusal vocabulary down at compile time (
@refusal_reasons) and mapping against it, so an atom can only ever come from a fixed set — the same disciplineCore.FakeInjectionalready adopted deliberately for this exact class. Every reason callers already match on keeps its existing atom, unchanged. An unrecognised reason now returns{:unknown_reason, reason}, keeping the venue's own wording rather than being flattened to a bare:refused: the list is deliberately not exhaustive (Gemini adds reasons without notice), so it has to degrade legibly rather than silently. The duplicate copy inPrivatenow delegates to the one implementation — it had to be found and fixed twice, and could as easily have been fixed in only one.Regression test asserts
:erlang.system_info(:atom_count)is unchanged across fifty novel reasons, because the old return value looked perfectly reasonable the entire time the bug was live.@refusal_reasonsitself was reviewed once more before landing: it had picked up three plausible-sounding entries (RateLimit,EndpointNotFound,InsufficientFunds) with no vendor documentation and no live measurement behind any of them, while missing four codes Gemini's own error table actually documents (MissingApikeyHeader,MissingPayloadHeader,MissingSignatureHeader,AmbiguousAuthentication). The guessed three were removed and the documented four added — every entry in the map now traces to eitherdocs/reference/gemini/rate-limits-and-auth.mdor a live measurement recorded elsewhere in this module's own docs, nothing asserted from how a reason sounds.get_staking_rates/1hadassetandprovider_idswapped, and read a field that does not exist — family-wide defect sweep, G1+G2. Re-verified live 2026-09-05:GET https://api.gemini.com/v1/staking/ratesreturns{"<provider-uuid>": {"ETH": {...}, "SOL": {...}}}— the outer key is a provider UUID, the inner key is the asset. This package assumed the reverse, so everyStakingRateit built carried an upcased UUID as:assetand a real asset symbol as:provider_id. Gemini's own OpenAPI names the nesting the same way —StakingRateResponsenests aStakingRateProviderunder "Provider UUID Keys", which itself nests "Currency Symbol Keys" — so the swap was checkable without a live call and wasn't: the test fixture was keyed the same wrong way the code assumed, which is exactly why it passed. The same live payload also showed:deposit_limit_usdreadingdepositLimitUsd, a field the venue does not send — the real field isdepositUsdLimit, and every row's cap was silentlynil. Both fixed together; the fixture is rewritten to the captured shape rather than to either assumption.networks_for_asset/2was documented "Public" and could not succeed for any consumer;list_networks/2's network directionPOSTed to a route the venue only serves asGET— family-wide defect sweep, G3+G4. Re-verified live 2026-09-05:GET /v2/network/BTCwith no credentials returns401 MissingSecurityHeaders, and the vendor's OpenAPI requiresapiKeyAuth,signatureAuthandpayloadAuthon it —Restnever aliasesAuthand sends no credentials anywhere, by design, so this direction was dead from the day it shipped. Independently,list_networks(nil, network: …)sentPOST /v2/networks/{network}/assets; the vendor documents that route asGET(operationId: getAssetsForNetwork) — there is no POST form. Together the two bugs meant both directions of network discovery were dead, on the one call whose own docstring warns that a wrong network produces an address on a chain this venue does not credit. Both now go throughPrivate.list_networks/2'ssigned_get/3— the asset direction moved out ofRestentirely, since it was never really public andResthas no way to sign a request; the network direction now asksGETinstead ofPOST.No resubscribe after a WebSocket reconnect — a silent coverage collapse with no error — family-wide defect sweep, G5. WebSockex reconnects a dropped socket on its own;
Socket.handle_connect/2only emitted a:link_upnotice, andSocket's own state (%{subscriber:, request_id:}) carried no memory of what had been subscribed — there was nothing to resend even if it tried.Feed'swantedMapSetwas written on everysubscribe/3and read by nothing (confirmed by grep). The sequence a consumer actually saw was:link_downthen:link_up— which reads as "recovered" — followed by silence until someone noticed a quiet chart. Same incident class the siblingdp_exchange_coinbasepackage already carries a fix for; adapted here rather than reinvented.Feednow re-issues itswantedset on a 60-second timer, unconditionally — not gated on detecting a reconnect, because a reconnect this process never learns about (a supervisor restart ofSocket, for instance) is indistinguishable from one it does.Also fixed alongside it:
ensure_socket/1callsSocket.start_link/1synchronously insidehandle_call, andFeed/SandboxFeedare named, shared processes — the whole blocking window that connect can take is borne by every other consumer'ssubscribe/3,unsubscribe/2andcoverage/1queued behind it. The connect was never actually unbounded, which was the first, wrong diagnosis of this:Socket.start_link/1passed no opts toWebSockex.start_link/4at all, so it silently inheritedwebsockex's own general-purpose defaults — measured from the vendored dependency,socket_connect_timeout: 6_000ms andsocket_recv_timeout: 5_000ms (deps/websockex/lib/websockex/conn.ex:10-11) — rather than choosing them. 6s + 5s of connect, plus onesend_framefor the subscribe that follows (up to 5s), is 16s againstFeed's own 15s@call_timeout: already over budget before any other overhead in that call.Socket.start_link/1now sets:socket_connect_timeout(3s) and:socket_recv_timeout(2s) explicitly, chosen against that same budget — 3s + 2s + 5s is 10s, leaving 5s of headroom — and both remain overridable throughopts, threaded fromFeed.start_link/1through toSocket.start_link/1alongside:urland:environment.Feed.fan_out/2crashed on a subscriber registered by name — DpCryptoManagement's issue #15, same defect found on the siblingdp_exchange_coinbasepackage.subscribe/2'sto:option accepts any value, andfan_out/2calledProcess.alive?/1on it directly — which only accepts a pid and raises on anything else. A consumer registering itself under a name (ordinary OTP practice) and handing that name toto:crash-looped the wholeFeedGenServer on every delivery. Fixed by resolving a subscriber (pid or name) to a pid first, treating an unregistered name the same as a dead pid: silently skipped, never a crash.Decimal.new/1raised on a malformed venue field, and it was reproducible in production.dp_crypto_managementfiled the same defect againstdp_exchange_webull(issue #3); auditing every copy of the pattern in this package found it live and triggerable here too. A 347-symbol subscribe against productionwss://ws.gemini.com— this package's own venue socket, at the scale a real consumer runs — crashed the connection within seconds on abookTickerframe carrying""for a bid.Every
decimal/1helper (rest.ex,socket.ex,private.ex) now parses withDecimal.parse/1, requiring the whole string be consumed, matching the idiomws_decode.exalready used. Re-ran the same 347-symbol live subscribe after the fix: 347 of 347 delivered, zero crashes, in 20 seconds.A second, quieter defect the first fix would otherwise have introduced: the lenient parse turning a malformed price into
nilinstead of raising would have let aQuotewithprice: nilreach a subscriber —@enforce_keysdoes not check that a value is non-nil, only that the key was given.get_price/2,get_trades/2,get_fx_rate/3and the socket's own last-trade delivery now refuse the record instead ({:error, {:invalid_decimal, field, value}}), rather than silently delivering a Quote, Trade or FxRate with a fabricated-lookingnilin a field the type promises is real.
Documentation
usage-rules.mdtwice told a consuming agent that every authenticated endpoint here returns{:error, :not_supported}— "Balances, orders, fees, transfers and trade history are yours to implement against your own auth" and, in "What this package does not do", "the host authenticates, so balances, orders, fees, transfers and trade history all return{:error, :not_supported}." Both were false the day they were written:Private(~2,400 lines) implements all of them, andcapabilities/0has declaredget_balances/2,get_orders/2,place_order/3,get_fees/2,get_transfers/2,get_trade_history/2,get_staking_balances/1and the rest of the account surface:experimentalsince 2026-08-28 — verified again here withmix run -eagainst the live module, not read off the source. The document contradicted itself in the same breath: its own sections onstake/3,unstake/3and clearing orders correctly walk through using that same authenticated surface. The detailed how-to-use sections were right; the two blanket "not_supported" claims were wrong, and are now corrected to say what this package actually does — signs a request you hand it credentials and a scheme for, obtains and stores neither. Left unfixed, a consuming agent reading this would have concluded Gemini has no authenticated surface and rebuilt an already-implemented API against its own auth layer, the inverse of the Robinhood defect. Family-wide defect sweep, G6.Also found and fixed in the same pass:
usage-rules.mdstill saidsupported_instrument_types: [:spot]under "Perpetuals are excluded", stale since perpetuals landed above in this same file —capabilities/0has declared[:spot, :perp]since 2026-09-01. Corrected to name the perpetuals endpoints instead of a capability list the venue section had already outgrown.The
:unsupportedlist is now split.venue_does_not_serve/0names the 22 endpoints that are Gemini's own absence — options, watchlists, replace/preview, position closing — each with the source and date behind it; three stay under@not_portedbecause they are the venue's surface and this package's backlog.README.mdstates what the contract covers — 62 of 87 callbacks:experimental, the best-covered venue in the family.docs/reference/gemini/endpoint-inventory.md's counts refreshed. It read "18%" until this release; the vendor-side page counts had not moved, this package's coverage had.
Documentation
Every negative this package makes is audited —
docs/reference/gemini/negative-claims.md, thirteen claims with the source and date consulted for each. All hold, including the two that are the venue's own words: no market orders ("they provide you with no price protection") and no plain stops.This venue is where the family learned the rule's other half. Every other package learned to check negatives; Gemini is where a documented, positive claim — a socket URL the vendor still published — turned out to be false. A claim about a venue is only as current as the last time someone looked, whichever way it points.
The audit also records a divergence worth keeping: Gemini's own error table lists
MissingApikeyHeaderat 400, and the live environment returns 401MissingSecurityHeaders(measured 2026-08-28).supported_instrument_typesgains:perp. The venue's perpetuals surface was always there; the package's claim of[:spot]was a statement about the package that had stopped being true.usage-rules.mdgains everything this release added — the sign convention on a short, the three staking numbers and which two survive, clearing's confirm-restates-everything rule, the shortname that is not the name you sent, the refresh token that rotates, and why the spreadsheet reports come back as bytes.AGENTS.mdgains a pointer to this package's ownusage-rules.md.
Changed
- Core dependency moves to
~> 0.1.36, andplace_orders/3is declared absent with the reason: this venue places one order per request. A batch is one request the venue accepts or rejects as a unit, and a caller placing several here callsplace_order/3several times and reconciles the outcomes itself.
Added
Account administration and the OAuth token lifecycle —
create_account/1,rename_account/3,list_accounts/1,get_roles/1,refresh_access_token/3andrevoke_access_token/1.The name you send is not the name you address by.
/v1/account/createtakes a display name and answers with a kebab-cased shortname, and that shortname is what every other endpoint'saccountparameter takes. A caller that kept what it sent would address the wrong subaccount, or nothing.rename_accounttouches two different things.opts[:name]is the display name;opts[:shortname]is the string other endpoints address by, and changing it changes how the account is reached. Neither given is{:error, :nothing_to_rename}rather than a call that changes nothing and reports success.list_accounts/1caps at 500 and does not paginate — the venue'slimit_accountsis both maximum and default, and a larger group comes back truncated with nothing to say it was. There is no cursor to follow, so it is stated rather than worked around.get_roles/1answers with three booleans, not one role, becauseFund ManagerandTradercombine andAuditorcombines with nothing.refresh_access_token/3is credential use, not consent. The browser redirect that obtains the first code belongs to the host; refreshing a token the host already holds is the same category as Schwab'sAuth.refresh/2. It posts a form toexchange.gemini.com/auth/token— a different host from every other endpoint, and the same URL the host's initial exchange posts to, separated only bygrant_type. That is the concrete case for why the package/host split cannot be read off a path.The response rotates the refresh token: a new one comes back and the old stops working, so a caller that stores only the access token has a session that ends at the next refresh.
revoke_access_token/1requires an OAuth token and refuses an API key — an API-key-signed call there would revoke nothing and come back shaped like success.Clearing, all eight endpoints:
create_clearing_order/2,create_broker_clearing_order/2,get_clearing_order/2,cancel_clearing_order/2,confirm_clearing_order/3,list_clearing_orders/1,list_clearing_brokers/1andlist_clearing_trades/1.A clearing order is not an order on the book. It is one half of a trade agreed with a named counterparty and it does nothing until that counterparty confirms.
is_confirmedon the response is the field that matters — a caller reading a successful create as a fill holds a position it does not have.confirm_clearing_order/3re-states every term and this package fills none of them in. The venue re-asks for the symbol, amount, price and side alongside the clearing id; reading them back from the order being confirmed would confirm whatever the venue had, which is the one thing re-stating them exists to prevent. Thesidethere is the confirming party's own — the opposite of the creator's.The broker form names both counterparties, and
sidebelongs to the source. Passing the two the wrong way round produces a valid order in which each side trades the direction the other meant, so both ids are required and refused by name when missing.expires_in_hrsis required here and optional on the bilateral form — the venue's own asymmetry.Three listings, three row shapes, and none of them merged. A bilateral order names one counterparty and a
side; a broker order names a source and a target and asource_side; a trade comes back camelCase underresultswhere the orders come back snake_case underorders. The venue's own keys are kept in each, because one normalised shape would match none of the three.list_clearing_trades/1'ssince_nanosis nanoseconds — the one Gemini timestamp that is not milliseconds.Perpetuals and margin, twelve endpoints.
get_positions/1,get_funding/2,get_contract_stats/2andnext_funding_timestamp/2;get_account_margin/1,list_funding_payments/1and the three funding reports; and the spot-margin trioget_margin_account/1,get_margin_rates/1andpreview_margin_order/2.Gemini sends a negative quantity for a short, and
Types.Positionrefuses to carry one::quantityis a positive size and:sidesays which way. A sign convention is a fact about one venue's JSON, not about the market, and passing it through hands a caller a position that is exactly backwards while every number in it stays plausible.notional_valuekeeps its sign, because that one is a value rather than a magnitude with a direction beside it.Settled funding and estimated funding stay in different fields. A real response carries
-1.50991beside-2.10595— 40% apart — which is how wrong a caller reading "the funding" would be. The sign is carried through unchanged: it means direction between longs and shorts, and normalising it would assert a convention Gemini did not state.Mark, index and last trade are three prices and none is the other. A position can be liquidated at a mark the market never printed, which is why
get_contract_stats/2carries mark and index separately and neither isget_price/2.get_positions/1publishes no liquidation price, andnilthere does not mean safe —get_account_margin/1carriesestimated_liquidation_pricefor the account.A private GET signs the full path including its query string. Gemini's report endpoints put the query in the signed
requestfield; signing the bare path yields a valid signature over the wrong string, which the venue reports as a credential problem rather than a parameter one. One string is built and used in both places.The spreadsheet reports return the venue's bytes, unparsed. 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.
fromDateandtoDatemust be given together or not at all — the venue makes each mandatory if the other is present, and one alone comes back bounded bynumRows, which is a real report over the wrong window.preview_margin_order/2enforces the venue's sizing rule up front:totalSpendfor a market buy,amountfor everything else, and a price for a limit order. Sending the wrong one previews a different order than the caller described.Margin rates arrive three ways per currency — hourly, daily and annual — and all three travel. Taking the hourly rate for the annual one is an error of four orders of magnitude that still looks like a rate.
supported_instrument_typesgains:perp. The venue's perpetuals surface was always there; the package's claim of[:spot]was a statement about the package that had stopped being true.Custodial staking, all six endpoints:
get_staking_rates/1(public,GET /v1/staking/rates),get_staking_balances/1,get_staking_rewards/1,get_staking_history/1,stake/3andunstake/3.The rate's unit is the whole risk. Gemini publishes three numbers for one position —
ratein basis points,ratePctas a percentage andapyPctannualised. The first two differ by a factor of a hundred and the third by compounding.Types.StakingRatecarries percentages only, both named: basis points are converted on the way in, and:apy_pctis never derived from:rate_pct— that needs a compounding frequency the venue did not state.A staked position is three amounts and stays three. The real shape is
balance: 10,available: 0,availableForWithdrawal: 10— redeemable in full, tradable not at all. A state the venue does not report isnil, never zero. Zero-balance rows are kept: the host adapter this replaces dropped them, which makes "no position reported" and "no position" the same answer.An unstake returns before it completes.
:amount,:amount_paid_so_farand:amount_remainingall travel, because a redemption unbonds on the chain's schedule and the three differ for most of its life.nilon the last two is "not reported", not "complete".opts[:provider_id]is required on both writes and is not defaulted. The same asset stakes with several providers at different rates; picking one here would stake or redeem at a rate the caller never chose. Missing it is{:error, :missing_provider_id}before a request is made.A transaction type this package does not know maps to
:other, with the venue's own word kept in:venue_type— a normalisation that loses the original cannot be audited when it turns out to be wrong.Notional balances and custody fees, closing this venue's fund-management surface:
get_notional_balances/3(/v1/notionalbalances/{currency}) andlist_custody_fees/2(/v1/custodyaccountfees).A notional balance is not a balance in another unit. The
amountis Gemini's ledger; theamountNotionalbeside it is Gemini's valuation of that quantity, at a rate it chose and does not publish here. Rows are returned as the venue sends them so the two numbers cannot be read as one. Reconcile a position withget_balances/2.A custody fee is a balance reduction with no trade behind it, which is the gap a consumer reconciling against fills alone cannot otherwise account for. An empty list means nothing was charged in the window asked for — never that the venue does not charge.
get_payment_method/3is declared absent:/v1/payments/methodsreturns the whole set and there is no path taking a method identifier. Filtering the listing here would answer with a snapshot while looking like a read, which is the distinction that callback exists to draw.The rest of money movement: payment methods, internal transfers, the allowlist writes and the transaction ledger.
list_payment_methods/2,add_payment_method/2,transfer_internal/4,request_approved_address/4,remove_approved_address/3andget_transactions/2.add_payment_method/2has two endpoints because the details differ by country —/v1/payments/addbankand/v1/payments/addbank/cad. 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.transfer_internal/4sends no address and no network — nothing leaves the venue. Both ends are required: a transfer with one missing is not a transfer, and defaulting either would move funds between accounts the caller did not name.request_approved_address/4returns the venue'spending-time. A successful response is not permission to withdraw; the entry sits under a time lock and a withdrawal to it before the lock lifts is refused.get_transactions/2returns every kind the venue records — fees and adjustments alongside fills and deposits.Money movement:
get_deposit_address/3,list_approved_addresses/1,estimate_withdrawal_fee/4andwithdraw/5. All four were:unsupported. This is the group where a defect moves funds and the one that can never be tested against the live venue here, so the rules matter more than the code.withdraw/5always sends an idempotency key. The venue acceptsclientTransferIdand treats it as optional; this does not. A withdrawal request that times out has an unknown outcome — the funds may already be moving — and without a key the safe-looking response, a retry, 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 requirement is documented and not guessed. The vendor says a memo is "required for certain networks that use memos (e.g., Solana, XRP, Cosmos)" and publishes no machine-readable list, so this package does not invent one.
opts[:memo_required]is a caller's assertion: passing it with no memo is refused here, where nothing has moved, rather than at the venue after the transfer is accepted.A withdrawal comes back
:pendingunless the venue says otherwise. The venue accepting one is not the chain confirming it, and a status this package does not recognise is pending rather than completed — a withdrawal the venue has not described has not arrived.An approved address can be on the list and still unusable. The venue reports
pending-timefor one inside its time lock and publishes no activation time, soApprovedAddress.usable?/2answersnil— unknown, not "ready". A status the venue invents later maps to:pending, because treating an unknown status as usable is the direction that loses money.A deposit address's
memo_requiredisnil, notfalse. This endpoint does not say, andfalsewould be a claim that no memo is needed — which on Solana or XRP loses the deposit.The fee estimate carries the destination, because fees differ by address on some networks and an estimate for one does not hold for another.
list_networks/2andlist_fee_promos/1.list_networks/2is the call that has to happen beforeget_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, two endpoints, and they are not symmetric:
GET /v2/network/{token}is public, while/v2/networks/{network}/assetsneeds the Fund Manager or Auditor role and returns "only the assets where your account has deposit and withdraw access enabled". Its answer is scoped to the credential, so an empty result 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.Rows stay the venue's own: its network names are its own, and translating them would invent a vocabulary it does not accept back.
list_fee_promos/1is notget_fees/2. That is the schedule applying to this credential; this is the public list of symbols where the venue charges something else, and a caller computing cost from the schedule alone is wrong for exactly these symbols. An empty list means no promotions are running, which is a real state.get_historical_prices/4routes perpetuals to/v2/derivatives/candles, which serves1mand nothing else.Sending a perpetual to the spot path is the failure this prevents, and it does not error. The symbol is well-formed and the spot endpoint answers, so a caller asking for 5m bars on
BTCGUSDPERPwould get bars back with no way to tell they were not the instrument it asked about.A width the derivatives endpoint does not serve is
{:unsupported_timeframe, width}: falling back to the spot path would answer about a different instrument, and falling back to1mwould relabel someone else's bars. Routing is onSymbolFormat.perpetual?/1, measured against the venue's own catalogue rather than guessed from the name.get_fx_rate/3—/v2/fxrate/{pair}/{timestamp}.This is not a rate the venue trades at. The vendor: "Gemini does not offer foreign exchange services. This endpoint is for historical reference only." The number comes from a third party the venue names under
provider, which this package carries asTypes.FxRate's:source—:providerstays:gemini, the venue relaying it. Collapsing the two would make a relayed BCB rate indistinguishable from one Gemini computed itself.Fourteen pairs are served and a pair outside them is refused before the request, because the venue's 404 for an unsupported pair reads the same as one for a bad timestamp — a caller sent there cannot tell which it got wrong.
The venue's own
asOfwins over the instant asked for: it may answer for a nearby moment, and its word is what happened. Requires the Auditor role, which the vendor states.The socket delivers the whole channel surface, not just
bookTicker.subscribe/3andunsubscribe/3take a channel and build the address throughWsChannels— the interval is part of the address for the…Fastand…Snapshotchannels, and a hand-assembled"{symbol}@depthFast"subscribes to nothing and produces silence rather than an error. A per-account channel takes[]for symbols and yields one address.A
@tradeframe's side is inverted fromm, which the socket delegates toWsDecode.to_trade/2rather than repeating — doing it in both places would undo it.A depth diff is delivered as a diff, not as an
OrderBook. Handing a subscriber the changed levels under a type that means "the whole book" is the substitution this family refuses. A sequence gap emits a:degradednotice, because the vendor's rule is discard-and-resubscribe and a consumer that keeps applying holds a book that is silently wrong from that frame onward with every price in it real. A partial-depth snapshot does become anOrderBook, carryinglastUpdateIdas the sequence.The new clauses are ordered before
bookTicker's, which is load-bearing: a depth diff carriess,bandatoo, so the older clause matched it and tried to read an array of levels as a price.The WebSocket surface: all twenty-two channels, their addresses, and decoders for the market-data frames. From the vendor's AsyncAPI document, read 2026-09-01 — not the rendered Stream Matrix, which shows eleven families and omits ten of these channels: the whole
requestForQuotefamily,connection, both…Snapshotchannels and the four…Fastdepth variants.Three rules in that document produce a plausible wrong answer if missed, and each is now guarded by a test.
mis "whether the buyer is the maker" — the opposite of the REST tape'stype. The same venue reports the trade side two different ways on two transports:/v1/tradesgives the taker's side directly, while@tradegives the maker flag.m: truemeans the buyer was resting and the seller aggressed. Carrying it through as a buy would invert every trade on the socket while agreeing with the REST field name, which is exactly how such a bug survives review.Timestamps are nanoseconds.
Eis documented as nanoseconds and the vendor notes the values exceed JavaScript's safe integer range. Read as milliseconds an event lands about fifty thousand years out; read as seconds it still looks like a date, which is worse.depthanddepthFastare differential, andU..uis the only way to know none were missed. The vendor: "if a frame'sUskips ahead of the last appliedu, discard the book and resubscribe to resync."depth_gap?/2is that check, and a frame with noUis treated as a gap because continuing would apply it blind. A quantity of zero deletes the level rather than setting it to zero, sodepth_changes/1returns it rather than filtering — filtering would drop the deletion and leave a level nobody quotes standing.Addresses are built rather than guessed: the interval is part of the address (
{symbol}@depth@100ms,balances@account@1s), a per-symbol channel with no symbol is an error, and a per-account channel given one is too —orders@accountwith a symbol appended is not a channel the venue has, and subscribing to it produces silence rather than a refusal.get_trades/2— the public tape,/v1/trades/{symbol}. Notget_trade_history/2, which is the credential's own fills.typeis the taker's side, and the venue says so explicitly: "buymeans that an ask was removed from the book by an incoming buy order". That is the opposite of the resting order's side, and a package reading it the other way inverts every entry on the tape while every number stays real.Broken trades are excluded unless
opts[:include_broken]asks for them. A busted print did not stand, and its price in a series becomes a phantom high or low in every range and volatility figure built on it. The venue's owninclude_breaksis sent as well as the filter being applied here — asking the venue is cheaper than filtering a page.opts[:since]goes as the venue'stimestampin milliseconds andsince_tidis passed through alongside it: the venue statessince_tidwins, and that precedence is left to the venue rather than resolved here.quote_conversion/4,commit_conversion/2andconvert/4— the Instant pair and the wrap endpoint./v1/instant/quotethen/v1/instant/executeis the two-step form: the venue states a price, a quantity, a fee and amaxAgeMs, and nothing moves until the commit./v1/wrap/{symbol}isconvert/4, the one-step form — no rate is held and the caller learns the price from the result.The expiry is anchored to the venue's own
Dateheader, not the local clock. A window computed against a drifted client expires at the wrong moment, and a conversion committed a second late fills at a rate the caller was never shown.The direction refuses more often than you would expect, and that is deliberate. The venue takes a symbol and a side, not a from/to pair, and
totalSpendisCCY2on a buy andCCY1on a sell. Deriving that needs to know which asset is the quote side — and this venue quotes in crypto as well as fiat, so forUSD -> BTCboth are quote currencies, both orientations parse, and only the catalogue says which pair exists. It returns{:ambiguous_conversion, from, to}rather than picking one; choosing wrongly spends the wrong asset, which is a real loss and not a wrong-looking number. Passopts[:symbol]andopts[:side].commit_conversion/2needs the terms the venue quoted against, not the id alone — the execute call takes symbol, side, quantity and price, and a missing one is an error rather than a value invented here.get_conversion/2stays unsupported: the venue quotes and executes and does not answer "what became of quote N". A caller that lost a quote re-quotes.get_trade_volume/2—/v1/tradevolume. One row per symbol per day with the maker and taker breakdown, under the venue's own field names. Notget_trade_history/2summed: this venue requires a symbol on every fills request, so reproducing it is one request per symbol per period and the answer would still be this package's arithmetic against the venue's ledger.cancel_all_orders/2, covering both of the venue's bulk cancels.:session -> POST /v1/order/cancel/session :account -> POST /v1/order/cancel/allopts[:scope]is required and there is no default. The account scope reaches orders no API key placed — the venue says so explicitly, including ones a person entered through its web interface — so choosing it for a caller who meant the session would cancel work nobody asked about, and choosing the session for a caller who meant the account would leave orders running. Gemini's own documentation recommends the session scope; that is guidance for the caller, not licence to pick here.Returns
%{cancelled: [id], rejected: [id]}, ids as strings like every other order id in this package. A non-emptyrejectedis not a failed call — the venue answered, and some of those orders were already gone.get_orders/2reaches/v1/orders/history. Resting and closed orders are two endpoints, not one with a filter, and only the resting half was implemented.history: trueasks for the other; a caller who does not say gets the resting ones, the set that can still change.symbol:,limit:andsince:are passed through in the venue's own names, and no default page size is substituted — one chosen here would silently become the caller's answer.
Fixed
BREAKING:
get_historical_prices/4returnsCore.Types.Candlewith:opened_at. It returned bare maps keyed on:timestamp, a name that does not say which end of the interval it is. A caller reading it as the close is off by exactly one interval, in a value that looks entirely reasonable. The fake carried the same shape.The
@unsupportednote claimedpreview_order/3"has no endpoint at all". Gemini publishesPOST /v1/margin/order/preview— a margin impact preview returning pre- and post-order risk statistics. That is not whatpreview_order/3asks, which is what the order would cost, so it is still not implemented as one; answering the cost question with margin statistics is exactly the nearby substitute this family refuses. But the endpoint is real, it is a real capability, and the note now says so instead of denying it.
Changed
get_transfers/2calls/v2/transfers(D6). The v1 path is absent from Gemini's published OpenAPI document, and v2's own description states "The v1 transfers endpoint is being retired." The three parameters are unchanged, so this is a path change only.
Added
ArchivedSocketsTest— fails the build if any code path speaks one of Gemini's four archived WebSocket APIs, or points a socket atapi.gemini.comrather thanws.gemini.com. This is the venue where that failure already happened once.
Added
- First release. Market data, order book, catalogue, quantization and streaming behind
DpExchange.Core.Venue. Every authenticated endpoint is declared:unsupported: signing is implemented and tested, but nothing here has run against real credentials, and declaring it:experimentalwould claim more than that deserves. - Streaming speaks
wss://ws.gemini.com, the API Gemini's current documentation describes — not theapi.gemini.com/v2/marketdataendpoint the prior adapter uses. Both answer today; only one is documented. Seedocs/reference/gemini/websocket-api-replacement.md. - Repo scaffold from the DpExchange standard; extraction pinned to the host's
553fa787with its working-tree state recorded, since the Gemini subtree was dirty at extraction time.
Measured against the live venue, 2026-08-28
Recorded with the evidence, because each contradicts something written down and "fixed the timeframes" with no evidence is not worth reading.
- The candle timeframe enum in Gemini's own documentation is wrong three ways out of
seven. The page lists
1h,6hand1d; the API rejects all three, and its 400 body names the real set:[1m, 5m, 15m, 30m, 1hr, 6hr, 1day]. The page also contradicts itself — prose says1day, its enum block says1d, and only the prose is right. - The candle window is fixed and
start/end/limitare ignored. Seven widths, 1440 one-minute bars down to 364 daily ones, reproducing the prior adapter's independent 2026-08-06 measurement exactly on all seven. Ranges are filtered client-side, and one reaching before the window is{:error, {:range_unavailable, …}}rather than a short answer that reads as a complete one. - No rate-limit headers exist. Only
date,x-request-idandx-envoy-upstream-service-time.get_rate_limit_status/2is:unsupportedrather than a constant that never moves. - No ticker publishes a quote timestamp.
/v1/pubticker's only timestamp stamps its 24-hour volume window;/v2/tickerhas none. Quotes carry the venue's HTTPDateheader, and a response without one is{:error, :missing_venue_timestamp}— never the local clock. - The venue publishes its burst depth, which no other venue in this family does, so all three GCRA parameters are declared rather than guessed: 120/min public, 600/min private, burst 5.
- Gemini now offers two nonce modes and they need differently-shaped values — seconds for time-based, monotonic for incremental — so the mode is a caller option rather than something this package can paper over.
The demo environment, and the boundary it does not move
environment: :sandboxpoints both transports at Gemini's demo exchange —api.sandbox.gemini.comandws.sandbox.gemini.com. Verified live: 391 symbols, the same REST shapes as production, and a WebSocket that acks and streamsbookTickerframes field-for-field like production.:productionis the default and an unrecognised value raises rather than falling back, because the failure is asymmetric — meaning demo and getting production sends a real order to a real exchange.A third documentation defect, found the same way as the first two. Gemini's market-data page names
exchange.sandbox.gemini.comas the sandbox base URL. That is the website:/v1/symbolsthere returns 404 and an HTML page, whileapi.sandboxreturns 391 symbols. The get-started page is right and the market-data page is wrong.The demo book is frequently crossed — a captured frame carried bid
68169.88against ask64886.32. Not corrected, reordered or filtered: the venue said it, and inventing a plausible book on top of an implausible one is the substitution this family refuses. Recorded so a consumer computing spreads against demo data knows why they go negative.Production and demo run side by side with nothing named. The supervisor, feed and limiter derive default names from the environment, so a consumer trading live while testing strategies against demo starts two trees and neither collides. Per-process selection through
Core.Configcovers the finer case — one strategy runner on demo while the trading path beside it stays on production. Two bugs were found by taking that case seriously rather than assuming it worked: a name collision that made the arrangement impossible, and — the dangerous one — a shared rate-limit bucket, where a call carryingenvironment: :sandboxbut no:limitermetered against the production budget. Demo strategy testing would have spent the budget live trading depends on, surfacing as a 429 on a real order at an arbitrary later moment with nothing pointing back at the cause.Authno longer decides which authentication is in use, and never did handle it. The scheme is now named by the caller —Auth.headers(:api_key | :oauth, …)— and an unknown scheme or mismatched credentials are refused rather than guessed at or partially signed. This package signs; the host authenticates and chooses which kind. Gemini offers an API key pair and a full OAuth 2.0 authorization-code flow with app registration, PKCE and 24-hour token refresh; the second needs a browser, a redirect URI and somewhere safe to keep a refresh token, none of which a venue package has. Guessing is also actively harmful: the venue returnsAmbiguousAuthentication(400) when V1 key headers and OAuth headers arrive together..env.samplecarries no venue credential, because there is nothing here for one to do. An unused credential in a public repo is a liability with no upside.
Found in dp_exchange_core while writing this, and fixed there in 0.1.8
Capabilitiesceilings had nowhere to carry a burst depth, so a venue that publishes one had to hardcode it beside the declaration it was supposed to configure.HttpClientflattened a 4xx into a message string, leaving{:refused, reason}reachable only by string-matching.raw_status: truereturns the response intact.HttpClient.request/5's spec advertised a rate-limit return shape it never produces.