DpExchange.Core.AdapterContract (DpExchangeCore v0.3.120)

Copy Markdown View Source

The conformance suite every venue package runs in its own CI.

This is the mechanism. Prose in six CLAUDE.md files drifts; a suite that runs in five CI pipelines cannot.

Why this lives in lib/ and not test/support/

It was in test/support/ first, following the reference repo, and that does not work for a suite consumers run. elixirc_paths(:test) governs this package's own build; a dependency is not compiled in the :test environment, so the file shipped inside the tarball and was never compiled into the consumer. A venue package's use DpExchange.Core.AdapterContract failed with module not loaded and could not be found — the suite present on disk and absent from the code path.

Shipping a file is not the same as shipping a module. A public testing API belongs in lib/, which is why Ecto.Adapters.SQL.Sandbox and Phoenix.ConnTest live there.

use ExUnit.Case appears only inside the generated block, so nothing here needs ExUnit at compile time and this compiles cleanly as a dependency.

defmodule DpExchange.Coinbase.ContractTest do
  use DpExchange.Core.AdapterContract,
    venue: DpExchange.Coinbase,
    symbol_format: DpExchange.Coinbase.SymbolFormat,
    sample_pairs: ~w(BTC-USDC ETH-USDC),
    credentials: %{api_key: "test", api_secret: "test"}
end

Options of use

  • :venue - required, the facade module.
  • :fake and :sample_pairs - not optional in practice: group 1 fails without them.
  • :symbol_format - the SymbolNormalizer module; group 4 is generated only when named.
  • :credentials - the credentials map the calls carry (default %{}).
  • :endpoint_opts - %{{name, arity} => keyword()}, what the fake needs to answer.
  • :endpoint_symbols - %{{name, arity} => symbol} for an endpoint narrower than :sample_pairs.
  • :order_args - %{order_request | order_requests | order_changes => value}; an unknown key raises at compile time.

  • :absent_feed_opts - options pointing the streaming calls at a feed nothing registered (default [feed: :dp_exchange_contract_absent_feed]).
  • :package_root - where groups 16, 18 and 19 scan compiled modules (default "lib").
  • :unlisted_symbol and :padded_credentials - the opt-in facts of group 41.
  • :unknown_order_id - group 49's order id nobody placed, in the venue's own id format (default: a text id; a venue with numeric ids names a number).
  • :ambiguous_place_order - opt-in for group 42: a 1-arity function (fn request -> ... end or &Mod.fun/1) that takes an order request map and runs the venue's real place_order/3 path, with a transport fault injected after the request is sent (a Req plug that returns a transport error, or a 503 on every attempt). Group 42 asserts that answer is {:error, {:order_unconfirmed, id, reason}}, and that the same function given a request refused before sending (no :order_type) is a plain or refused error, never :order_unconfirmed. Absent, group 42 is skipped, like group 41's facts.
  • :process_probe - opt-in for group 51: a {module, function} naming a 1-arity function that starts the venue's own long-lived process or processes (its feed, its socket) with no network (the fake transport, a closed port, a stub) from a credentials map and answers {:ok, pid} or {:ok, [pid]}. Group 51 renders :sys.get_status/2 of each, which is what OTP prints in a crash report, and fails if the secret it passed in is in it. Absent, group 51 is skipped, like group 41's facts, and a venue holding a credential in a process it never probed is a venue this suite has not examined.

What it asserts, and what it deliberately does not

Fifty-four groups, listed in assertions/0. The load-bearing one is capabilities and behaviour agreeing in both directions: over-declaring fails in a caller's hands at runtime, and under-declaring hides working functionality. Holding both is what makes capabilities/0 trustworthy enough for a consumer to branch on instead of branching on venue identity.

Maturity is asserted for presence, not for truth. Every active endpoint must declare :proven or :experimental, because an absent value is the failure this prevents. Whether a :proven claim is true is not machine-checkable, and a suite that pretended otherwise would be worse than one that admits the limit.

No assertion may name a socket, a channel string, a transport module or a polling interval. If one does, mechanism has leaked through the facade and the assertion is the bug, not the venue.

The fake satisfies the same suite as the real adapter

That is the ratchet. The suite's job is not to anticipate every way a fake can diverge — it is to make each discovered divergence permanent. The reference implementation this pattern comes from went from nothing to over a thousand lines by absorbing thirteen gaps its first consumer found, and none reached an external user across thousands of downloads.

Every gap found becomes a new assertion here. A gap fixed only in one venue's fake is a gap the next venue will reintroduce.

Summary

Functions

What is wrong with a fake's answer to an endpoint its capabilities/0 declares active, or nil when nothing is.

What is wrong with a venue's answers to an ambiguous write, or nil. Assertion 42.

The position of the amount among name/arity's arguments, from the suite's own argument table (arg_shape/3), or nil when the call takes none.

The writes whose amount assertion 47 checks, when capabilities/0 declares them active.

What is wrong with the answer of an endpoint to assertion 5's question, or nil: does a success hold the Core.Types struct (one of modules) its callback promises?

How a call to name/arity is shaped, position by position: :credentials, :symbol, :timeframe, :opts, or one of the order arguments.

The assertion groups, for documentation and for a venue package to report against.

The identity fault of a venue's asset_classes/0 answer, or nil. Assertion 3: a non-empty list of the classes a host switches on.

What is wrong with a Balance a fake returned, or nil. Assertion 24.

What is wrong with the answer to a depth request, or nil: {:too_many_levels, side, n} when a side carries more than depth levels.

What is wrong with the answer to a request carrying limit:, given the answer baseline without it, or nil. Only what the convention requires: Coinbase refuses :limit ({:unsupported_option, :limit}) because its old option meant depth, while a venue whose book has no :limit ignores it as Core's usage rules let it, and one with a native :limit may refuse a value ({:invalid_option, :limit, v}). So a success and those two refusals are fine; a raise (:raised) or any other answer ({:answered, answer}) is not. Nothing is judged when the baseline itself was not a success.

What is wrong with one side of an order book a fake returned, or nil. Assertion 25.

The :depth assertion 38 asks for: one level per side.

Whether the unbounded candle ask of assertion 37 gave the group anything to check, or {:no_candles, answer}.

The ranges assertion 37 re-asks with, as {range, must_include}, chosen from the fake's own bars (oldest first). Two or more bars: start the first bar's opened_at and end the last one's, which must keep the first and drop the last. One bar: start alone (keeps it) and end alone (drops it). None: nothing to ask.

What is wrong with the candles a fake returned for timeframe, or nil. Assertion 37.

Drives fake through subscribe_notices/1, subscribe/2 of pairs, subscribe/2 of unlisted when given, and unsubscribe/2, then collects every Core.Notice delivered to this process until none arrives for wait milliseconds. Assertion 45.

Whether a term holds a pid, port or reference anywhere inside it, structs and map keys included. Assertion 11: a facade answer is data, never a process the caller must manage.

Runs each {name, args, expected} of cases against module and returns the ones whose answer is not {:error, expected}, as {name, arity, expected, fault} with fault as refusal_fault/2 names it. Empty means every case was refused as the convention says.

What is wrong with get_symbols/1's answer without a query, given the declared catalog_access, or nil. Assertion 12.

What is wrong with venue.child_spec/1 where a secret is concerned, or nil. Assertion 22.

What is wrong with a venue's coverage_by_kind/1 answer, given the coverage/1 map of the same moment and the declared streamable kinds, or nil. Assertion 15.

What is wrong with a call's answer when its credentials were stripped, or nil. Assertion 17.

The endpoints assertion 17 asks a credential_benefit: :required venue's fake to refuse when called with credentials stripped.

The names of the Core.Venue callbacks whose first argument is credentials(), read from the behaviour's own @callback specs.

The calls of cases that module can be asked, as {name, args, expected} triples: exported at that arity and not declared :unsupported in caps.

What is wrong with a fake's answers for the two opt-in family reasons, or nil. Assertion 41.

The modules in modules that belong to an application outside permitted.

A failure message for case_faults/2's result, one line per call that was not refused.

The optional callbacks module implements at the wrong arity, as {name, arity, arities}: the callback's name is exported at arities but not at the one the behaviour declares, so a caller at the declared arity gets an UndefinedFunctionError. Assertion 1.

The {name, args, expected} cases of assertion 36: get_historical_prices/4 with an :include_partial that is not a boolean must answer {:error, {:invalid_option, :include_partial, value}}. nil is not tried: several venues read it as an absent option.

What is wrong with a write's answer to the invalid value as its amount, or nil. Assertion 47, venue rule W4.

The invalid amounts assertion 47 hands every amount-taking write: {label, value}.

Order ids get_order/3 and cancel_order/3 must refuse as {:invalid_order_id, value}: nil and a whitespace-only string.

Single-symbol values get_price/2 and get_top_of_book/2 must refuse as {:invalid_symbol, value}: nil, a number, and a whitespace-only string.

:to values a streaming call must refuse as {:invalid_subscriber, value}: neither a pid nor an atom other than nil, true and false. nil is not here: it means the caller.

Symbol lists the streaming calls must refuse as {:invalid_symbols, value}: not a list, a list holding a non-string or a blank string (whitespace-only is blank), and an improper list. sample is a symbol the venue serves, so the bad lists are bad for exactly one reason.

What is wrong with a fake's subscribe/2 given a lower-case pair, or nil. Assertion 29.

What is wrong with the answers to market_price_requests/2, or nil. Assertion 34.

The requests that put a :price on a market order, one for each time in force the venue might want for it, or [] when caps does not support :market.

What is wrong with a value (or list of values) a fake returned, or nil. Assertion 39.

The public functions of venue that fake does not export, as a sorted list of {name, arity}. Assertion 44.

The callbacks Core.Venue requires that module does not export, as {name, arity}. Assertion 1.

What is wrong with a fake's subscribe/2 given to: nil, or nil. Assertion 27.

What is wrong with a no-request call's answer to timeout: 5_000, or nil. Assertion 46.

Whether a run of cases against module examined nothing it should have: no case is eligible (eligible_cases/3) although caps declares at least one of the cases' calls active, which means that call is not exported at its arity.

Whether assertion 39 examined nothing: endpoints were active but none of their answers was checked (examined holds one boolean per endpoint, true when a value was inspected).

The rule violations in notices, as {kind, fault} pairs, judged by DpExchange.Core.Notice.conformance_faults/1. [] is conforming, and so is an empty list: judging nothing is not a fault, which is why group 45 also reports how many it judged.

The {name, args, expected} cases of assertion 35. opts_for gives, for a callback name, the option lists to try; the suite passes the venue's own options with credentials stripped and an empty list, because the id is checked before the account option and before credentials.

The malformed place_order/3 requests of assertion 34, as {request, expected}, built from base, a request the venue accepts: a non-map ({:invalid_order_request, request}), a request with no :order_type ({:missing_field, :order_type}) and one with a key the builder does not read ({:unsupported_order_field, key}).

The requests of assertion 52, as {request, reason}, built from base, a request the venue accepts: for each of :quantity and :price that base carries, one request per value of invalid_amounts/0 (a float, zero, a negative, NaN, infinity) in its place.

What is wrong with an Order or Fill a fake answered, or nil. Assertion 54.

How many Order and Fill structs answer holds (a struct, a list of them, or {:ok, either}), the number assertion 54 actually judged.

The {name, args, expected} cases of assertion 50, from the suite's own argument lists for preview_order/3 ([credentials, request, opts]) and replace_order/4 ([credentials, id, changes, opts]).

The applications a package may link against: its own, every declared dependency, and what those depend on at runtime, as strings.

The width assertions 36 and 37 ask get_historical_prices/4 for: "1h" when caps declares it, else the first declared width the shared vocabulary gives a fixed length, else the first declared width, or nil when none is declared.

What is wrong with a venue's running process where a secret is concerned, or nil. Assertion 51.

The Core.Types struct(s) each Core.Venue callback's success is typed as, read from the behaviour's own @callback specs.

The identity fault of a venue's provider_name/0 answer, or nil. Assertion 3.

What is wrong with a ranged answer, or nil. :start is inclusive and :end exclusive, on opened_at: {:outside_range, opened_at} for a bar outside range, {:missing, opened_at} for one of must_include that was dropped.

What is wrong with a call's answer to an argument the family refuses locally, or nil. Assertions 32 to 35.

The identity fault of a venue's runtime_id/0 answer, or nil. Assertion 3.

The {name, args, expected} cases of assertion 33. opts_for gives the options for a callback name; the suite passes options with credentials stripped, because the refusal is decided before credentials are read.

What is wrong with a staking write's answer to a valid call, or nil. Assertion 48, venue rule W5.

The {kind, endpoint} pairs a package declares streamable while saying the venue does not serve the matching pull endpoint at all (absent). Assertion 8: the two cannot both be true.

What is wrong with a streaming call made while no feed runs, or nil. Assertion 28.

The {name, args, expected} cases of assertion 32, for the streaming calls given opts.

Whether result is a success that gave assertion 5 a struct to check: {:ok, struct} or {:ok, [struct | _]}.

What is wrong with a message subscribe/2 pushed, or nil when nothing is. Assertion 20.

What is wrong with a fake's subscription set after four calls, or nil. Assertion 26.

What is wrong with the times on a struct a fake returned, or nil. Assertions 14 and 23.

What is wrong with a call's answer to width, a timeframe the venue does not declare, or nil. Assertion 21.

The {name, arity} of each call in cases that caps declares active and module does not export, in the order first met.

The reads and cancels assertion 49 asks about an unknown id: the order callbacks Core.Venue documents {:refused, :not_found} for. Other id-taking callbacks (get_conversion/2, get_payment_method/3, get_watchlist/2) have no documented answer for an unknown id, so none is asserted.

What is wrong with a fake's answer to an order id nobody placed, or nil. Assertion 49, venue rule W6.

Whether an endpoint's answer to the probe is evidence the options were checked: it could be judged (unknown_option_judged?/1), or it is the {:unknown_option, key} refusal itself.

The callbacks assertion 43 cannot hand an unknown option, each with the written reason.

What is wrong with a read's answer to a call carrying unknown_option_probe/0, or nil. Assertion 43.

unknown_option_fault/1, for an endpoint whose plain call is the baseline.

Whether assertion 43 could judge an endpoint's answer to the probe.

The option key assertion 43 hands every read: one no venue reads and none can mistake for its own.

The callbacks of callbacks assertion 43 probes: all of them, less unknown_option_exclusions/0.

The five reads assertion 43 judged before it covered every endpoint. They are judged on the probe alone, with no baseline call: a refusal of the probe key there is a fault whatever the plain call would have answered.

The order id assertion 49 asks about: one no write of the suite ever minted.

The kinds in streamable or authenticated_streamable that this contract has no word for (Capabilities.data_kinds/0). Assertion 8.

The endpoints venue_does_not_serve/0 names that caps does not declare :unsupported. Assertion 8: an endpoint the venue does not serve cannot also be one the package answers.

The timeframe assertion 21 asks get_historical_prices/4 for: the first width of Timeframe.nameable/0 that caps.historical_timeframes does not name, or nil when the venue declares the whole vocabulary and there is no width left to prove a refusal with.

What is wrong with a call's answer to an endpoint capabilities/0 declares :unsupported, or nil when nothing is. Assertion 12's under-declaring direction.

The options use DpExchange.Core.AdapterContract accepts.

Checks the order_args: option of use DpExchange.Core.AdapterContract. It must be a map whose keys are drawn from :order_request, :order_requests and :order_changes.

Validates the options given to use DpExchange.Core.AdapterContract, returning them.

Whether value, the success of a price endpoint, gave assertion 39 anything to inspect.

What is wrong with the volume window on a Quote a fake returned, or nil. Assertion 30.

What is wrong with a fake's wanted/1, or nil. Assertion 40.

Functions

active_endpoint_fault(arg1)

@spec active_endpoint_fault(term()) :: nil | :not_supported | :raised

What is wrong with a fake's answer to an endpoint its capabilities/0 declares active, or nil when nothing is.

  • :not_supported — the fake answered {:error, :not_supported}: the declaration and the behaviour disagree (assertion 12, over-declaring).
  • :raised — the call raised. {:raised, error} is how the suite's call_on/3 reports it. A raise is not an answer, and treating it as one let a fake that crashed on every endpoint pass "does not answer :not_supported".

Any other answer, refusals and errors included, is not this check's business.

ambiguous_write_fault(ambiguous, presend, sent_id)

@spec ambiguous_write_fault(term(), term(), String.t() | nil) ::
  nil | {atom(), term()}

What is wrong with a venue's answers to an ambiguous write, or nil. Assertion 42.

ambiguous is what place_order/3 answered when a transport fault came after the request was sent (a timeout, a dropped connection, a 503 on every attempt). It must be {:error, {:order_unconfirmed, id, reason}}: the order may have been placed, so a plain {:error, _} invites the caller to retry into a duplicate, and {:refused, _} claims permanence the venue never gave. id is a client order id, a list of them, or nil when the venue has none and none was generated. When the request carried a binary client_order_id (sent_id) it is that id: nil there is {:unconfirmed_id, nil}.

presend is what the same path answered for a request refused before anything was sent. It must be an {:error, _} or {:refused, _} that is not :order_unconfirmed: nothing left the process, so claiming uncertainty would make a caller reconcile an order that never existed.

{:not_unconfirmed, answer}, {:unconfirmed_id, id}, {:presend_unconfirmed, answer} or {:presend_not_refused, answer}.

amount_position(name, arity)

@spec amount_position(atom(), non_neg_integer()) :: non_neg_integer() | nil

The position of the amount among name/arity's arguments, from the suite's own argument table (arg_shape/3), or nil when the call takes none.

amount_write_endpoints()

@spec amount_write_endpoints() :: [{atom(), non_neg_integer()}]

The writes whose amount assertion 47 checks, when capabilities/0 declares them active.

answer_type_fault(arg1, modules)

@spec answer_type_fault(term(), [module()]) :: nil | String.t()

What is wrong with the answer of an endpoint to assertion 5's question, or nil: does a success hold the Core.Types struct (one of modules) its callback promises?

Returns a short description of the offence: a struct of another module, a raw map, a list holding something else, a bare value ({:ok, nil}, {:ok, 5}; only {:ok, :cancelled} is allowed, as cancel_order/3 types it) or a raise. A refusal or an error is not a success and is not judged.

arg_shape(name, arity, credentialed?)

@spec arg_shape(atom(), non_neg_integer(), boolean()) :: [atom()]

How a call to name/arity is shaped, position by position: :credentials, :symbol, :timeframe, :opts, or one of the order arguments.

credentialed? is whether the callback takes its credential positionally — see credentialed_callbacks/0. A per-callback override wins over the shape the arity implies. Both the call assertions and assertion 17's credential-stripped call are built from this one table; the stripped call used to rebuild its own and ignore the overrides, so for place_order/3 and replace_order/4 it sent a malformed order, the fake raised, and "no {:ok, _} with credentials stripped" held for a reason that had nothing to do with credentials.

assertions()

@spec assertions() :: [{pos_integer(), String.t()}]

The assertion groups, for documentation and for a venue package to report against.

asset_classes_fault(classes)

@spec asset_classes_fault(term()) :: nil | {:asset_classes, term()}

The identity fault of a venue's asset_classes/0 answer, or nil. Assertion 3: a non-empty list of the classes a host switches on.

balance_fault(value)

@spec balance_fault(term()) ::
  nil | {:wrong_struct, term()} | {:currency | :timestamp | :provider, term()}

What is wrong with a Balance a fake returned, or nil. Assertion 24.

{:currency, value} unless it is non-blank text (an amount attributable to no asset cannot be sized, booked or reconciled), {:timestamp, value} unless a DateTime, {:provider, value} when empty. :balance is deliberately not judged: it may honestly be nil.

book_depth_fault(answer, depth)

@spec book_depth_fault(term(), pos_integer()) ::
  nil
  | {:too_many_levels, :bids | :asks, non_neg_integer()}
  | {:answered, term()}

What is wrong with the answer to a depth request, or nil: {:too_many_levels, side, n} when a side carries more than depth levels.

{:error, {:invalid_option, :depth, _}} is nil: the venue's own minimum, the family's refusal for a value it cannot honour. Any other non-success is {:answered, answer}. It was nil for every refusal, so a fake that refused depth: 1 for an unrelated reason (a missing option, a raise) passed an assertion documented as "the fake must honour :depth" without a single book being examined. The ask is made with the same arguments as the baseline book of group 25, which already succeeded.

book_limit_fault(arg1, answer)

@spec book_limit_fault(term(), term()) :: nil | :raised | {:answered, term()}

What is wrong with the answer to a request carrying limit:, given the answer baseline without it, or nil. Only what the convention requires: Coinbase refuses :limit ({:unsupported_option, :limit}) because its old option meant depth, while a venue whose book has no :limit ignores it as Core's usage rules let it, and one with a native :limit may refuse a value ({:invalid_option, :limit, v}). So a success and those two refusals are fine; a raise (:raised) or any other answer ({:answered, answer}) is not. Nothing is judged when the baseline itself was not a success.

book_ordering_fault(levels, direction)

@spec book_ordering_fault(term(), :asc | :desc) ::
  nil
  | {:too_thin, non_neg_integer()}
  | {:unreadable_level, term()}
  | {:unsorted, [Decimal.t()]}

What is wrong with one side of an order book a fake returned, or nil. Assertion 25.

direction is :desc for bids and :asc for asks. {:too_thin, n}: fewer than two levels, which no comparator can order wrongly, so the assertion would pass having checked nothing. {:unreadable_level, level}: a level that is not {Decimal, size}. {:unsorted, prices}: the prices are not best first.

book_probe_depth()

@spec book_probe_depth() :: pos_integer()

The :depth assertion 38 asks for: one level per side.

candle_ask_fault(answer)

@spec candle_ask_fault(term()) :: nil | {:no_candles, term()}

Whether the unbounded candle ask of assertion 37 gave the group anything to check, or {:no_candles, answer}.

Only a non-empty {:ok, candles} is something to check. A refusal, a raise or an empty list used to skip the whole group, which then reported green having examined no candle.

candle_range_probes(candles, timeframe \\ nil)

@spec candle_range_probes([struct()], String.t() | nil) :: [
  {keyword(), [DateTime.t()]}
]

The ranges assertion 37 re-asks with, as {range, must_include}, chosen from the fake's own bars (oldest first). Two or more bars: start the first bar's opened_at and end the last one's, which must keep the first and drop the last. One bar: start alone (keeps it) and end alone (drops it). None: nothing to ask.

With timeframe given and a fixed width, the one-bar ask that must keep the bar is start: the bar and end: one width later, not start: alone. A start: with no end: runs to the venue's "now", and a venue that bounds a window by its width (Coinbase's 350 buckets) refuses a fake's old fixed bar for being too far back, which is a refusal about the clock the fake was written against and not about the range. A width Core has no rule for (1w) keeps start: alone.

candle_series_fault(candles, timeframe, asset_classes)

@spec candle_series_fault([struct()], String.t(), [atom()]) ::
  nil | {:wrong_timeframe | :unordered | :unaligned, term()}

What is wrong with the candles a fake returned for timeframe, or nil. Assertion 37.

  • {:wrong_timeframe, label} — a bar labelled with a width other than the one asked for. A bar stamped "1d" that was asked for as "1h" is a missing granularity becoming the closest one in the label, which is how every bar then gets joined to the wrong series; group 21 holds the refusal of an unserved width, and this holds the label of a served one. Judged first.
  • {:unordered, opened_at} — a bar that is not later than the one before it. Oldest first, strictly.
  • {:unaligned, opened_at} — a bar not on its width's grid. The grid is Timeframe.aligned?/2 (strict, UTC), except that for a venue that is not crypto-only (asset_classes) a width wider than 30 minutes and shorter than a day is session-anchored (US equity sessions open at 09:30 ET) and only needs 30-minute alignment. Daily and longer bars are mapped to 00:00 UTC of the session date and stay strict. Widths the vocabulary does not model are not judged.

captured_notice_faults(fake, pairs, opts, unlisted, wait)

@spec captured_notice_faults(
  module(),
  [String.t()],
  keyword(),
  String.t() | nil,
  timeout()
) ::
  {non_neg_integer(), list()}

Drives fake through subscribe_notices/1, subscribe/2 of pairs, subscribe/2 of unlisted when given, and unsubscribe/2, then collects every Core.Notice delivered to this process until none arrives for wait milliseconds. Assertion 45.

Returns {judged, faults}: how many notices were captured, and notice_batch_faults/1 of them. A call that raises is {0, [{:raised, error}]}. judged of 0 means the fake emitted nothing and the group verified nothing; the fakes in this family register a notice subscriber silently, so that is the usual case, and it must not be read as coverage.

carries_process?(term)

@spec carries_process?(term()) :: boolean()

Whether a term holds a pid, port or reference anywhere inside it, structs and map keys included. Assertion 11: a facade answer is data, never a process the caller must manage.

case_faults(module, cases)

@spec case_faults(module(), [{atom(), list(), term()}]) :: [
  {atom(), arity(), term(), :raised | {:answered, term()}}
]

Runs each {name, args, expected} of cases against module and returns the ones whose answer is not {:error, expected}, as {name, arity, expected, fault} with fault as refusal_fault/2 names it. Empty means every case was refused as the convention says.

The arguments are not kept in the result: they carry the suite's credentials, and a failure message prints what it is given.

catalog_access_fault(atom, answer)

@spec catalog_access_fault(:enumerable | :query_only, term()) ::
  nil | :query_required | {:answered, term()}

What is wrong with get_symbols/1's answer without a query, given the declared catalog_access, or nil. Assertion 12.

:query_only means there is no list-everything call, so the answer must be exactly {:error, {:query_required, venue}} (usage-rules.md). Any other answer is {:answered, answer}: it was {:error, _}, so a fake refusing for a missing credential or any unrelated reason satisfied a :query_only claim it never demonstrated. :enumerable must not demand a query: :query_required otherwise.

child_spec_credential_fault(venue)

@spec child_spec_credential_fault(module()) :: nil | {:leaked, String.t()}

What is wrong with venue.child_spec/1 where a secret is concerned, or nil. Assertion 22.

Hands child_spec/1 a :credentials map holding one unique canary under every secret key name this family's venues use, and looks for the canary in inspect/2 of the answer, which is what OTP prints on every child crash. {:leaked, rendered} when it is there. A child_spec/1 that drops the credentials altogether is not a fault, and one that raises is not caught here: the group fails on the raise, as it should.

Public so that a teeth test can hand it a child_spec/1 that stores the raw map, which the inline version of this check could never be shown to catch.

coverage_by_kind_fault(by_kind, coverage, streamable)

@spec coverage_by_kind_fault(%{required(atom()) => map()}, map(), [atom()]) ::
  nil
  | {:union_mismatch, [String.t()], [String.t()]}
  | {:undeclared_kinds, [atom()]}

What is wrong with a venue's coverage_by_kind/1 answer, given the coverage/1 map of the same moment and the declared streamable kinds, or nil. Assertion 15.

{:union_mismatch, kind_symbols, coverage_symbols} (both sorted) when the symbols across every kind are not exactly the symbols coverage/1 reports, the two being meant as one fact; {:undeclared_kinds, kinds} when it reports a kind streamable does not declare. The union is judged first.

credential_gate_fault(answer)

@spec credential_gate_fault(term()) :: nil | {:succeeded | :raised, term()}

What is wrong with a call's answer when its credentials were stripped, or nil. Assertion 17.

  • {:succeeded, answer} - {:ok, _}: the fake served a call it could not have signed.
  • {:raised, answer} - a raise, exit or throw ({:raised, e}, {:exit, r}, {:throw, v} from the suite's call_on/3). A raise is not a refusal: the family answers with tuples, and a fake that crashes on %{} credentials holds the gate for a reason that has nothing to do with credentials.

Any other answer is nil: the refusals ({:error, _}, {:refused, _}) and the callbacks that return a bare value, which the group walks too and which cannot be {:ok, _}.

credential_gated_endpoints(caps, asset_classes)

@spec credential_gated_endpoints(DpExchange.Core.Capabilities.t(), [atom()]) :: [
  {atom(), arity()}
]

The endpoints assertion 17 asks a credential_benefit: :required venue's fake to refuse when called with credentials stripped.

Every facade callback that is answerable (not child_spec/1 or start_link/1, which start something), active in caps, not one of the two whose own doc types the credential credentials() | nil, not declared in Capabilities.no_venue_contact, and not market_status/1 on a venue whose asset_classes are exactly [:crypto] (crypto trades continuously, so there is no session for a credential to gate).

This is public, and the assertion calls it, so that the selection can be shown to fail: the previous tests of this decision rebuilt it by hand beside the fixtures and asserted what the fixtures did, so no change to the decision could break them. The assertion also refuses an empty result, because a loop over no endpoints reports green having examined none.

credentialed_callbacks()

@spec credentialed_callbacks() :: [atom()]

The names of the Core.Venue callbacks whose first argument is credentials(), read from the behaviour's own @callback specs.

The suite shapes a call's arguments by whether the callback takes its credential positionally. That used to be a hand-written list of eleven names, and it rotted in the way a list does: list_payment_methods/2, get_payment_method/3, get_notional_balances/3, list_custody_fees/2, get_transactions/2, place_orders/3, cancel_all_orders/2, preview_order/3, preview_replace/4, close_position/3 and get_trade_volume/2 all take credentials() first and none was on it. Each was therefore called with a symbol where the credential belongs, a fake that matched on its credentials raised, and the raise was taken for an answer by every assertion that tolerates one. A union such as credentials() | nil (test_connection/2) counts.

eligible_cases(module, caps, cases)

@spec eligible_cases(module(), DpExchange.Core.Capabilities.t(), [
  {atom(), list(), term()}
]) :: [
  {atom(), list(), term()}
]

The calls of cases that module can be asked, as {name, args, expected} triples: exported at that arity and not declared :unsupported in caps.

A callback a venue legitimately declares :unsupported is skipped the way the suite's other assertions skip it. It is not a pass: assertion 12 holds the declaration to the behaviour.

family_reason_fault(price_answer, padded_answer, venue)

@spec family_reason_fault(term(), term(), atom()) :: nil | {atom(), term()}

What is wrong with a fake's answers for the two opt-in family reasons, or nil. Assertion 41.

unlisted is a well-formed symbol the fake does not list and price_answer is what its get_price/2 said for it: anything but {:refused, :not_listed} is {:unlisted_symbol, answer}. padded_answer is what a credentialed read said for a credentials map with surrounding whitespace, from a venue whose runtime_id/0 is venue: anything but {:error, {:invalid_credentials, venue}} is {:padded_credentials, answer}. A nil for either answer is a reason the venue did not opt in to, and is not judged.

foreign_modules(modules, permitted, build_lib_dir)

@spec foreign_modules([module()], MapSet.t(String.t()), Path.t()) :: [module()]

The modules in modules that belong to an application outside permitted.

build_lib_dir is Path.join(Mix.Project.build_path(), "lib"). A module found under it belongs to the application named by its first path segment. A module found anywhere else is OTP or the Elixir standard library and is permitted, and a module found nowhere is a host-application module this package depends on without saying so, which is foreign.

The previous check could not fire for a dependency. It looked for /deps/<name>/ in the module's path, but Mix compiles a dependency into _build/<env>/lib/<name>/ebin and loads it from there, never from deps/. Every dependency module therefore matched nothing and was permitted, so a package linking an undeclared dependency passed. Only the :non_existing branch ever rejected anything.

format_case_faults(faults)

@spec format_case_faults([{atom(), arity(), term(), term()}]) :: String.t()

A failure message for case_faults/2's result, one line per call that was not refused.

half_implemented_optionals(module)

@spec half_implemented_optionals(module()) :: [{atom(), arity(), [arity()]}]

The optional callbacks module implements at the wrong arity, as {name, arity, arities}: the callback's name is exported at arities but not at the one the behaviour declares, so a caller at the declared arity gets an UndefinedFunctionError. Assertion 1.

include_partial_refusal_cases(symbol, timeframe, opts)

@spec include_partial_refusal_cases(String.t(), String.t(), keyword()) :: [
  {atom(), list(), term()}
]

The {name, args, expected} cases of assertion 36: get_historical_prices/4 with an :include_partial that is not a boolean must answer {:error, {:invalid_option, :include_partial, value}}. nil is not tried: several venues read it as an absent option.

invalid_amount_fault(answer, value)

@spec invalid_amount_fault(term(), term()) ::
  nil | {:accepted | :wrong_error | :raised, term()}

What is wrong with a write's answer to the invalid value as its amount, or nil. Assertion 47, venue rule W4.

The answer must be exactly {:error, {:invalid_amount, value}}, the value as given: a venue that echoes a different term has rewritten what the caller sent. {:accepted, answer} is a success, the dangerous one (a withdrawal of NaN was sent); {:wrong_error, answer} is any other answer, which names another reason or another tag; {:raised, answer} is a raise, exit or throw.

invalid_amounts()

@spec invalid_amounts() :: [{atom(), term()}]

The invalid amounts assertion 47 hands every amount-taking write: {label, value}.

Venue rule W4 (DpExchange.Core.Venue): a float, zero, a negative, NaN and infinity are each {:error, {:invalid_amount, value}}. NaN and infinity are Decimals, which is how a validator that compares with Decimal.gt?/2 before checking Decimal.nan?/1 raises.

invalid_order_ids()

@spec invalid_order_ids() :: [term()]

Order ids get_order/3 and cancel_order/3 must refuse as {:invalid_order_id, value}: nil and a whitespace-only string.

invalid_single_symbols()

@spec invalid_single_symbols() :: [term()]

Single-symbol values get_price/2 and get_top_of_book/2 must refuse as {:invalid_symbol, value}: nil, a number, and a whitespace-only string.

invalid_subscribers()

@spec invalid_subscribers() :: [term()]

:to values a streaming call must refuse as {:invalid_subscriber, value}: neither a pid nor an atom other than nil, true and false. nil is not here: it means the caller.

invalid_symbol_lists(sample)

@spec invalid_symbol_lists(String.t()) :: [term()]

Symbol lists the streaming calls must refuse as {:invalid_symbols, value}: not a list, a list holding a non-string or a blank string (whitespace-only is blank), and an improper list. sample is a symbol the venue serves, so the bad lists are bad for exactly one reason.

lower_case_delivery_fault(fake, pair, opts, wait)

@spec lower_case_delivery_fault(module(), String.t(), keyword(), non_neg_integer()) ::
  nil | :not_delivered

What is wrong with a fake's subscribe/2 given a lower-case pair, or nil. Assertion 29.

The real facade upper-cases subscribed symbols, so the payload arrives under pair (the canonical form). :not_delivered when nothing arrived under it within wait milliseconds.

market_price_fault(answers)

@spec market_price_fault([term()]) :: nil | :price_never_refused | {:answered, term()}

What is wrong with the answers to market_price_requests/2, or nil. Assertion 34.

Every answer must be {:error, {:unexpected_field, :price}} or a refusal of the duration that was tried ({:unsupported_order_combination, type, tif}, {:missing_field, :time_in_force}, {:unexpected_field, :time_in_force}), and at least one must be the price refusal.

  • {:answered, answer} — an answer that is neither: a success (the price was dropped and a different order placed than the one asked for), a raise, any other error.
  • :price_never_refused — every duration was refused for being the wrong duration, so nothing showed what the venue does with the price.

market_price_requests(base, caps)

@spec market_price_requests(map(), DpExchange.Core.Capabilities.t()) :: [map()]

The requests that put a :price on a market order, one for each time in force the venue might want for it, or [] when caps does not support :market.

The time in force a venue takes on a market order is the venue's own (one wants :ioc, one none at all), and the check order is time in force before price fields. So the request is sent with no duration and with each duration caps names, and market_price_fault/1 judges the set.

market_value_fault(values)

@spec market_value_fault(term()) :: nil | tuple()

What is wrong with a value (or list of values) a fake returned, or nil. Assertion 39.

A traded price (Quote.price, Trade.price, a candle's open, high, low and close) is above zero: {:non_positive, field, value}. A TopOfBook bid or ask is nil or above zero. An OrderBook level has a price and a size above zero and each side is best first ({:non_positive, {side, :price | :size}, value}, {:unreadable_level, side, level}, {:unsorted, side}). Any other value is not judged.

missing_fake_exports(venue, fake)

@spec missing_fake_exports(module(), module()) :: [{atom(), arity()}]

The public functions of venue that fake does not export, as a sorted list of {name, arity}. Assertion 44.

A function on the facade and not on the Fake is an UndefinedFunctionError in a consumer's async: true suite and an answer in production: the consumer cannot fake what the Fake does not have. [] is conforming. The Fake may export more (test helpers); that is not judged here.

missing_required_callbacks(module)

@spec missing_required_callbacks(module()) :: [{atom(), arity()}]

The callbacks Core.Venue requires that module does not export, as {name, arity}. Assertion 1.

nil_recipient_fault(fake, pairs, opts, wait)

@spec nil_recipient_fault(module(), [String.t()], keyword(), non_neg_integer()) ::
  nil
  | {:raised, Exception.t()}
  | {:escaped, atom(), term()}
  | :nothing_delivered

What is wrong with a fake's subscribe/2 given to: nil, or nil. Assertion 27.

A forwarded nil is a caller that said nothing, and nothing means the caller: it must not raise and must deliver to the calling process. wait is how long to wait for the delivery. {:raised, error} or {:escaped, kind, reason}; :nothing_delivered.

no_request_transport_fault(answer)

@spec no_request_transport_fault(term()) ::
  nil | {:ignored | :wrong_error | :raised, term()}

What is wrong with a no-request call's answer to timeout: 5_000, or nil. Assertion 46.

subscribe_notices/1 sends no HTTP request, so a transport key there is read by nothing: a caller who passed timeout: believed the call was bounded and it was not. It must be {:error, {:unknown_option, :timeout}}. {:ignored, answer} is any answer that carried on (:ok, {:ok, _}); {:raised, answer} a raise, exit or throw; {:wrong_error, answer} anything else.

nothing_checked?(module, caps, cases)

@spec nothing_checked?(module(), DpExchange.Core.Capabilities.t(), [
  {atom(), list(), term()}
]) ::
  boolean()

Whether a run of cases against module examined nothing it should have: no case is eligible (eligible_cases/3) although caps declares at least one of the cases' calls active, which means that call is not exported at its arity.

A venue that declares every call of cases :unsupported has nothing to hold and is false; the declaration itself is held by assertion 12.

nothing_examined?(arg1, examined)

@spec nothing_examined?([term()], [boolean()]) :: boolean()

Whether assertion 39 examined nothing: endpoints were active but none of their answers was checked (examined holds one boolean per endpoint, true when a value was inspected).

No active endpoint at all is not a failure (the venue declares them all :unsupported).

notice_batch_faults(notices)

@spec notice_batch_faults([term()]) :: [
  {atom() | nil, DpExchange.Core.Notice.fault()}
]

The rule violations in notices, as {kind, fault} pairs, judged by DpExchange.Core.Notice.conformance_faults/1. [] is conforming, and so is an empty list: judging nothing is not a fault, which is why group 45 also reports how many it judged.

order_id_refusal_cases(opts_for)

@spec order_id_refusal_cases((atom() -> [keyword()])) :: [{atom(), list(), term()}]

The {name, args, expected} cases of assertion 35. opts_for gives, for a callback name, the option lists to try; the suite passes the venue's own options with credentials stripped and an empty list, because the id is checked before the account option and before credentials.

order_request_cases(base)

@spec order_request_cases(map()) :: [{term(), term()}]

The malformed place_order/3 requests of assertion 34, as {request, expected}, built from base, a request the venue accepts: a non-map ({:invalid_order_request, request}), a request with no :order_type ({:missing_field, :order_type}) and one with a key the builder does not read ({:unsupported_order_field, key}).

order_value_cases(base)

@spec order_value_cases(map()) :: [{map(), term()}]

The requests of assertion 52, as {request, reason}, built from base, a request the venue accepts: for each of :quantity and :price that base carries, one request per value of invalid_amounts/0 (a float, zero, a negative, NaN, infinity) in its place.

reason is {:invalid_quantity, value} or {:invalid_price, value}, the value as given (Core.Venue.place_order/3, "Quantities and prices accept a Decimal, an integer or a numeric string, and refuse a float, zero and negative values, and NaN and infinity"). Only one field is bad in each request, so the refusal can only be for that field. A key base does not carry is not probed: adding a :price to a market order would be refused for a different reason ({:unexpected_field, :price}), and a venue whose sizing is a :quote_size has no :quantity to spoil. [] therefore means base had neither, and the group refuses to pass having examined nothing.

order_value_fault(values)

@spec order_value_fault(term()) :: nil | {:order | :fill, String.t()}

What is wrong with an Order or Fill a fake answered, or nil. Assertion 54.

answer is a struct, a list of them, or {:ok, either}; anything else (a refusal, :ok, a map) is not judged. Each struct is rebuilt through its own new/1 (Types.Order.new/1, Types.Fill.new/1), so a fake's order is held to everything the type refuses: a side, type, status or time in force outside the vocabulary, a status beside a fill figure that contradicts it, a negative size, a float, a leg that is not an OrderLeg. The fault is {:order, message} or {:fill, message}, the constructor's own ArgumentError message.

Groups 5 and 31 judge only that a success is an Order and that its id is accepted back; a fake that answered %Order{status: :filled, filled_quantity: 0} or side: "buy" passed both. Every venue in the family builds its orders as struct literals, which skips the constructor, so this is the only place the constructor's refusals reach a venue's Fake.

order_values_examined(values)

@spec order_values_examined(term()) :: non_neg_integer()

How many Order and Fill structs answer holds (a struct, a list of them, or {:ok, either}), the number assertion 54 actually judged.

A group over a fake that refused every call, or answered {:ok, []} everywhere, judged nothing and must not read as a pass; assertion 54 requires at least one when place_order/3 is active.

order_write_discipline_cases(preview_args, replace_args)

@spec order_write_discipline_cases(list(), list()) :: [{atom(), list(), term()}]

The {name, args, expected} cases of assertion 50, from the suite's own argument lists for preview_order/3 ([credentials, request, opts]) and replace_order/4 ([credentials, id, changes, opts]).

preview_order/3 is handed each malformed request of order_request_cases/1, and must answer the tuple place_order/3 answers for it. replace_order/4 is handed an empty change map and values that are not maps, and must answer {:invalid_order_request, value}: a replace that changes nothing is not a replace, and a non-map never raises.

permitted_apps(app, deps)

@spec permitted_apps(atom(), [tuple()]) :: MapSet.t(String.t())

The applications a package may link against: its own, every declared dependency, and what those depend on at runtime, as strings.

deps is Mix.Project.config()[:deps] in any of Mix's three shapes. The purity assertion read it with for {dep, _rest} <- deps, which matches a two-element tuple only, so every dependency declared with a requirement AND options — {:jason, "~> 1.4", optional: true}, {:foo, "~> 1.0", only: :test} — was silently dropped from the declared set.

probe_timeframe(capabilities)

@spec probe_timeframe(DpExchange.Core.Capabilities.t()) :: String.t() | nil

The width assertions 36 and 37 ask get_historical_prices/4 for: "1h" when caps declares it, else the first declared width the shared vocabulary gives a fixed length, else the first declared width, or nil when none is declared.

A venue is asked for a width it serves: asking for one it does not would be refused for the width before the option under test was ever read.

process_credential_fault(arg1)

@spec process_credential_fault({module(), atom()} | nil) ::
  nil | {:leaked, pid(), String.t()} | {:probe_failed, term()}

What is wrong with a venue's running process where a secret is concerned, or nil. Assertion 51.

probe is the :process_probe option: {module, function}, a 1-arity function that starts the venue's own process or processes with no network from a credentials map. It is handed a map holding one unique canary under every secret key name this family's venues use, and must answer {:ok, pid} or {:ok, [pid, ...]}. Each process is asked for :sys.get_status/2, which carries its process dictionary and the state it would print in a crash report, rendered with inspect/2; the canary in it is {:leaked, pid, excerpt}. The processes are stopped afterwards whatever the answer.

{:probe_failed, answer} when the probe starts nothing it can examine (an error, an empty list, a non-pid, a process that does not answer to :sys): a probe that examined nothing must not read as a pass. nil for no probe at all: the group is opt-in.

Public so that a teeth test can hand it a process that keeps the raw map, which the inline version could never be shown to catch.

promised_types()

@spec promised_types() :: %{required({atom(), non_neg_integer()}) => [module()]}

The Core.Types struct(s) each Core.Venue callback's success is typed as, read from the behaviour's own @callback specs.

Returns %{{name, arity} => [module]} for every callback whose success type names a DpExchange.Core.Types.* struct, and omits the rest. Read from the compiled specs rather than written as a table, so it cannot drift from the contract it restates. Used by assertion 5.

provider_name_fault(name)

@spec provider_name_fault(term()) :: nil | {:provider_name, term()}

The identity fault of a venue's provider_name/0 answer, or nil. Assertion 3.

range_fault(range, answer, must_include)

@spec range_fault(keyword(), term(), [DateTime.t()]) ::
  nil | {:outside_range | :missing | :not_answered, term()}

What is wrong with a ranged answer, or nil. :start is inclusive and :end exclusive, on opened_at: {:outside_range, opened_at} for a bar outside range, {:missing, opened_at} for one of must_include that was dropped.

A range chosen to keep a bar must be answered. The ranges come from the fake's own bars, so a window around them is one the venue has data for, and an answer that is not {:ok, list} is {:not_answered, answer}: a fake refusing every range it was asked passed this group for having examined no bound. When must_include is empty (the ask that must drop the bar) a refusal stays nil: a venue may decline a window with nothing in it, and a refusal there says nothing about the bounds.

refusal_fault(answer, expected)

@spec refusal_fault(term(), term()) :: nil | :raised | {:answered, term()}

What is wrong with a call's answer to an argument the family refuses locally, or nil. Assertions 32 to 35.

expected is the whole error term, for example {:invalid_symbols, nil}; the answer must be exactly {:error, expected}.

  • :raised — the call raised, exited or threw ({:raised, error}, {:exit, reason} or {:throw, value} from the suite's call_on/3). A raise is not a refusal.
  • {:answered, answer} — any other answer: :ok for a malformed list, {:ok, _}, {:error, :feed_not_started} where a symbol was wrong, a refusal after credentials.

runtime_id_fault(venue, id)

@spec runtime_id_fault(module(), term()) :: nil | {:runtime_id, term(), String.t()}

The identity fault of a venue's runtime_id/0 answer, or nil. Assertion 3.

The id is an atom equal to the module's last segment, underscored: DpExchange.Coinbase is :coinbase. to_string/1 is compared, because nil, true and false stringify to words no module segment is.

single_symbol_refusal_cases(opts_for)

@spec single_symbol_refusal_cases((atom() -> keyword())) :: [{atom(), list(), term()}]

The {name, args, expected} cases of assertion 33. opts_for gives the options for a callback name; the suite passes options with credentials stripped, because the refusal is decided before credentials are read.

staking_return_fault(answer)

@spec staking_return_fault(term()) ::
  nil | {:not_a_staking_transaction | :unexamined | :raised, term()}

What is wrong with a staking write's answer to a valid call, or nil. Assertion 48, venue rule W5.

stake/3 and unstake/3 are typed result(Types.StakingTransaction.t()), and the Fake returns that struct as the real path does. {:not_a_staking_transaction, answer} is a success of another kind (a raw map); {:unexamined, answer} is a refusal or error, which proves nothing about the return and is how a fake that cannot be asked passes unseen (declare what it needs in endpoint_opts:, or declare the endpoint :unsupported); {:raised, answer} a raise, exit or throw.

streamed_but_absent(absent, streamable)

@spec streamed_but_absent([{atom(), arity()}], [atom()]) :: [
  {atom(), {atom(), arity()}}
]

The {kind, endpoint} pairs a package declares streamable while saying the venue does not serve the matching pull endpoint at all (absent). Assertion 8: the two cannot both be true.

streaming_call_fault(call)

@spec streaming_call_fault((-> term())) ::
  nil | {:escaped, atom(), term()} | {:not_a_value, term()}

What is wrong with a streaming call made while no feed runs, or nil. Assertion 28.

call is a zero-arity function making the call. A streaming callback returns a value (:ok, {:error, _} or a map); {:escaped, kind, reason} when it exited, threw or raised in the caller, {:not_a_value, result} for any other answer.

streaming_refusal_cases(sample, opts)

@spec streaming_refusal_cases(String.t(), keyword()) :: [{atom(), list(), term()}]

The {name, args, expected} cases of assertion 32, for the streaming calls given opts.

Every malformed symbol list on subscribe/2, unsubscribe/2 and update_symbols/2; every bad :to on subscribe/2 and subscribe_notices/1; and one call wrong in both ways, which must answer for the symbols, because the symbols are checked first. opts decides whether this drives a fake or the real facade with no feed running.

struct_answer?(arg1)

@spec struct_answer?(term()) :: boolean()

Whether result is a success that gave assertion 5 a struct to check: {:ok, struct} or {:ok, [struct | _]}.

Assertion 5 takes a refusal, an error and an empty list as "not a success, so nothing to compare", which is right for each answer alone. Across a whole fake it is not: a fake that refused every endpoint (a missing endpoint_opts:) or returned {:ok, []} everywhere passed the group having compared nothing. The group requires at least one answer for which this is true.

subscribed_push_fault(arg, runtime_id)

@spec subscribed_push_fault({:dp_exchange, term(), term()}, atom()) ::
  nil | :wrong_tag | :not_a_core_struct

What is wrong with a message subscribe/2 pushed, or nil when nothing is. Assertion 20.

message is the {:dp_exchange, runtime_id, payload} the suite received and runtime_id is the venue's own DpExchange.Core.Venue.runtime_id/0.

  • :wrong_tag — tagged with something other than the venue's runtime_id/0, so a process subscribed to several venues cannot tell them apart. Judged first.
  • :not_a_core_struct — the payload is not a DpExchange.Core.Types.* struct: raw venue data forwarded to subscribers, the shape with a wired decoder that never ran.

subscription_set_fault(fake, list, opts)

@spec subscription_set_fault(module(), [String.t(), ...], keyword()) ::
  nil | {atom(), term()}

What is wrong with a fake's subscription set after four calls, or nil. Assertion 26.

subscribe/2 adds to the live set, update_symbols/2 replaces it, unsubscribe/2 removes only what it names; the set is read back through coverage/1. Needs two symbols the fake serves. The fault names the first rule broken: {:never_covered, symbol} (nothing to compare with), {:subscribe_dropped, symbols}, {:subscribe_missed, symbol}, {:unsubscribe_left, symbol}, {:unsubscribe_overreached, symbol}, {:update_not_taken, symbol}, {:update_left, symbol} or {:state_not_restored, symbols}.

The last step unsubscribes the one symbol the set still holds, and the set must then be empty again: coverage that retains anything for a symbol no longer subscribed is state that grows by one entry for every distinct symbol a venue is ever asked about.

time_fault(value, module)

@spec time_fault(term(), module()) ::
  nil | {:wrong_struct, term()} | {:observed_at, term()} | {:venue_time, term()}

What is wrong with the times on a struct a fake returned, or nil. Assertions 14 and 23.

module is the struct the endpoint promises (Quote, OrderBook or TopOfBook). {:wrong_struct, value}; {:observed_at, value} unless it is a DateTime; {:venue_time, value} unless it is a DateTime or nil: the venue's own time, never an epoch integer, a NaiveDateTime or a stand-in.

timeframe_fault(answer, width)

@spec timeframe_fault(term(), String.t()) ::
  nil | :substituted | {:wrong_answer, term()}

What is wrong with a call's answer to width, a timeframe the venue does not declare, or nil. Assertion 21.

The answer must be exactly {:error, {:unsupported_timeframe, width}}, the width as given (DpExchange.Core.Venue.get_historical_prices/4). :substituted is {:ok, _}: the venue served the width it has in place of the one asked for, a missing granularity becoming the closest one, which mislabels every candle it touches. {:wrong_answer, answer} is any other answer: a different error, a refusal, a raise, or the tag with another value. It used to be nil for all of them, so a fake that answered {:error, :boom} to every width it was asked for passed a group documented as "refused, never served at the nearest width"; the refusal was assumed, not seen.

unexported_calls(module, caps, cases)

@spec unexported_calls(module(), DpExchange.Core.Capabilities.t(), [
  {atom(), list(), term()}
]) :: [
  {atom(), arity()}
]

The {name, arity} of each call in cases that caps declares active and module does not export, in the order first met.

eligible_cases/3 drops such a case without a word, so a fake missing one of two calls was judged on the other and passed. Used by assertions 33 and 35, whose calls (get_price/2, get_top_of_book/2, get_order/3, cancel_order/3) are required Core.Venue callbacks, so a declaration of one active with no export behind it is a fake that cannot be asked, not a call the venue does not serve.

unknown_id_endpoints()

@spec unknown_id_endpoints() :: [{atom(), non_neg_integer()}]

The reads and cancels assertion 49 asks about an unknown id: the order callbacks Core.Venue documents {:refused, :not_found} for. Other id-taking callbacks (get_conversion/2, get_payment_method/3, get_watchlist/2) have no documented answer for an unknown id, so none is asserted.

unknown_id_fault(answer)

@spec unknown_id_fault(term()) :: nil | {:invented | :wrong_answer | :raised, term()}

What is wrong with a fake's answer to an order id nobody placed, or nil. Assertion 49, venue rule W6.

The real path answers a 404 {:refused, :not_found} (DpExchange.Core.Venue.cancel_order/3). {:invented, answer} is a success: the fake made up an order for an id it never issued, so a consumer's test of its not-found handling can never run. {:wrong_answer, answer} is any other answer, including a refusal with another reason, or an error from a call the fake could not place for lack of endpoint_opts:; {:raised, answer} a raise, exit or throw.

unknown_option_evidence?(mode, arg2)

@spec unknown_option_evidence?(:strict | term(), term()) :: boolean()

Whether an endpoint's answer to the probe is evidence the options were checked: it could be judged (unknown_option_judged?/1), or it is the {:unknown_option, key} refusal itself.

A refusal of the probe is proof whatever the plain call did, so an endpoint the fake refuses for a venue reason (an order id it has not placed) still counts when it refused the option too. Assertion 43 requires at least one such answer.

unknown_option_exclusions()

@spec unknown_option_exclusions() :: %{
  required({atom(), non_neg_integer()}) => String.t()
}

The callbacks assertion 43 cannot hand an unknown option, each with the written reason.

Everything else the facade declares is probed. An exclusion is a decision, so it is listed here rather than skipped inside the group: a callback absent from both unknown_option_probed/1 and this map would be silently unprobed, and the teeth tests hold that the two cover the whole behaviour.

unknown_option_fault(answer)

@spec unknown_option_fault(term()) ::
  nil | {:ignored | :wrong_error | :raised, term()}

What is wrong with a read's answer to a call carrying unknown_option_probe/0, or nil. Assertion 43.

A facade call given an option it does not read must refuse it, {:error, {:unknown_option, :dp_contract_unknown_option}}, not ignore it: an ignored misspelling (limt:) is a plausible answer to a different question. {:error, :not_supported} is also fine, since an endpoint the venue does not serve answers that whatever the options. {:ignored, answer} is a success or a refusal that carried on as if the key were absent; {:wrong_error, answer} is any other answer, which names a different key or is not the family tag; {:raised, answer} is a raise, exit or throw.

unknown_option_fault(mode, answer)

@spec unknown_option_fault(:strict | term(), term()) ::
  nil | {:ignored | :wrong_error | :raised, term()}

unknown_option_fault/1, for an endpoint whose plain call is the baseline.

With :strict or a baseline that succeeded, the answer is judged by unknown_option_fault/1. With any other baseline the fake refused the generated call for its own reason, so the options were never reached and only two things are faults: a raise, and a success (a probe cannot turn a refusal into a success).

unknown_option_judged?(arg1)

@spec unknown_option_judged?(:strict | term()) :: boolean()

Whether assertion 43 could judge an endpoint's answer to the probe.

mode is :strict (judged on the probe alone) or the answer the same call gave without the probe. A write or a read the fake refuses for its own reasons before it reads the options (a venue-specific refusal of the generated arguments) gives no evidence about the options, so it is not judged; the group requires at least one judged answer.

unknown_option_probe()

@spec unknown_option_probe() :: :dp_contract_unknown_option

The option key assertion 43 hands every read: one no venue reads and none can mistake for its own.

unknown_option_probed(callbacks)

@spec unknown_option_probed([{atom(), non_neg_integer()}]) :: [
  {atom(), non_neg_integer()}
]

The callbacks of callbacks assertion 43 probes: all of them, less unknown_option_exclusions/0.

unknown_option_strict()

@spec unknown_option_strict() :: [{atom(), non_neg_integer()}]

The five reads assertion 43 judged before it covered every endpoint. They are judged on the probe alone, with no baseline call: a refusal of the probe key there is a fault whatever the plain call would have answered.

unknown_order_id()

@spec unknown_order_id() :: String.t()

The order id assertion 49 asks about: one no write of the suite ever minted.

unknown_stream_kinds(caps)

@spec unknown_stream_kinds(DpExchange.Core.Capabilities.t()) :: [term()]

The kinds in streamable or authenticated_streamable that this contract has no word for (Capabilities.data_kinds/0). Assertion 8.

unrecorded_absences(venue_absences, capabilities)

@spec unrecorded_absences([{atom(), arity()}], DpExchange.Core.Capabilities.t()) :: [
  {atom(), arity()}
]

The endpoints venue_does_not_serve/0 names that caps does not declare :unsupported. Assertion 8: an endpoint the venue does not serve cannot also be one the package answers.

unserved_timeframe(caps)

@spec unserved_timeframe(DpExchange.Core.Capabilities.t()) :: String.t() | nil

The timeframe assertion 21 asks get_historical_prices/4 for: the first width of Timeframe.nameable/0 that caps.historical_timeframes does not name, or nil when the venue declares the whole vocabulary and there is no width left to prove a refusal with.

unsupported_fault(arg1)

@spec unsupported_fault(term()) :: nil | :string_refusal | :raised | :served | :other

What is wrong with a call's answer to an endpoint capabilities/0 declares :unsupported, or nil when nothing is. Assertion 12's under-declaring direction.

  • nil — {:error, :not_supported}, the atom the contract promises.
  • :string_refusal — {:error, "not_supported"}. A caller matching the atom misses it and treats a refusal as an unrecognised error.
  • :raised — the call raised ({:raised, error} from the suite's call_on/3).
  • :served — {:ok, _}: declared unsupported but it works, which hides functionality a consumer could have used.
  • :other — any other answer, which is neither the atom nor a success.

use_options()

@spec use_options() :: [atom()]

The options use DpExchange.Core.AdapterContract accepts.

Anything else is refused by validate_use_opts!/1: a misspelled option (fkae:, sample_pair:) used to be ignored, and the group it was meant to switch on then ran against its default or was skipped, which reads as a pass.

validate_order_args!(order_args)

@spec validate_order_args!(term()) :: %{optional(atom()) => term()}

Checks the order_args: option of use DpExchange.Core.AdapterContract. It must be a map whose keys are drawn from :order_request, :order_requests and :order_changes.

An unknown key raises at compile time instead of being ignored. A misspelt key that was silently dropped would leave the suite's default order in place, and the assertion it was meant to reach would go on failing, or passing, for a reason nobody declared.

validate_use_opts!(opts)

@spec validate_use_opts!(term()) :: keyword()

Validates the options given to use DpExchange.Core.AdapterContract, returning them.

Raises ArgumentError for a non-keyword list, or a key outside use_options/0.

value_examined?(values)

@spec value_examined?(term()) :: boolean()

Whether value, the success of a price endpoint, gave assertion 39 anything to inspect.

A struct does, except an OrderBook with no level on either side and a TopOfBook with neither a bid nor an ask (nothing there for market_value_fault/1 to hold). A list does when one of its elements does, so {:ok, []} from get_trades/2 or an empty candle list does not. Anything else, a bare map or a scalar, is not something market_value_fault/1 judges.

{:ok, _} of any shape used to count as examined, so a fake answering {:ok, []} for every price endpoint passed the group having looked at no price, size or level at all.

volume_window_fault(other)

@spec volume_window_fault(term()) ::
  nil
  | {:wrong_struct, term()}
  | {:window_without_volume | :unstated_window, term()}

What is wrong with the volume window on a Quote a fake returned, or nil. Assertion 30.

A quote with no volume names no window: {:window_without_volume, window}. A quote with a volume names which quantity it is (one of Quote.volume_windows/0): {:unstated_window, window}.

wanted_fault(fake, pairs, opts)

@spec wanted_fault(module(), [String.t()], keyword()) :: nil | {atom(), term()}

What is wrong with a fake's wanted/1, or nil. Assertion 40.

A fake that does not export wanted/1 has not adopted the optional callback and is never faulted. Otherwise, after subscribe/2 of pairs, wanted/1 is a list or a MapSet ({:not_a_collection, value}), holds every symbol subscribed ({:subscribed_not_wanted, symbols}) and holds every symbol coverage/1 reports ({:covered_not_wanted, symbols}), because what arrived was asked for. The set is cleared with update_symbols/2 first, so an earlier subscription cannot satisfy it.