The facade. The single module a consumer touches, identical on every venue.
defmodule DpExchange.Coinbase do
@behaviour DpExchange.Core.Venue
endNothing else in a venue package is public API. A venue's transport, rate limiting, signing, session handling and supervision are implementation detail behind this, and a consumer cannot reach them because there is nothing here that returns them.
The one rule everything else follows from
The facade is one fixed set of functions, never extended per venue and never omitted.
What differs between venues is which of them are active, and that is declared — once,
in capabilities/0 — rather than expressed by a venue defining extra functions or
leaving some out.
This is what dissolves the "all the same, but some have extras" tension. A venue does not add functions; it declares which ones it answers. There is no second mechanism and no escape hatch, because an escape hatch is a way for a caller to need to know which venue it is holding, and that is the one thing this contract exists to prevent.
What crosses, in full
Anything not on this list is a design error, not an omission.
In: credentials, symbols, order requests, options. That is the entire inbound surface. No modules, no functions, no callbacks, no sink — a consumer never hands a package a piece of itself.
Out: {:ok, value} / {:error, reason} / {:refused, reason} from the pull
endpoints; DpExchange.Core.Types.* structs pushed to subscribers; DpExchange.Core.Notice
structs on the notices channel; telemetry under [:dp_exchange, …].
Never out: socket handles, connection pools, transport library state, MQTT sessions, rate-limit buckets, signing keys, retry timers, supervisor pids.
Both endpoints always exist
Every venue can be pulled and can be subscribed. There is no flag for it and
nothing to branch on. A venue with no streaming API implements subscribe/2 over its
own polling and pushes the results; a consumer calling subscribe/2 gets a stream
whether the mechanism beneath it is a socket, an MQTT session or a loop.
That is not a convenience. The moment a consumer can ask how the data arrives, it can branch on the answer, and every consumer above the package forks on transport.
Maturity is per endpoint, and it is bidirectional
capabilities/0 reports :proven | :experimental | :unsupported for each function.
The declaration and the behaviour may not disagree, in either direction:
:provenor:experimentalmeans the function works. Neither may answer{:error, :not_supported}— maturity says how well a thing is known, never whether it runs.:unsupportedmeans the function exists and returns{:error, :not_supported}. It may not raise, may not be undefined, and may not quietly succeed with degraded data.
Both halves are asserted by the conformance suite. Checking only the first lets a venue
under-declare and hide working functionality; checking only the second lets it
over-declare and fail in a caller's hands. Holding both is what lets a consumer branch on
capabilities/0 instead of on venue identity.
{:error, :not_supported} is the atom. The source this contract was extracted from
returned the string "not_supported" in some places and the atom in others — in one case
both within a single module — so a caller matching the atom silently missed the string and
treated a refusal as an unrecognised error.
A library does not start itself
child_spec/1 exists so you supervise the venue. This package ships no aggregate
supervisor and no start-everything entry point: it would have to know which venues exist,
and it would take a decision that is yours — which venues run, under what restart
strategy, with what names. A consumer that has not asked for a venue never finds a socket
open.
Summary
Types
Opaque to this contract. A venue package documents its own shape.
Whether the venue is trading right now. Crypto venues answer :open always.
The three-state maturity of one endpoint.
A refusal is not an error.
How a symbol's data is reaching the caller right now, as observed.
Canonical BASE-QUOTE for a pair; a bare symbol on an equity venue.
Callbacks
Which asset classes this venue trades, e.g. [:crypto] or [:crypto, :equity].
Cancels an order.
The activation map: which functions are answerable, and how well each is known.
Starts the venue's whole tree — connections, limiter, session refresh, whatever it needs.
What is observed arriving, by which route — never what was subscribed.
Accounts visible to the credential.
Balances for the credentialed account.
The fee schedule that applies to this credential.
Historical candles for symbol at timeframe.
A bulk snapshot across the venue's symbols, where it offers one.
One order's current state.
The order book for symbol, best price first on both sides.
Orders visible to the credential.
The current price for symbol.
What the venue's ceiling currently is and how much of it is left.
Every symbol the venue lists.
Past fills for the credential.
Deposit and withdrawal history — needed to compute cost basis for transferred-in assets.
The venue's listings with the fields get_symbols/1 discards — base, quote,
instrument type, trading status.
Whether the venue is trading right now.
Places an order. Irreplaceable by definition: this is the act.
The venue's display name.
Rounds a price and quantity to what the venue will actually accept.
The venue's stable identifier, matching its package's namespace segment.
Starts the venue directly. See child_spec/1 — a venue with no processes returns :ignore.
Subscribes the caller to symbols.
Subscribes the caller to the package's notices — what it is doing and what is going wrong with it.
Whether the venue is reachable and the credential, if given, is accepted.
Stops delivery for symbols.
Changes a live subscription's symbol set without tearing the venue's connections down.
Functions
The endpoints a package must prove before it can stop being EXPERIMENTAL.
The refusal every unsupported endpoint returns.
Every notice kind a venue may emit. See DpExchange.Core.Notice.
The endpoints outside the core set, each with the test it fails.
Every callback a venue package must implement, as {name, arity}.
Types
@type credentials() :: map()
Opaque to this contract. A venue package documents its own shape.
@type market_status() :: :open | :closed | :pre | :post
Whether the venue is trading right now. Crypto venues answer :open always.
@type maturity() :: :proven | :experimental | :unsupported
The three-state maturity of one endpoint.
:experimental is the default and the only honest starting state. :proven is earned
per endpoint by production use, never by careful implementation.
A refusal is not an error.
{:refused, reason} is the venue stating it does not carry this symbol at all — a
permanent answer. {:error, reason} may be transient and is worth retrying. Collapsing
the two makes a delisted symbol look like a network blip forever.
@type route() :: :stream | :internal_poll | :not_covered
How a symbol's data is reaching the caller right now, as observed.
Never what was subscribed. A venue that cannot observe delivery answers :not_covered
rather than assuming its subscription worked.
@type symbol() :: String.t()
Canonical BASE-QUOTE for a pair; a bare symbol on an equity venue.
Callbacks
@callback asset_classes() :: [atom()]
Which asset classes this venue trades, e.g. [:crypto] or [:crypto, :equity].
@callback cancel_order(credentials(), String.t(), keyword()) :: result(DpExchange.Core.Types.Order.t())
Cancels an order.
@callback capabilities() :: DpExchange.Core.Capabilities.t()
The activation map: which functions are answerable, and how well each is known.
Static and safe at boot. This is what a consumer branches on instead of venue identity.
@callback child_spec(keyword()) :: Supervisor.child_spec()
Starts the venue's whole tree — connections, limiter, session refresh, whatever it needs.
A venue with no processes returns :ignore. You put this in your supervision tree
and choose its restart strategy, shutdown order and name.
What is observed arriving, by which route — never what was subscribed.
This is the strongest guarantee in the contract, and it exists because intent standing in
for evidence is how a venue reported 325 symbols subscribed and confirmed while 174 were
delivering. A venue that cannot observe delivery answers :not_covered rather than
reporting success it cannot see.
Symbols absent from the map are :not_covered.
@callback get_accounts( credentials(), keyword() ) :: result([map()])
Accounts visible to the credential.
@callback get_balances( credentials(), keyword() ) :: result([DpExchange.Core.Types.Balance.t()])
Balances for the credentialed account.
@callback get_fees( credentials(), keyword() ) :: result(map())
The fee schedule that applies to this credential.
@callback get_historical_prices(symbol(), String.t(), keyword(), keyword()) :: result([DpExchange.Core.Types.Quote.t()])
Historical candles for symbol at timeframe.
The venue rejects a timeframe it does not serve rather than substituting the nearest one. A missing granularity silently becoming the closest one mislabels every candle it touches, and every value stays plausible.
A bulk snapshot across the venue's symbols, where it offers one.
@callback get_order(credentials(), String.t(), keyword()) :: result(DpExchange.Core.Types.Order.t())
One order's current state.
@callback get_order_book( symbol(), keyword() ) :: result(DpExchange.Core.Types.OrderBook.t())
The order book for symbol, best price first on both sides.
@callback get_orders( credentials(), keyword() ) :: result([DpExchange.Core.Types.Order.t()])
Orders visible to the credential.
@callback get_price( symbol(), keyword() ) :: result(DpExchange.Core.Types.Quote.t())
The current price for symbol.
@callback get_rate_limit_status( credentials() | nil, keyword() ) :: result(map())
What the venue's ceiling currently is and how much of it is left.
Every symbol the venue lists.
@callback get_trade_history( credentials(), keyword() ) :: result([DpExchange.Core.Types.Fill.t()])
Past fills for the credential.
@callback get_transfers( credentials(), keyword() ) :: result([map()])
Deposit and withdrawal history — needed to compute cost basis for transferred-in assets.
@callback list_instruments(keyword()) :: result([DpExchange.Core.Instrument.t()])
The venue's listings with the fields get_symbols/1 discards — base, quote,
instrument type, trading status.
Optional: single-quote venues derive base and quote trivially and have no non-spot instruments, so requiring an implementation there is ceremony.
@callback market_status(keyword()) :: result(market_status())
Whether the venue is trading right now.
Crypto venues answer :open. An equity venue answers honestly, including pre- and
post-market.
Not cosmetic. A feed that delivers nothing warns loudly and keeps warning, so without this an equities package alarms every night and every weekend — and a real outage becomes indistinguishable from Saturday. The venue is the only thing that knows its own calendar; the policy — whether to trade in extended hours — stays with the consumer.
@callback place_order(credentials(), map(), keyword()) :: result(DpExchange.Core.Types.Order.t())
Places an order. Irreplaceable by definition: this is the act.
@callback provider_name() :: String.t()
The venue's display name.
Rounds a price and quantity to what the venue will actually accept.
Optional: when a venue does not implement it, a caller skips quantization and falls back to per-asset-class defaults.
@callback runtime_id() :: atom()
The venue's stable identifier, matching its package's namespace segment.
Starts the venue directly. See child_spec/1 — a venue with no processes returns :ignore.
Subscribes the caller to symbols.
The venue decides everything about how. Connections, sharding, pacing, protocol, whether it streams at all or polls and pushes the results — none of it crosses this boundary, and a caller cannot discover it.
Events arrive as messages to the subscribing process, or to a pid given in opts,
tagged so a process subscribed to several venues can tell them apart. The payload is a
DpExchange.Core.Types.* struct — the same value the pull endpoints return — so one
handler serves a price whether the caller asked for it or was sent it.
Back-pressure is a bounded mailbox, and it is declared
A venue pushing faster than its subscriber consumes drops oldest beyond a stated bound
and emits a :degraded notice saying so. Growing a mailbox silently until the node dies
is the failure this avoids; dropping silently is the failure the notice avoids.
Subscribes the caller to the package's notices — what it is doing and what is going wrong with it.
Distinct from subscribe/2, which carries what the venue says about the market. A
monitoring process that never touches market data still needs to know a credential was
rejected.
A notice is a prompt to re-read, never the record. Delivery is not guaranteed — see
DpExchange.Core.Notice.
@callback test_connection( credentials() | nil, keyword() ) :: result(map())
Whether the venue is reachable and the credential, if given, is accepted.
Stops delivery for symbols.
Addressed by the thing itself, never by a handle. Takes the same identifiers
subscribe/2 was given: on crypto the pair, on an equity venue the symbol. A caller
already knows what it subscribed to, so a subscription reference would be pure overhead
wrapping something it already holds — and one more thing to leak.
A dead subscriber stops delivery too. A venue must not accumulate events for a process that no longer exists.
Changes a live subscription's symbol set without tearing the venue's connections down.
Functions
The endpoints a package must prove before it can stop being EXPERIMENTAL.
The rule matters more than the list
The facade will grow, and someone will have to classify an endpoint this list does not contain. An endpoint is core only if both hold:
- Irreplaceable — only this venue can answer it. If a consumer can get the same answer elsewhere, the package failing is an inconvenience rather than a blocker.
- Load-bearing — the consumer's primary job fails without it rather than degrading. If the documented behaviour on absence is "the caller does without", it is not core.
Reclassify by applying the two tests, never by amending a table.
Two properties of the boundary
Core is per venue. An endpoint the venue does not offer is :unsupported and drops
out of that venue's core set; a market-data-only venue is not held to trading it does not
have.
Trading is core exactly when it exists. If place_order/3 is active then the order
group is core, and nothing but live trading proves it.
@spec not_supported() :: {:error, :not_supported}
The refusal every unsupported endpoint returns.
The atom, never the string. The source this was extracted from used both — in one module, both forms — so a caller matching the atom silently missed the string and treated a refusal as an unrecognised error.
@spec notice_kinds() :: [DpExchange.Core.Notice.kind()]
Every notice kind a venue may emit. See DpExchange.Core.Notice.
The endpoints outside the core set, each with the test it fails.
Recorded because it is what a future classifier reasons from, not as a list to append to.
Every callback a venue package must implement, as {name, arity}.
The conformance suite drives its completeness assertion off this rather than a hand-maintained list, so a callback added here cannot be forgotten there.