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 — pin three-part (~> 0.1.0). 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 —
which venue, what was run against it, and when. "Marked proven" with no evidence is not
an acceptable changelog line.
[Unreleased]
Added
PollingFeedgains:on_notice— a feed that knows it has delivered nothing now says so on a channel a consumer can act on, not only in a log line, per DpCryptoManagement's issue #21.PollingFeedalready detected this condition and named it in its own words —Logger.warning("... has delivered NOTHING in 154 consecutive attempts ...")— and stopped there. Issue #21 was found only because a human went grepping logs for that literal sentence; issue #22 took days for the same reason on a different venue. ALogger.warningis not a signal a supervising process can subscribe to.:on_noticeis an injected function, the same shape:on_refusalalready is, called with a%Core.Notice{kind: :coverage_change}the instant the feed crosses INTO the delivering-nothing state, and aseverity: :inforecovery notice the instant it crosses back OUT — a consumer that learns a feed died and never learns it recovered is only half-served.:coverage_changewas chosen over inventing a new kind: it is the same kinddp_exchange_coinbaseuses for the sibling case (a channel subscribe that exhausted its retries without ever becoming delivery), and "subscribed intent not becoming delivery" is exactly what a feed delivering nothing is. It fires once per transition, never once per failed tick and never once per sweep while an outage continues — the existing "delivered NOTHING" log line still repeats every sweep by design, so a consumer wanting only that repetition still has it; the notice channel is additive, not a replacement.detailscarries the feed'slabel, the consecutive failure count, and the last error — never a credential or a raw payload;Core.Notice.new/3refuses credential-shaped keys outright and would raise if it carried one.Defaults to a no-op, so every existing caller of
PollingFeed.start_link/1is unaffected. Wiring Robinhood's and Schwab's own feeds to fan this out to theirsubscribe_notices/1subscribers is a follow-up once this ships — Core has to publish first, since both packages depend on it from Hex.coverage_by_kind/1— the Core half of splittingcoverage/1by data kind, per DpCryptoManagement's issue #22.coverage/1is correct and unchanged: it counts any payload for a symbol as delivering, aTypes.OrderBookexactly as much as aTypes.Quote. That is why Coinbase'slevel2channel delivering over 11,000 frames for 406 symbols whiletickerwas dark for all but 5 still reportedcoverage/1as:streamfor all 406 — truthfully, and uselessly, because "one kind dark, another healthy" and "everything healthy" produce the identical map. Verified by running it:coverage after ONLY an OrderBook (no ticker quote): %{"XLM-USD" => :stream}. Seedocs/design/2026-09-05_coverage-by-data-kind.mdfor the fuller account, including why the consumer's own two proposed fixes (:subscribed_pending, adelivering/1companion) would not have caught this: both split subscribed from delivering, and this defect was never about that axis.@callback coverage_by_kind(keyword()) :: %{Capabilities.data_kind() => %{symbol() => route()}}reuses the existingdata_kind()vocabulary rather than inventing a parallel one, and is added toVenue.@optional_callbacksrequired to be optional: a venue package depends on Core from Hex, so a required callback here would mean every venue instantly failing completeness the moment this version publishes — the exact cross-repo coupling that caused a premature-deploy incident once already and delayed the:gfw/:gfmwiring behind a Core release before that.required_callbacks/0is unchanged;peripheral_endpoints/0classifies it irreplaceable and not load-bearing.AdapterContractgains assertion group 15, asserted only when a venue exports the callback (Code.ensure_loaded?/1thenfunction_exported?/3— the former is what stops the latter spuriously reportingfalsefor a merely-unloaded module): the union of symbols across every kind must equalcoverage/1's own key set exactly, and every kind key must be one the same venue's owncapabilities().streamabledeclares. An absent callback asserts nothing — a venue that has not adopted yet is not a failure, and the moduledoc says so in theifguard's own comment so nobody "fixes" it into a hard requirement later.ReferenceVenuedeliberately does not implement it, so Core's own conformance run (AdapterContractTest) is the regression proof that the suite stays green against a non-adopting venue; three fixtures incontract_teeth_test.exsreplicate the assertion's exact computation against a conforming fake and two deliberately broken ones (a union that drops a symbol, a kind not declared instreamable), the same pattern assertions 1, 4 and 12 already use in that file.The moduledoc's own group count was wrong before this landed — it said "Thirteen groups" while
assertions/0already listed fourteen, a drift caught while adding the fifteenth. Corrected alongside every other place in this repo that names a callback or assertion count (README.md,usage-rules.md,usage-rules/adapter.md,usage-rules/testing.md,usage-rules/feeds.md,docs/guides/building-an-exchange-package.md) — 87 callbacks became 88, fourteen assertion groups became fifteen.usage-rules/feeds.mdandusage-rules.mdboth document the failure this callback exists to make visible, not only the callback's shape — a consuming agent reading either now learns thatcoverage/1alone cannot distinguish a half-dead feed from a healthy one, which is the whole reason this shipped.Venue adoption (Coinbase, Gemini, Webull, Schwab, Robinhood) is tracked separately in the design doc's checklist and is not part of this change — Core ships first, by design.
Fixed
The
nil-vs-absentKeyword.gettrap, closed as a class rather than one incident at a time (C1).polling_feed.ex's:start_delay_msalready carried a fix and an incident comment; the same trap was open at every other default-bearing option inPollingFeed,HttpClientandDefaultRateLimiter— reachable because every venue forwards its ownoptsunchanged by family convention, so a key the caller never set arrives askey: nilrather than absent, andKeyword.get(opts, key, default)only substitutesdefaultfor an ABSENT key.interval_ms: nilcrashedProcess.send_after/3and restarted the feed straight into the same crash;on_refusal: nilraisedBadFunctionError;symbols: nilraised insideMapSet.new/1.HttpClient'sretry_attempts: nilwas worst: Erlang term ordering sortsnilabove every integer, sonil > 1istrue, and a forwardednilsilently entered the retry branch and died computing4 - nil— anArithmeticErrorraised directly in the calling venue process, which this library does not supervise. Fixed with one shared helper,DpExchange.Core.Config.opt/3, applied at every reachable site across the three modules (not only the four originally named) — a present-and-nilvalue is now treated the same as an absent one everywhere a default applies, and an explicitfalseis still honoured, becauseopt/3deliberately does not use||.PollingFeed— a hung fetch wedged the entire feed, silently (C2).fetch/fetch_allran synchronously insidehandle_infowith no timeout boundary;safely/1caught a raise or anexit, not a call that simply never returns. Verified with a fetcher doingProcess.sleep(:infinity):status/1andcoverage/1never answered, every symbol went dark, and nothing was logged — which defeats this module's own headline design, since its moduledoc exists specifically to make a silently-broken feed loud. Every fetch now runs inside a bounded, disposableTask(bounded_fetch/2,Task.async+Task.yield+Task.shutdown), and a hang past:fetch_timeout_msbecomes an ordinary fetch failure — retried next tick, counted towardfailures_since_ok, escalated by the existing "delivered NOTHING" warning. The default timeout is derived from the poll interval and clamped between 30s and 60s: a floor aboveHttpClient's own 30s per-request default, so a short interval cannot self-sabotage an entirely ordinary retrying HTTP call, and a ceiling so a venue polled once an hour cannot wedge this feed for an hour.DefaultRateLimiter—timeout: nilsilently disabled the wait ceiling (C3).acquire/3read:timeoutwith a plainKeyword.get/3, so a forwardedtimeout: nil— reachable fromHttpClient, whoselimiter_opts/1forwards:timeoutverbatim — producedwait_ms > nil, which Erlang term ordering makes always false. "Fail closed after N ms" silently became "wait however long it takes". Verified live against an exhausted bucket. Covered by the sameDpExchange.Core.Config.opt/3fix as C1, and asserted with its own regression test: an exhausted bucket withtimeout: nilnow refuses near-instantly (the refusal is decided on the server, before any sleep) rather than sleeping out a near-minute wait in the caller.HttpClientunder-recorded real venue usage (C4).record/3— the call that fills the bucketacquire/3andcheck/3measure against — was only reached from the{:ok, response}branch of the request pipeline. A retried 5xx and a venue 429 both genuinely reached the wire and genuinely consumed the venue's quota, and neither was recorded — the same mechanism as the incident already recorded in this module's own moduledoc ("395 calls per 60s against a documented 300, while the budget panel read 83/240"): the missing calls there were exactly the retried and rate-limited ones this closes. Every outcome of a request that actually reachesmake_http_request/5— success, retry, 429, or a permanent 4xx — is now recorded exactly once, right after the request is made and before the result is inspected; a request refused by the limiter itself, before anything left the process, is still not recorded.Types.*—@enforce_keysguarded presence, notnil(C5).%Candle{open: nil, high: ..., low: ..., close: ..., ...}built without complaint despiteopen's typespec declaringDecimal.t(), neverDecimal.t() | nil— exactly what a JSON decode bug on a renamed venue key produces, and the failure only surfaced later, deep insideDecimal, far from where the bad data entered. EveryTypes.*module now exposes a validatingnew/1, built on a new shared helper,DpExchange.Core.Types.Validate, that checks every field named in the module's own@enforce_keysfornilas well as presence and raisesArgumentErrornaming the offending field.Types.Orderis the one deliberate exception: its own moduledoc documents that six of its seven enforced keys legitimately admitnil("the venue's word, or nothing"), so itsnew/1narrows the check to:provideralone, the one field that was never meant to benil. Struct literals (%Candle{...}) are unchanged and remain valid for internal and test use;new/1is the path a venue's own decoder should prefer.CanonicalPairtrusted caller-supplied quote ordering (C6). The moduledoc requires a venue'squoteslist to be given longest-first; nothing enforced it, and the module's own round-trip invariant does not catch a misordering — concatenation round-trips byte-for-byte regardless of where the cut landed. Verified:quotes: ["USD", "BUSD"]mis-split"ETHBUSD"into"ETHB-USD".quotesis now sorted by length, descending, insideCanonicalPairitself before any suffix match is attempted, so a caller cannot get the ordering wrong any more, whatever order it hands in.
Added
time_in_forcevocabulary extended with:gfwand:gfm— "good for week" and "good for month" (C7). Real Robinhood values, confirmed in the vendor's own OpenAPI schema (both the order request and response schemas, enum["gtc","gfd","gfw","gfm"]), with no slot in this contract's vocabulary before now. Purely additive: existing venues declaring a subset ofsupported_time_in_forceare unaffected. Robinhood could not use the new values until this shipped to Hex, so wiringRobinhood.to_order/1andorder_config/2was sequenced as a follow-up rather than done in the same batch — the cross-repo atom coupling is what caused a prior premature-deploy incident. That follow-up has since landed:dp_exchange_core0.1.45 published these atoms, anddp_exchange_robinhoodnow decodes all four vendor values and raised its dependency floor to~> 0.1.45so it cannot compile against a Core lacking them.DpExchange.Core.FakeInjection— deterministic failure injection and a credential-free wiring mode for a venue'sFake— DpCryptoManagement's issue #14. None of the four venueFakes exposed aconfigure/1-shaped seam for exercising a consumer's own retry/circuit-breaker code, or a way to skip aFake's venue-faithful credential check to test pure dispatch/decode logic. Built onCore.Config's existing process-scoped override machinery rather than a new mechanism — the exactasync: trueisolation guarantee every other seam in this family already has.Deterministic by design: outcomes are queued explicitly and popped in order, never a probability. Per-symbol targeting composes with whole-call injection — a symbol-specific queue is checked first, and a symbol-targeted failure can never affect a different symbol's call, matching this family's established rule that one bad symbol must not fail a whole batch. Function-level targeting was deliberately left out: the feature this replaces asked for one global knob, and no filed need asked for more.
This ships the shared mechanism only; the four
Fakes adopt it one at a time in their own packages, starting with Robinhood. Seedocs/design/2026-09-04_webull-sharding-and-fake-injection.md§3.6/§3.7.The conformance suite now asserts coverage rather than accepting it as a claim (O4). Three new assertions, and the one worth naming exists because the drift it hunts had just happened: a venue package declared six streamable kinds while its socket was written, tested and never called by the facade. Four of the six reached no subscriber by any route, and every test passed for a release — the socket's own tests exercise its callbacks directly, and nothing asked what a consumer receives.
- Every absence has a recorded cause. An endpoint named in
venue_does_not_serve/0must actually be declared:unsupported. The mislabel goes both ways and both are defects: a venue's own absence filed as a backlog item invents work that cannot be done, and a backlog item filed as the venue's absence hides a capability a consumer could have had. Robinhood shipped four of the first kind and no test failed — nothing fails when a comment is wrong. streamablenames only kinds this contract has a word for. A structural check cannot prove delivery, but it can refuse a vocabulary the contract does not define, which is where over-declaration usually starts.- A streamed kind is not contradicted by its own package. A kind declared streamable
while the same package's
venue_does_not_serve/0says the venue has no such data at all is a contradiction that cannot be true in either direction.
All five venue packages pass the three today; they were run against each before this landed.
- Every absence has a recorded cause. An endpoint named in
Fixed
Notice.reject_credentials!/1could exhaust the VM's atom table from venue-derived input (C8). It normalised everydetailskey withString.to_atom/1before comparing it against the credential vocabulary. Atoms are never garbage collected and the atom table is finite;detailsmaps are built by venue packages from venue-supplied content (a channel name, a raw payload key, a symbol) with nothing in the contract bounding their keys, so a venue varying that content could walk the table to exhaustion and kill the whole node — through a guard whose entire purpose is to make notices safe. Fixed by deriving a string set from@credential_keysonce, at compile time, and comparing every incoming key as a downcased string; no atom is ever created from caller input. SameDOS.BinToAtomclassCore.FakeInjectionwas already built to avoid. The raised error still names the offending keys exactly as before.PollingFeedcrashed when a caller forwardedstart_delay_ms: nil. Robinhood's and Schwab's ownFeedwrappers both build this option withKeyword.get(opts, :start_delay_ms)and no default of their own — a present key with anilvalue whenever their caller never set one.Keyword.get/3's own default only substitutes for an ABSENT key, not a present-and-nil one, sostate.start_delay_msended upniland crashed inProcess.send_after/3. Fixed at this layer with|| @default, so every venue'sFeedis covered rather than each patching its own pass-through.A stray zero-byte
lib/dp_exchange/x.newwas shipping in the tarball. It arrived as a redirect artefact incf03c21and had been published in every release since. Found by doing whatmix.exs's own comment block says to do — inspectingmix hex.buildoutput before publishing — which is the same check that caught the 4.4 MB PLT. Nothing warns about either; the only defence is reading the file list.
Documentation
Three new guides, and the first is the one this plan most needed.
usage-rules/auth.mdstates the split once, plainly — storage is the host's, use is the package's — and then does the thing nothing in the family did: a per-venue table. Schwab is three-legged OAuth with a one-time-use refresh token on a seven-day sliding window; Gemini is HMAC or OAuth, sharing a refresh URL with the host's own code exchange and separated only bygrant_type; Coinbase and Robinhood are Ed25519; Webull has two token systems, one of which returns200with a token that does not work until a person enters an SMS code.A host integrating two venues implements two different things, and until now nothing said so. It also carries the restart-versus-refresh decision table: a host that does not know that distinction loses sessions silently and has no operator action available.
usage-rules/money-movement.md— the group where a defect moves funds, and the only one that can never be tested here. Preconditions in order, with the reason each is not style advice: the network is required and never defaulted because funds sent to a chain the venue does not credit are gone;memo_required: nilmeans the venue did not say, not that no memo is needed; a retry without an idempotency key withdraws twice, which is why this family always sends one rather than waiting to be asked.usage-rules/environments.md— running live and demo in one supervision tree, resolved per process rather than per node. Records what each venue actually offers: Gemini's demo is a full exchange with test funds, Webull's UAT has REST and no broker at all, and the other three have nothing.The four existing guides are rewritten around the surface that shipped.
feeds.mdnow covers four pushing venues and one polling behind the same facade;symbols.mdcovers venues whose symbol is not a pair, where the work is refusal rather than transformation;testing.mdstates which of the four tiers each capability group can actually reach, and which cannot be reached at all;adapter.mdcovers the options surface, the two-list split for absences, and the negative-claim audit as a required artefact.docs/reference/core/negative-claims.md— Core's negatives are about the contract and the ecosystem rather than a venue, and they are audited the same way. Every one holds. The packaging claim needed correcting: 7.5 MB of saved Schwab portal HTML sat indocs/guides/, which is infiles:, and would have published inside a package whose whole premise is that it ships nothing venue-specific.README.mdstates what the contract covers — 87 callbacks by group — and indexes the seven guides.AGENTS.mdpoints at them.
Added
place_orders/3— several orders in one request, which closes OQ8.It is not
place_order/3in a loop. A batch is one request the venue accepts or rejects as a unit; N calls are N partial outcomes a caller has to reconcile, and the reconciliation is exactly what goes wrong when the third of five fails. A venue that publishes a batch endpoint gives a consumer an atomicity it cannot build from the single-order call, which is why this is a callback rather than a helper a consumer writes.A partial batch is the shape to expect, not the exception. Venues validate per order and return per order, so the result is a list the same length as the request — each entry either an order or the venue's refusal of that one. Collapsing it into a single ok-or-error is the failure this callback is documented against: a caller told "the batch failed" when four of five were placed has four positions it does not know about.
Venues cap the size — Webull at 50 — and a request over the cap is refused by the venue rather than split by a package. Splitting turns one atomic request into several and quietly undoes the only reason to call it.
Changed
Types.Order's:symboland:idnow admitnil, joining the four that already did.Robinhood acknowledges a cancel request without describing the order it cancelled: there is an id and nothing else. Inventing a symbol to satisfy a type would put a guess where the venue was silent, which is the one thing this type's enforced-but-nullable keys exist to prevent. The keys stay enforced so a constructor must decide; the types admit
nilso the decision can be "the venue did not say".asset_classes/0's vocabulary widened from[:crypto, :equity]to[:crypto, :equity, :option, :future, :event_contract], and the conformance suite's known-classes assertion with it.The narrower list was not a decision about scope — it was the set of classes any package had reached so far, frozen into an assertion. The first package to serve option endpoints could not declare it without failing conformance, and a class a venue serves but cannot declare is a class the host cannot route to.
asset_classes/0is a statement about a package today; the contract now says so where it is declared.
Added
place_orders/3— several orders in one request, which closes OQ8.It is not
place_order/3in a loop. A batch is one request the venue accepts or rejects as a unit; N calls are N partial outcomes a caller has to reconcile, and the reconciliation is exactly what goes wrong when the third of five fails. A venue that publishes a batch endpoint gives a consumer an atomicity it cannot build from the single-order call, which is why this is a callback rather than a helper a consumer writes.A partial batch is the shape to expect, not the exception. Venues validate per order and return per order, so the result is a list the same length as the request — each entry either an order or the venue's refusal of that one. Collapsing it into a single ok-or-error is the failure this callback is documented against: a caller told "the batch failed" when four of five were placed has four positions it does not know about.
Venues cap the size — Webull at 50 — and a request over the cap is refused by the venue rather than split by a package. Splitting turns one atomic request into several and quietly undoes the only reason to call it.
Three more account-and-funding callbacks:
get_payment_method/3,get_notional_balances/3andlist_custody_fees/2.get_payment_method/3exists because a listing is a snapshot. A funding source's verification state changes without the account doing anything — a bank closes, a card expires, a venue suspends a rail. Picking the row out of an earlierlist_payment_methods/2result reads a status that may have been true an hour ago, and moving fiat against it is the failure that produces.get_notional_balances/3is notget_balances/2in another unit. The quantity is the venue's ledger; the notional figure beside it is the venue's valuation of that quantity at a rate the venue chose and does not have to publish. Two venues will disagree about the notional value of the same holding and both be right about the balance. Rows stay the venue's own maps so the two numbers cannot be read as one — the valuation is the one that is only ever an estimate. Reconcile positions withget_balances/2; this is for reporting.list_custody_fees/2explains a balance reduction with no trade behind it. Custody fees are periodic and come straight out of the balance, so a consumer reconciling against fills alone finds a gap it cannot account for. An empty list means the venue charged nothing in the window asked for — it never means the venue does not charge. A venue with no custody product returns{:error, :not_supported}, which is what tells the two apart.Six money-movement callbacks:
list_payment_methods/2,add_payment_method/2,transfer_internal/4,request_approved_address/4,remove_approved_address/3andget_transactions/2.transfer_internal/4is notwithdraw/5. Nothing leaves the venue, no chain is involved and no address is required. Conflating them is dangerous in both directions: a caller reaching forwithdraw/5for an internal move pays a network fee it did not need to, and one reaching for this expecting an external transfer sends nothing anywhere.request_approved_address/4is the most consequential write in this contract — an address on the allowlist is one funds can be sent to. It requests rather than grants: venues hold new entries under a time lock, and a successful response is not permission to withdraw. Removal is separate and generally immediate, which is the asymmetry to expect — a venue is slow to widen what funds may reach and quick to narrow it.A payment method being listed does not mean it is usable, and a newly added one is pending: venues verify a bank account out of band and the API call only starts that.
detailsstays the venue's own shape, because bank details differ by country and a normalised struct would be wrong for every country but one.get_transactions/2is wider than bothget_trade_history/2andget_transfers/2— fees, interest, dividends and adjustments alongside deposits and fills. Summing it is not a balance;get_balances/2is the authority and this is the explanation.list_networks/2andlist_fee_promos/1.list_networks/2is whatget_deposit_address/3needs before it can be called. That callback takes a network, and nothing else in the contract said which networks a venue accepts for an asset. Guessing one produces an address on a chain the venue does not credit, and funds sent there are gone — the single most expensive mistake available in this surface. It answers both directions, because venues publish both and they are different questions: which networks carry an asset, and which assets a network carries.Rows stay the venue's own maps. Network naming is not standardised — one venue's
ethereumis another'sERC20— and normalising here would invent a vocabulary no venue accepts back.list_fee_promos/1is notget_fees/2. That returns the schedule applying to a credential; this is a public list of symbols where the venue charges something other than its published schedule. A caller computing cost from the schedule alone is wrong for exactly the symbols on this list.get_fx_rate/3andTypes.FxRate. Gemini publishesGET /v2/fxrate/{pair}/{ts}and the family had no shape for it.It is not a rate the venue trades at. Gemini's own documentation says it "does not offer foreign exchange services" and that the endpoint is "for historical reference only"; the number comes from a third party the venue names. So
:sourceand:benchmarkare carried alongside the rate, and:provider— the venue relaying it — is a separate field. Collapsing them would make a Gemini-relayed BCB rate indistinguishable from one Gemini computed itself, and only the second would be the venue's own claim. Two venues relaying the same pair at the same instant can legitimately disagree, and a caller reconciling them needs to know it is comparing sources rather than finding a bug.:as_ofis the instant asked for, echoed by the venue. A rate without it is a number with no time attached, which is not a rate.get_trades/2— the public tape.Types.Tradealready existed and nothing could return it; two venues publish the tape and the family had no callback for it.It is not
get_trade_history/2, which returns the credential's own fills. The tape is everyone's executions and has no order of yours behind it — answering one with the other hands a caller a filtered view of the market and calls it the market.Types.Tradegains:broken, defaulting tofalse. Exchanges bust erroneous prints, and a broken trade did not stand: its price is not a price the market traded at. Leaving one in a series puts a phantom high or low into every range, breakout and volatility figure built on it, and none of them will error.get_trades/2excludes them unlessopts[:include_broken]says otherwise — hiding them entirely would conceal that the exchange made a correction.The moduledoc now also records what
:sidemeans: venues report the taker's side, so Gemini'sbuymeans an ask was removed by an incoming buy order. A package mapping that to "the maker was selling" inverts every entry while every number stays real.get_auction_imbalance/2andget_volume_profile/3, withTypes.AuctionImbalanceandTypes.VolumeProfile. Two equity-microstructure capabilities Webull publishes that the family had no facade or shape for.An auction imbalance is not a quote or a book. During an auction the continuous book stops being the price; what matters is how much can be matched, how much cannot, and where it would clear — three numbers a
Quotehas nowhere to put. A caller reading a continuous quote at 15:59 is reading a book that is not where the close will happen.opts[:auction]is required, because the opening and closing auctions are different auctions with different windows.The imbalance side is carried as the venue sent it, unmapped. Venues publish the direction as a code and the tables differ — Webull documents
imbalance_sidewith the example"2"and does not say what 2 means. Guessing it backwards tells a caller there is unmatched buying when there is selling: wrong, entirely plausible, and at the one moment of the day with the most volume behind it.A volume profile is not a candle with extra fields. A candle's single volume number cannot say that of 1,000 shares 600 lifted the ask and 400 hit the bid, nor at which prices each happened, and neither type is derivable from the other.
:deltais the venue's own figure and is not recomputed from the totals: a venue that classifies some prints as neither aggressive buy nor sell reports numbers that do not reconcile, and that gap is information about its classifier rather than a fault to paper over.get_auction_imbalance/2returns a list, newest first, because the venue publishes a series: the imbalance updates every few seconds through the auction window, and how it moved is the point.opts[:history]selects the published series where a venue serves the snapshot and the series separately — the same shapeget_orders/2uses for resting versus closed orders. A series entry may carry less than a snapshot: Webull's NOII bars publish the three prices and the time and not the quantities or the side, which come backnil— the venue did not publish them there, rather than the imbalance being zero.:event_contractin the instrument-type vocabulary. Webull lists event contracts as a tradable instrument type and the vocabulary had no term for one, so a package serving them had to declare something untrue.It is not an option and not a future. There is no strike, no underlying to deliver, and the payoff is a step at 0 or 1 rather than a curve — declaring one as
:optionwould hand a caller a Greeks-shaped hole where the instrument has no Greeks.convert/4andget_trade_volume/2onVenue. Two more Gemini endpoints with no facade.convert/4is not a shorthand forquote_conversion/4pluscommit_conversion/2, and the difference is who carries the price risk. The two-step form shows a rate and holds it: the caller sees the number before anything moves.convert/4executes at whatever the venue's price is on arrival and the caller learns the rate from the result. A package cannot manufacture the first from the second — quoting a rate it computed itself and calling it held would be a promise the venue never made — so a venue declares each independently. Gemini's/v1/wrap/{symbol}is the one-step form.get_trade_volume/2is the account's own volume, not the market's, and notget_trade_history/2summed. The venue's aggregation is what its fee tiers are computed from; reproducing it means every fill over the reporting window — one request per symbol on a venue that requires one — and the result would still be this package's arithmetic rather than the venue's ledger. Where they disagree, the venue's decides what a caller is charged.cancel_all_orders/2onVenue. Gemini publishes two bulk cancels and the family had no facade for either.opts[:scope]is required and has no default.:sessioncancels what this credential's session opened;:accountcancels everything the account has open, including orders placed by another key or by a person at the venue's own web interface. A default would make the wider, destructive reading the answer to a question nobody asked, and the narrower one would silently leave orders running. The caller states it.It is not
get_orders/2pluscancel_order/3in a loop: that is N requests with N partial outcomes and cannot reach an order that appeared between the listing and the cancels.Returns
%{cancelled: [id], rejected: [id]}. A non-emptyrejectedis not a failed call — the venue answered and some orders were already gone.preview_replace/4andclose_position/3onVenue. Both are Coinbase endpoints the family had no facade for, and both are the kind that cannot be assembled from the calls that already exist.preview_replace/4is notpreview_order/3with an order id. The venue prices an amendment against the resting order's own state, including whatever of it has already filled. A caller who asks what a fresh order would cost is asking a different question and getting a different number. Without it the choice is committing to an irreversible amendment blind, or cancel-then-place — which reopens the windowreplace_order/4exists to close.close_position/3is notget_positions/1plusplace_order/3. The size a caller computes is the size as of the caller's last read; the venue's is the size now. On a position that moved in between, the caller's arithmetic leaves a residue or overshoots into a position the other way. Only the venue flattens to exactly zero, which is why it returns anOrder— it is an order, placed on the caller's behalf with a side and size the caller never states.Both are peripheral, both record which of the two tests they fail, and every venue that does not serve them returns
not_supported()as before.
Changed
Types.Order'sside,order_type,quantityandstatusadmitnilin the typespec. They always could in practice — a venue sending a status this package does not recognise has producednilsince the beginning — and the typespec said otherwise, which meant dialyzer accepted the wrong thing and rejected the right one.Coinbase's
close_position/3is where it surfaced: the venue never states the side of a closing order, and the type left no way to say so. The keys stay enforced, so a constructor must still decide; the types now allow that decision to be "the venue did not say".BREAKING:
Core.Types.Quoteno longer carries:bidand:ask. They are order book data — resting orders — andQuoteis trade data. Every venue package in the family was filling them, and one readprice || askfrom a best-bid/ask endpoint, producing a quote whosepricewas a resting order. Every value was real; only the meaning was wrong.A caller wanting the top of the book calls
get_top_of_book/2. A caller wanting what traded callsget_price/2. Neither can stand in for the other.Core.Types.Quote's:timestampguarantee is unchanged and now load-bearing: the venue's own, used as-is. Observation time lives onTopOfBook.observed_at, in a field that says what it is.
Added
Options.
Types.OptionContract(identity only — no prices),Types.OptionGreeks(model output, with the theoretical value named:model_pricebecause it is the field most easily mistaken for a price),Types.OptionChain(two-dimensional, expiry → strike →{call, put}, a one-sided strike keepingnilrather than a missing key), andTypes.OrderLeg. Callbacksget_option_chain/2,get_option_expirations/2,get_option_greeks/2.A chain row carrying bid, ask, last, mark and theoretical value offers five plausible prices and no help choosing, so it is split three ways: identity here, book on
TopOfBook, last trade onQuote.:multiplierofnildoes not mean 100. A venue that cannot trade multi-leg must refuse, never decompose — a caller left holding one filled leg has naked risk it never chose.BREAKING:
get_historical_prices/4returns[Types.Candle.t()], not[Types.Quote.t()]. It declared quotes, and the venue packages returned bare untyped maps with their own key sets — so the declared type was false and nothing compared one venue's candles to another's.Types.Candlenames its time field:opened_at, because venues disagree about whether a bar is stamped at its open or its close and the difference is one whole interval — a series joined across both conventions is misaligned by a day with every value correct.coherent?/1catches a malformed bar at the boundary.:volumeisnilwhen unpublished, never0.Types.Ordergains:time_in_forceand:legs.Capabilities.supported_time_in_forcedeclared what a venue accepts while the order type had no field for it, so a caller reading an order back could not tell an IOC that expired from a GTC still working.Derivatives.
Types.Funding(settled:amountkept apart from:estimated_amount— a real response has them 40% apart) andTypes.ContractStats(mark and index are separate prices, and neither is a traded price), withget_funding/2andget_contract_stats/2.Conversions.
Types.Conversionplusquote_conversion/4,commit_conversion/2andget_conversion/2— the facade's only two-step write.:expires_atis the point: committing an expired quote can fill at the current rate, which looks like success.expired?/2returnsnilwhen no expiry was stated — unknown, not valid.Portfolios.
Types.Portfolioandlist_portfolios/1. A portfolio is an address, not a value; balances, orders and positions are addressed withportfolio: idinoptsrather than by adding a parameter to forty signatures.Money movement, write side.
Types.DepositAddress,Types.ApprovedAddress,Types.Withdrawal, andget_deposit_address/3,list_approved_addresses/1,estimate_withdrawal_fee/4,withdraw/5.withdraw/5is the only operation in this contract that cannot be undone. The allow-list is first-class:ApprovedAddress.usable?/2returnsnilfor a pending address with no stated activation, because venues delay first use precisely so a stolen account cannot add an address and drain it.DepositAddress.memo_requiredis tri-state — a deposit missing a required memo is credited to nobody, sonilmust never be defaulted tofalse.:networkis enforced on both.Core.Types.Positionandget_positions/1— exposure, distinct from a balance and not derivable from one.:sideis explicit and:quantityalways positive, because venues disagree about how to say "short" and a guessed sign convention yields a position that is exactly backwards while every number stays plausible. Realised and unrealised P&L are separate and never summed.:liquidation_priceofnilmeans the venue did not say, not that the position is safe.data_kindgains:top_of_book,:candlesand:positions. Measured against Gemini's AsyncAPI and Schwab's Streamer service list: all three are streamed by a venue in the family and had no kind.:top_of_bookis deliberately not:order_book— venues stream them on separate channels because one carries a level and the other a book.t:data_kind/0records the full channel-to-kind mapping so it can be checked rather than trusted.Custodial staking. Six callbacks —
get_staking_rates/1,get_staking_balances/1,get_staking_rewards/1,get_staking_history/1,stake/3,unstake/3— and ahas_stakingcapability flag, which earlier notes recorded as shipped and which did not exist.Custodial only. A venue that returns an unsigned transaction for the caller to sign and broadcast is doing something else, and one venue publishes both. A caller believing it had staked when it holds an unsigned transaction nobody signed is the most expensive form of this family's recurring failure.
Four types, shaped by the venues' published schemas:
Types.StakingBalance— keepsstaked,available_to_tradeandavailable_for_withdrawalapart; a real response has the whole position redeemable and none of it tradable.by_provideris carried, not summed: a redemption is addressed to a provider.Types.StakingRate— percentages only,rate_pctandapy_pctboth named. One venue publishes basis points, a simple percentage and an APY for the same position;bps_to_pct/1lives here so the 100× conversion is done once.Types.StakingReward— carries its accrual period and the rate at accrual.Types.StakingTransaction— carries the unbonding progressionamount/amount_paid_so_far/amount_remaining.settled?/1returnsnilwhen the venue reports no progress — unknown, not complete.
Core.Types.TopOfBook— best bid and ask, with nopricefield.bid_sizeandask_sizeare optional (nilmeans not published, never zero);venue_timeis the venue's own ornil, since several BBO endpoints publish none;observed_atis required.mid/1,spread/1andcrossed?/1are functions, not fields — a mid is derived, and a caller has to ask for it rather than find it sitting there looking like venue data.get_top_of_book/2on theVenuebehaviour, registered inperipheral_endpoints/0.Conformance assertion 14, "top of book is not a price" — asserts the returned struct is a
TopOfBook, thatobserved_atis set, thatvenue_timeis the venue's ornil, and thatTopOfBookhas nopricefield and cannot grow one.
Changed
preview_order/3andreplace_order/4are nowVenuecallbacks, and required rather than optional. §6.1's rule is that the facade is one fixed set, never extended per venue, and optionality is reserved for callbacks where requiring them would be pure ceremony. These two are not: whether a venue can preview an order, and whether it can amend one atomically, are things a consumer routes on — andreplace_order/4is a claim about risk, since its absence means cancel-then-place, which opens a window in which no order is live.Not a breaking change, because there is nothing to break yet. No consumer implements this behaviour outside the family, and all five venue packages were updated in the same change. A venue that serves neither returns
Venue.not_supported()and declaressupports_order_preview: false/supports_order_replace: false. Once the host adopts these packages, adding a required callback would be breaking and would take the0.2.0seed §7.2 describes — that signal is deliberately not spent here.
Added
- Five capability fields and two facade callbacks, closing every contract gap Schwab
found. Each existed because a venue could not say something true about itself.
ceilinggained an optional:scope(:credential | :account | :application), and:limitbecamenon_neg_integer. Both matter: a limiter keyed by credential silently over-permits a venue that counts per account, and a registration granted zero throughput is legal and is not:unsupported— the endpoint exists and the venue serves it; that application cannot use it, and the remedies differ.supported_sessions— which trading session an order may name.[]is the continuous-market case and stays the default.[:regular]alone raises: it says nothing, and a consumer would build a session selector with one option.supports_order_preview,supports_order_replace,supports_multi_leg_orders— all raise if claimed whileplace_order/3is:unsupported.catalog_access(:enumerable | :query_only) — whether the catalogue can be listed at all.:query_onlyraises ifget_symbols/1is:unsupported, because "searchable only" and "not served at all" are different facts.preview_order/3andreplace_order/4as required facade callbacks. Required rather than optional: the facade is one fixed set, and optionality is for ceremony. Both are peripheral, andreplace_order/4's reason states the risk — absence means cancel-then-place, which works and opens a window with no order live.
- Four order types:
:trailing_stop,:trailing_stop_limit,:market_on_close,:limit_on_close. Real types Schwab accepts that Core had no word for, so a venue serving them had to under-declare — the safe direction, and still a lie. - Eight instrument types:
:option,:future,:future_option,:index,:mutual_fund,:bond,:forex,:cash_equivalent.[:spot, :perp]was the whole vocabulary while every venue was crypto; an option is not a spot instrument, so an equities broker declared[:spot]plus a comment saying that understated it. A declaration that needs a comment to be true is what this struct exists to prevent. - Two conformance assertions: the order-shape claims must match what the facade answers,
and
catalog_accessmust match howget_symbols/1behaves without a query.
Documentation
usage-rules/adapter.mdnever mentionedDpExchange.Core.Config.opt/3,Types.<T>.new/1or the:gfw/:gfmaddition tosupported_time_in_force— all three shipped in this same[Unreleased]section (C1, C5, C7 above), and a package author reading only the guide that ships in the Hex tarball would never learn any of them exist. Fixed by adding: a "domain vocabularies are closed lists" section naming the full currentsupported_order_typesandsupported_time_in_forcevocabularies, including:gfw/:gfmand why they were added; a "preferTypes.<T>.new/1" section carrying the same@enforce_keys-guards-presence-not-nilexplanation the code's own moduledoc gives, plus theTypes.Orderexception; and a section on the forwarded-optsnil-vs-absent trap namingDpExchange.Core.Config.opt/3as the fix, next to the existing "opts is the venue's own vocabulary" discussion it extends. Found by auditing this package's own consumer docs the same way the family-wide sweep audited the other five packages'.README.md's family table said five of six packages were "not yet published." All six are live on Hex — checked against Hex's package API 2026-09-05, every one ofdp_exchange_core,dp_exchange_coinbase,dp_exchange_gemini,dp_exchange_webull,dp_exchange_robinhoodanddp_exchange_schwabreturns200. Corrected to "published, experimental," with a line stating that publication is not proof of maturity — readcapabilities/0for that, not this table.Two stale assertion-count claims.
usage-rules/testing.mdsaid "Thirteen assertion groups";docs/guides/building-an-exchange-package.mdsaid "28 assertions." Neither matchesDpExchange.Core.AdapterContract.assertions/0, the canonical list the suite's own moduledoc points readers to, which currently names 14 groups. Both corrected to cite that count and the function that defines it, rather than a number that drifts every time a group grows.
[0.1.11] - 2026-08-31
Fixed
- The conformance suite refused
1wand1Mtoo.Capabilities.validate_history!/1was fixed in 0.1.10 to checkTimeframe.nameable/0, butAdapterContract's assertion 2 still checkedknown/0— so a venue serving weekly or monthly candles built its declaration successfully and then failed Core's own conformance suite. That is the worse of the two failures: the package looks correct right up until the suite it exists to satisfy rejects it. Second site of one defect; found running the suite against Schwab.
[0.1.10] - 2026-08-31
Added
Timeframe.nameable/0andTimeframe.nameable?/1— the widths Core can read as a label, which is deliberately wider thanknown/0, the widths it can bucket.1wand1Mare nameable and have no boundary rule, and never will: a weekly bar's start depends on which weekday the venue begins its week, and a month is not a fixed number of seconds.max_leverageaccepts:per_account— a positive statement that the venue margins and the ceiling belongs to the account rather than to the venue. Reg-T forced it: a Schwab margin account carries five different buying powers that are not multiples of one another, and a cash account at the same venue carries none of them, so no scalar is true.nilwithsupports_margin: truestill raises, becausenilmeans "nobody said" — and the error now names:per_account, so a venue author discovers the option instead of inventing a number. Without it the only ways to ship were to declaresupports_margin: false, which is false, or to invent a multiplier.
Fixed
Capabilitiesno longer refuses a venue that serves weekly or monthly candles.validate_history!/1checkedhistorical_timeframesagainstTimeframe.known(), which is the set Core can bucket — so declaring1wraised, even thoughTimeframealready documents both as deliberately unbucketable and instructs callers to read "no boundary rule" as "cannot check" rather than "invalid". Core contradicted itself:aligned?/2tolerates an unmodelled width,boundary/2passes it through, andCapabilitiesrejected it outright. A venue serving a real weekly candle had two options, under-declare or not ship. It now checksTimeframe.nameable/0; a width Core cannot name at all, such as3m, is still refused. Found deriving Schwab's declaration.Timeframenow models10m(600 seconds). Its absence was not neutral:aligned?/2returnstruefor a width it cannot model — "no rule" must not read as "invalid" — so every 10-minute candle passed the authenticity check unexamined, andboundary/2was a no-op on it. Found deriving Schwab's declaration, where/pricehistoryserves 1, 5, 10, 15 and 30-minute widths. Unlike1wand1M, which are deliberately absent because their boundaries are not fixed, 600 seconds is not ambiguous and there was no reason to leave it unmodelled.
[0.1.9] - 2026-08-28
Fixed
HttpClient.request/5's spec no longer advertises{:error, :rate_limited, retry_after: seconds}. It never returned it. Both rate-limit paths convert to a two-element error before returning, each deliberately and for a recorded reason — a venue 429 because a three-element tuple reaching a two-elementcasecrashed 152 collector tasks in one night, and our own limiter's refusal because the two used to share wording and a self-inflicted throttle was read as a flaky venue for weeks. The spec was corrected rather than the behaviour. This is the fourth wrong-spec defect found by a venue package, and it does the same damage as the others: dialyzer reports a caller's correct handling of the advertised shape as unreachable dead code.
Added
HttpClientacceptsraw_status: true, returning{:ok, response}for a 4xx instead of flattening status and body into a message string. The contract makes{:refused, reason}permanent and{:error, reason}possibly transient, and a venue states which in its 4xx body — Gemini namesInvalidSymbol,InvalidParameterValue. Without this a venue package has to recover the distinction by string-matching, andString.contains?(message, "404")also matches a body that happens to contain "404". Opt-in, because the string form is what existing callers match on. 5xx is unaffected: a server error is not a venue's considered answer.Capabilitiesceilings may now carry an optional:burst— the depth a venue lets a caller run ahead of its rate before queueing. Found by the Gemini extraction: a GCRA limiter takes three parameters and this type carried two, so a venue that publishes its burst depth had nowhere to declare it and the package had to hardcode the number beside the declaration — the exact drift the struct exists to prevent. Gemini is the first venue in the family to publish one ("a burst rate of five additional requests that are queued"). Optional rather than required, because a venue that publishes no burst must not be made to invent one, and absence is distinguishable from a declared value. A present:burstmust be a positive integer; zero is a limiter that never lets anything through.- Repo foundation: toolchain pin,
.gitignore, formatter, credo, license,mix.exs, config layout, CI workflow, design-docs scaffolding.