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]
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.