An in-process Gemini, 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
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 — a plausible wrong answer is worse than a failure, because only one of the two gets noticed.
It never rewrites a value the caller supplied, and it never stamps the current
clock. @at below is fixed, because a fake that stamps utc_now/0 cannot be used to
test anything about freshness and is itself the substitution this family refuses.
It models Gemini's refusals, and its two shapes of refusal differ
A fake where everything works proves half the contract. This one refuses:
- a symbol it does not carry, with the real reason for that endpoint — Gemini
does not refuse a bad symbol the same way everywhere, and neither does this fake:
:invalid_symbolwhereRest's own tests pin a JSONInvalidSymbolbody (get_order_book/2,quantization/1,place_order/3),{:unknown_reason, text}where the venue answers with plain text instead (get_price/2,get_top_of_book/2— measured live,/v1/pubticker— andget_historical_prices/4— measured live,/v2/candles), or the venue's own unrecognised JSON reason (get_trades/2, measured live asBadRequest). A single invented:not_listedused to stand in for all of these, which is precisely the divergence the 2026-09-06 real/fake parity sweep exists to catch: a consumer's test pattern-matching the real shape passed here for the wrong reason; - a timeframe Gemini does not serve, including
2h,4hand12h, which the shared vocabulary models and this venue does not; - a range reaching before the venue's fixed window, with
{:range_unavailable, …}rather than a short answer — because the real endpoint ignores bounds entirely and a truncated result reads as a complete one.
That last one is this venue's distinctive failure mode, and a consumer that has not handled it will find out here rather than in production.
Failure injection and anonymous mode
Every function below that has 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.
authenticated/2 also checks FakeInjection.credentials_bypassed?/1 before running the
same scheme-resolution Private's Auth.headers/5 runs — see that function's own
comment for the shapes it returns. Neither changes anything for a test that never calls
FakeInjection — see that module for the full contract.
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 — see FakeInjection's own moduledoc.
Summary
Functions
@spec coverage_by_kind(keyword()) :: %{ required(DpExchange.Core.Capabilities.data_kind()) => %{ required(DpExchange.Core.Venue.symbol()) => DpExchange.Core.Venue.route() } }
See DpExchange.Gemini.coverage_by_kind/1.
subscribe/2 above only ever pushes a Types.Quote (via get_price/2) — it never
builds a Types.TopOfBook, so this fake is honestly :quotes-only. :top_of_book
still appears as a key, empty, rather than being omitted: an omitted key here would
read as "this fake does not know about that kind", where an empty map reads as what is
actually true — the kind is declared, and nothing of it has been observed. Less
capable than the real adapter is allowed; answering a different shape is not, so the
keys match capabilities().streamable exactly, the same two the real adapter reports.