EXPERIMENTAL. This package has not run in production. While it is
0.xa minor version may break you — pin all three segments (~> 0.3.0). Maturity is declared per endpoint throughcapabilities/0, not per package: read that, not this banner.
dp_exchange_core is the contract for the DpExchange family. It talks to no exchange. It
defines what a venue package is, so a consumer can drive any venue through one identical
facade and add a new one without editing anything outside its own repo.
A nil option means "not set", everywhere in the family
Every package forwards opts unchanged through its layers, so if you build options with an
explicit nil — to: Application.get_env(:my_app, :recipient) when nothing is configured —
that nil arrives intact. It is treated as though the key were absent: to: nil delivers to
the caller, limiter: nil means the venue's own limiter, and so on.
It used to be read as the value itself, and to: nil was the worst of it: subscribe/2
answered :ok and delivered to nobody, while the venue's fake raised instead.
Two exceptions, both deliberate. name: nil keeps OTP's meaning — an unregistered
process — because that is how you run several instances side by side. And
Fanout.max_queue_len!/2 refuses max_queue_len: nil at start rather than guessing a bound
you may believe you configured.
The one thing to understand first
Every venue package exposes the same facade, and nothing crosses it. Transport, rate limiting, credential handling, session lifecycle and supervision are internal to a venue package. You cannot tell from the facade whether data arrives over a WebSocket, an MQTT session or a polling loop, and you must not try to find out.
What differs between venues is which facade functions are active, declared once in
capabilities/0. Branch on capabilities, never on venue identity. A case venue do
in your code is the coupling this contract exists to remove.
As a consumer
# In your supervision tree. Nothing starts itself — a package you have not asked for
# never opens a connection.
children = [
{DpExchange.Coinbase, credentials: coinbase_credentials()}
]
# Pull.
{:ok, %DpExchange.Core.Types.Quote{} = pulled} = DpExchange.Coinbase.get_price("BTC-USD", [])
# Push. The same struct arrives as a message; one handler serves both.
:ok = DpExchange.Coinbase.subscribe(["BTC-USD"], to: self())
receive do
{:dp_exchange, :coinbase, %DpExchange.Core.Types.Quote{} = pushed} -> handle(pushed)
endBoth endpoints exist on every venue. A venue with no streaming API implements
subscribe/2 over its own polling and pushes the results. There is no flag for this and
nothing to branch on.
Three return shapes, and the third matters
| Shape | Meaning | What to do |
|---|---|---|
{:ok, value} | it worked | proceed |
{:refused, reason} | the venue does not carry this — permanent | stop asking |
{:error, reason} | it failed, possibly transiently | retry as your policy allows |
Collapsing :refused into :error makes a delisted symbol look like a network blip
forever. Collapsing it into :ok is worse.
Two {:error, _} shapes are not retryable. A write that may have reached the venue is
{:error, {:order_unconfirmed, client_order_id_or_nil, reason}} (order writes) or
{:error, {:write_unconfirmed, key_or_nil, reason}} (every other state-changing write).
Retrying either can double the write: look the order up first. A malformed argument is
refused locally, before credentials are read, as {:error, {:invalid_symbol, v}},
{:invalid_symbols, v}, {:invalid_subscriber, v} or {:invalid_order_id, v} — match the
tag, never a venue's wording, and fix the argument rather than retry.
{:error, :not_supported} is the atom
Never the string. Match the atom.
Read capabilities/0 before you rely on anything
caps = DpExchange.Coinbase.capabilities()
case DpExchange.Core.Capabilities.maturity(caps, {:place_order, 3}) do
:proven -> place_it()
:experimental -> place_it_but_watch()
:unsupported -> use_something_else()
end:proven and :experimental both mean it works — maturity says how well a thing is
known, never whether it runs. :unsupported means the function exists and returns
{:error, :not_supported}; it will not raise and will not quietly return degraded data.
Anything undeclared is :experimental. That is the honest default, not a claim.
Three fields whose absence means something specific
Reading these as "not set" loses the whole point of declaring them:
max_leverage: :per_account— the venue margins, but has no single ceiling. Read the ceiling from the balance response, not from the declaration. 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.nilhere means the venue does not margin at all.authenticated_ceiling: nilon a venue that clearly has limits — the limit is not a property of the venue. Schwab's order ceiling is set per application at registration, anywhere in0..120per minute per account, so any number in the package would be a claim about somebody else's registration. Configure your own.historical_timeframes: []— the venue publishes no candle endpoint. It is not "we did not check": a venue that serves candles must name the widths, andCapabilitiesraises if it claims history without them.
Five fields that say what the venue can do with an order
supported_sessions— which trading session an order may name.[]means the venue trades continuously and a caller never names one; that is every crypto venue. Non-empty means the market closes, and an order carries which window it is for.supports_order_preview— the venue will validate an order and estimate its cost without placing it. Where it is true, use it: on a venue that throttles writes and not reads, a rejection found by previewing costs nothing and one found by placing costs a scarce write.supports_order_replace— the venue amends atomically. Where it isfalse, your only route is cancel-then-place, which is not equivalent: it opens a window in which no order is live. Treat this as a risk property, not a convenience.supports_multi_leg_orders—falseon every venue in the family today, including ones whose venue supports spreads.place_order/3takes a flat request; the field tells you the venue is richer than the contract rather than letting you discover it.catalog_access—:enumerablemeansget_symbols/1returns everything.:query_onlymeans there is no list-everything call, so you must pass a search term and will get{:error, {:query_required, venue}}without one. That is not:not_supported— the endpoint works. The suite holds a:query_onlyvenue's fake to exactly that tuple (group 12); any other error does not satisfy the claim.
Rate ceilings: read :scope, and do not treat zero as absent
A ceiling may carry :scope — :credential, :account or :application. It
changes what you must key your limiter by: a limiter keyed by credential silently
over-permits a venue that counts per account, and you will find out by being throttled.
limit: 0 is a legal value and is not nil. It means this registration was granted
no throughput on that path. The endpoint exists and the venue serves it — your application
cannot use it. That is a different problem from :unsupported, with a different remedy:
one is a conversation with the venue, the other is not.
Do not pass that zero to a rate limiter. DefaultRateLimiter requires a positive
:limit, because :limit is the rate its arithmetic divides by; it refuses a zero at
start_link/1. The zero is a statement about your registration, not a rate — a path you hold
none of has nothing to pace, and the right response is to not call it. If you are deriving
limiter configuration from capabilities/0 mechanically, floor it (max(limit, 1)) and keep
the real number where a reader finds it, in the declaration. Note also that :burst is
optional on a ceiling and required by DefaultRateLimiter, so a ceiling handed over
verbatim will not have it.
Timeframes: nameable is wider than bucketable
Timeframe.known/0 is what Core can bucket — aligned?/2 and boundary/2 answer for
those. Timeframe.nameable/0 is what Core can read as a label, and adds 1w, 1M
and 1y.
Weekly, monthly and yearly have no boundary rule and never will: a weekly bar's start
depends on which weekday the venue begins its week, a month is not a fixed number of
seconds, and neither is a year (365 or 366 days, depending which one). So seconds/1
returns :error for all three and aligned?/2 returns true — "no rule" means
"cannot check", never "invalid". Validate a declaration against nameable/0; reach for
known/0 only when you need the width in seconds.
Coverage is observed, never intended
coverage/1 reports what is actually arriving, by route. It is not a list of what you
subscribed to. A venue that cannot observe delivery answers :not_covered rather than
reporting a success it cannot see.
This is the strongest guarantee in the contract, and it exists because a venue once reported 325 symbols subscribed and confirmed while 174 were delivering.
coverage/1 alone cannot tell a half-dead feed from a healthy one
coverage/1 collapses every kind of data a symbol receives into one route. That is exactly
right for "is anything arriving" and exactly wrong for "is everything arriving that should
be": on Coinbase, the level2 (order book) channel delivered over 11,000 frames for 406
symbols while ticker (quotes) was dark for all but 5, and coverage/1 truthfully answered
:stream for all 406 — it counts any payload, an order book update exactly as much as a
quote. "Ticker dark, book healthy" and "everything healthy" produced the identical
coverage/1 map, and the defect stayed invisible across two issues before it was found.
coverage_by_kind/1, where a venue implements it, answers the question coverage/1
cannot:
%{
quotes: %{"BTC-USDC" => :stream}, # 5 symbols
order_book: %{"BTC-USDC" => :stream, "XLM-USDC" => :stream} # 406 symbols
}The gap between the two map sizes is the signal a single boolean-per-symbol threw away.
Optional, and check for it before relying on it. It is in Venue.@optional_callbacks
precisely so Core could ship it without every venue package instantly failing conformance;
call function_exported?(YourVenue, :coverage_by_kind, 1) before calling it. Where it is
implemented, its own symbols are guaranteed to be exactly the union coverage/1 reports, and
every kind key is one the venue's own capabilities().streamable declares — the conformance
suite asserts both. It is not a replacement for coverage/1, not a per-channel report (a
venue's own channel names never cross the facade), and not a freshness or latency API —
it is the same observed-arrival fact, split by data_kind().
wanted/1 is what you asked for; coverage/1 is what arrived
wanted/1, where a venue implements it (optional, so check function_exported?/3), answers
the symbols you asked to stream: what subscribe/2 and update_symbols/2 set and
unsubscribe/2 did not remove. It is a list or a MapSet of canonical symbols, never a route
map. It is intent, and coverage/1 is evidence, so the two together name the gap: a symbol
in wanted/1 and not in coverage/1 is asked for and not arriving. Every symbol in
coverage/1 is also in wanted/1.
One reason for each situation
Every venue answers these the same way, from the real path and from its Fake. Match the tag; never match a venue's wording. No venue text and no credential rides in a reason.
| Situation | Answer |
|---|---|
| a well-formed symbol the venue does not list (any single-symbol read) | {:refused, :not_listed} |
| a listed symbol with no current quote or book | {:refused, :no_quote} |
| the credential does not hold that account | {:refused, {:account_not_found, id}} |
| a crypto pair on an equity-only venue | {:refused, {:not_an_equity_symbol, symbol}} (the venue's own tag; still a refusal) |
| a credential value with surrounding whitespace or control characters | {:error, {:invalid_credentials, venue}}, refused before signing, never echoed |
| a plaintext URL configured for a credentialed transport | {:error, {:insecure_url, venue}} |
a malformed :start/:end range | {:error, {:invalid_range, range}} |
| a range starting before what the venue retains | {:error, {:range_unavailable, timeframe, earliest: dt_or_nil, requested: dt}} |
| too many bars for one request | {:error, {:range_too_wide, count}} |
| a reply that may be truncated | {:error, :history_truncated} |
Which rows the suite asserts: {:refused, :not_listed} and {:invalid_credentials, venue}
(group 41, opt-in), :invalid_symbol(s)/:invalid_subscriber/:invalid_order_id and the
order-request tags (groups 32 to 35, and 50 for preview and replace), :order_unconfirmed
(group 42, opt-in) and {:invalid_option, :include_partial, v} (group 36), {:unknown_option, key} on every endpoint the venue's Fake serves, reads, writes and feed calls alike (group 43),
{:unknown_option, :timeout} on subscribe_notices/1 (group 46), {:invalid_amount, v} on the
amount-taking writes (group 47) and {:refused, :not_found} for an order id nobody placed
(group 49). The others (:no_quote, :account_not_found, :insecure_url, the range tags) are
held by each venue's own tests.
venue is the venue's runtime_id/0 atom. A refusal is permanent and an error may be worth
retrying, so a venue that gives a retryable error for an unlisted symbol or an unknown
account is not conforming. :invalid_symbol is Core's own local tag for a value that is not
a symbol at all (nil, not text, blank), never a venue saying it does not list one.
Notices: a prompt to re-read, never the record
subscribe_notices/1 carries what the package says about itself — link state
(:link_up, :link_down, :link_reconnecting, :link_recovered), credentials
(:credentials_rejected, :credentials_expiring, :session_refresh_failed), sustained
:rate_limited, :coverage_change, :catalog_change, a :refusal, a :data_quality
payload that did not decode, and a :degraded slower path (including a subscriber whose
mailbox is over its bound and is being skipped). A consumer's handler should ignore a kind
it does not know.
Kind rules. A sequence gap or an undecodable depth or book update is :data_quality.
A venue refusing a subscription is :refusal, with details.symbols (a list). :degraded
is only for "the venue answers from a slower path". :credentials_rejected is emitted
wherever a stream or a poll is refused for authentication. A poll route's :link_down is
closed by a :link_up on the first good poll afterwards, once, with the same
details.symbols shape the streaming :link_up uses, so a consumer waiting for recovery
is not left waiting.
details.reason is an atom or a tagged tuple, never free text. The venue's own wording
goes in details.message, bounded. Match on reason; show message.
Delivery is not guaranteed, and your correctness must not depend on it. A
:catalog_change notice is a reason to call list_instruments/1; it is not the authority
that a pair was delisted. Treat a notice as a nudge and a dropped message costs latency.
Treat it as the record and a dropped message is silent, wrong state — which has happened:
two symbols suspended at 03:14 and 03:27 opened fresh positions at 21:46 because the
message that would have stopped them vanished with a restarting process.
provider is usually an atom, and sometimes a string
Route on it, but match defensively. A venue's own feed names itself with the venue atom
(:coinbase, :schwab). Core.PollingFeed — the poll a venue runs for symbols its stream
does not reach, and the whole feed for a venue with no stream — names whatever its caller
gave it as provider:, falling back to its label, which is a string.
A venue package that wires provider: through emits the atom from both halves. One that
does not emits :venue from its feed and "some-label" from its poll, for the same
venue, and a consumer matching only the atom quietly misses half its notices.
details.label always names which feed spoke, whichever the provider is.
The poll's [:dp_exchange, :link, :up | :down | :event] telemetry carries the same
provider as its notices. Before 0.3.61 it carried the label, so a dashboard grouped by
provider split one venue in two even when the venue wired provider: through.
Credentials
Passed as arguments, per call. A package never reads them from a vault, an environment variable or your config, and never stores them. Notices never carry them.
Storage is yours; use is the package's. Signing, session refresh, token rotation and revocation happen inside a venue package — a package that only signed, and handed you an expired token back twice an hour, would be unusable for anything unattended. The consent leg — a browser, a login page, a person — is always yours, at every venue in this family.
The five venues do not share an auth model, and integrating two of them means implementing two different things. Read Authentication before writing any of it; in particular the Schwab section, because its refresh token is one-time use and losing the rotated value is unrecoverable without a person.
The contract is 89 callbacks, and a package implements what its venue serves
Venue.required_callbacks/0 is what the compiler enforces, and it is all but four of them.
Only list_instruments/1, quantization/1, coverage_by_kind/1 and wanted/1 are
@optional_callbacks and may be absent. For every other callback a venue that does not serve
it still defines the function, declares it :unsupported, and it exists and returns
{:error, :not_supported} — never a raise, never a missing function.
Two things a consumer should read rather than infer:
venue_does_not_serve/0splits the:unsupportedlist in two: what the venue does not serve, and what this package has not ported. Both answer identically; only the second can ever change. A host planning around a gap needs to know which it is looking at. It is not aVenuecallback and a package may omit it — checkfunction_exported?/3first.Venue.peripheral_endpoints/0names the endpoints a consumer can live without, with the reason for each. It is the difference between a gap that costs you a feature and one that costs you the migration.
asset_classes is a statement about a package today, never a permanent scope boundary.
A venue package serving crypto today may serve options tomorrow, and the only test of scope
is whether the venue provides it.
Options in opts are the venue's own vocabulary
Callbacks take keyword(), and packages read venue-specific keys from it — category: on
Webull, portfolio: on Coinbase, account_number: on Robinhood. One rule keeps that from
becoming the coupling this contract removes:
A call that passes no options gets a correct answer. Options select among things a venue offers; they never carry something the call cannot work without. Where a venue genuinely requires a parameter this contract has no word for, the package refuses locally and by name rather than sending a request the venue will reject less clearly.
An option the call does not read is refused, not ignored: {:error, {:unknown_option, key}}
(the first offender, and a key holding nil counts), or {:error, {:invalid_options, opts}}
for something that is not a keyword list. A misspelled limt: that was ignored would return
the answer to a different question than the one asked. So a consumer moving between venues
must not carry an option only one of them reads. A family option the venue cannot honour
(limit:, depth:, since:, include_partial: and the other market-data options) is
refused too, as {:unsupported_option, key} or {:invalid_option, key, value}. See
usage-rules/adapter.md.
timeout: bounds a whole response, per attempt. Every package makes its HTTP calls
through DpExchange.Core.HttpClient, where :timeout (default 30 s) now caps the entire
response, not each chunk of it (since 0.3.38). Before that, a response that trickled in
never timed out. With retries on, a call can take up to retry_attempts times this, plus
backoff.
HttpClient.request/5 refuses, with ArgumentError before anything is sent, a mistyped
option: :timeout must be a non-negative integer or :infinity, :weight a positive
integer, :max_retry_after_s a non-negative integer of at most 4_294_967 seconds, and
:log_requests, :rate_limit_per_endpoint, :rate_limit_blocking, :raw_status and
:decode_body booleans (raw_status: "false" would otherwise be truthy). A forwarded nil
is read as absent. A :get that carries a non-empty body is refused the same way: Req 0.7.0
to 0.7.2, which mix.exs admits, send it as a POST.
A write is sent once unless its venue says it may be repeated. :retry_attempts defaults
to 3 for a :get and to 1 for a :post, :put or :delete: a timeout after the venue took
a write is not proof it did not, and a retry could place it twice. Only a venue package that
holds an idempotency key passes an explicit :retry_attempts for a write.
max_body_bytes: bounds the size of a response (default 16 MiB). The body is counted as
it arrives and the transfer is halted at the limit, so an oversized or endless response is
never held in memory. It returns {:error, {:response_too_large, limit}}, never retried.
Market data means the same thing on every venue
What a caller can rely on, whichever package answers (the full table is in Implementing a venue package):
- Candles are finished bars, oldest first. The still-forming bar is returned only with
include_partial: true(defaultfalse), and then it may still change.opts[:start]is inclusive andopts[:end]exclusive, both onopened_at;opened_atis the bucket start. Crypto is on the strict UTC grid. For a session-traded instrument, widths of 30 minutes and under are strict, wider intraday widths are 30-minute aligned (equity sessions open at 09:30 ET), and daily and weekly bars sit at 00:00 UTC of the session date. - A traded price is above zero. A price at or below zero refuses the reply. A bid or ask
of
0is no resting order:nil. - An order book is best price first, each level with a price and a size above zero, at
most
depth:levels a side.limit:is not a depth option. - An option the venue cannot honour is refused (
{:unsupported_option, key}or{:invalid_option, key, value}), never ignored. venue_timeis the venue's stated time of the trade or quote, ornil. For a time you can always rely on, useobserved_at.
Money movement is its own subject
One group of callbacks moves funds, one of them cannot be undone by anyone, and none of it is ever tested in this family — it is answered in production, with real money.
Read Money movement before calling any of it. Its
preconditions are not style advice: the network is required and never defaulted because
funds sent to an address on a chain the venue does not credit are gone, and
memo_required: nil means the venue did not say rather than no memo is needed.
Detailed guides
- Implementing a venue package — writing one
- Authentication — what you do, what the package does, per venue
- Running live and demo at the same time
- Money movement — the group where a defect moves funds
- Symbols and the round-trip invariant
- Feeds, subscriptions and notices
- Testing, fakes and isolation