An in-process Coinbase, for a consumer's tier-1 tests and for the conformance suite's active-endpoint assertion.
It is not a mock. Nothing is stubbed, no expectation is recorded, and no call is
verified. It is a real implementation of DpExchange.Core.Venue that answers from
memory instead of from the network, and it runs the same conformance suite as the
real adapter.
Two rules, from thirteen real bug reports about a comparable fake
Eleven of those thirteen were the fake diverging from the real client. Six were loud — the fake rejecting what the real thing accepts — which costs time and nothing else. Three were silent, and those are the ones this is designed against.
Less capable is allowed. Differently capable is not. Where this cannot answer, it
returns an error. It never returns an empty success for something unsupported: the
original answered {:ok, []} to an unsupported query and dropped clauses it could not
parse, so callers got plausible wrong answers rather than failures.
It never rewrites a value the caller supplied. The original discarded the caller's timestamp and substituted the current clock, landing points written 900 seconds apart microseconds apart.
It models Coinbase's refusals, not just its successes
A fake where everything works proves only half the contract. This one refuses a symbol
it does not carry with {:refused, :not_listed} — permanent, distinct from a transient
error — and refuses a timeframe Coinbase does not serve, including 12h, which the
shared vocabulary models and this venue does not.
It also enforces the 350-candle boundary as a refusal rather than a truncation, because that is what the venue does: 351 candles requested returns zero and an error, not the first 350.
Failure injection
Every function below with a real success path (not an unconditional
Venue.not_supported()) checks DpExchange.Core.FakeInjection.next_outcome/1 or /2
first — a queued or always-set outcome from FakeInjection.queue_failures/2,3 or
fail_always/2,3 short-circuits the fake's normal logic and is returned as-is. See that
module for the full contract, and DpExchange.Robinhood.Fake for the same wiring
applied to a venue whose Fake also gates credentials.
subscribe/2, unsubscribe/2 and update_symbols/2 are NOT wired — each takes a list
of symbols in one call, and "this one symbol in the batch fails, the rest succeed" is a
case whole-call injection cannot express. coverage/1 and subscribe_notices/1 are not
wired either: both are local bookkeeping reads/writes that always succeed by
construction, not a call standing in for one the real venue could refuse.
No credential-bypass mode here. Unlike DpExchange.Robinhood.Fake, this fake has no
central credential check to bypass — most functions below never inspect credentials
at all (an existing gap this wiring does not change; see dp-exchange-core issue #14).
Adding a check here that the fake never had would be a behaviour change beyond this
task's scope, not a wiring exercise.
Summary
Functions
coverage/1, split by kind — see DpExchange.Coinbase.coverage_by_kind/1.
Functions
@spec coverage_by_kind(keyword()) :: %{ required(DpExchange.Core.Capabilities.data_kind()) => %{ required(String.t()) => atom() } }
coverage/1, split by kind — see DpExchange.Coinbase.coverage_by_kind/1.
subscribe/2 above only ever pushes a DpExchange.Core.Types.Quote (it never
synthesises a DpExchange.Core.Types.OrderBook), so every symbol this fake reports
arrives under :quotes and :quotes alone — anything else would claim delivery this
fake never actually sends, which is exactly the "differently capable" divergence this
module's own moduledoc forbids. Less capable than the real venue here is honest; a
fabricated :order_book entry would not be.