DpExchange.Core.AdapterContract (DpExchangeCore v0.3.76)

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

What it asserts, and what it deliberately does not

Thirty-one 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.

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 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 modules in modules that belong to an application outside permitted.

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

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

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

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.

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.

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.

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.

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.

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.

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.

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.

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.