DpExchange.Core.OrderRoundTripCheck (DpExchangeCore v0.3.120)

Copy Markdown View Source

Asks a venue's fake whether it accepts the order ids its own order-minting calls just returned.

The defect this exists for

A fake that refuses its own ids. dp_exchange_gemini's fake answered place_order/3 with an order, and then answered get_order/3 and cancel_order/3 for that same order {:error, {:invalid_order_id, _}} — the id it had minted was not one its own read and cancel would take (gemini issue #5). Every assertion in Core.AdapterContract passed: group 5 calls get_order/3 with an id it makes up and accepts a refusal as an answer, group 12 asks only that an active endpoint not answer :not_supported, and nothing placed an order and then used what came back. A consumer's tier-1 test that places, reads back and cancels — the first thing anyone writes against a trading fake — could not pass.

The same class is recorded in dp_exchange_webull's fake, whose place_order/3 echoed a caller-chosen client_order_id that its own get_order/3 and cancel_order/3 then refused as :not_found because they knew only one fixed id. Different mechanics, one shape: the fake is a set of independently written functions, and nothing made them agree about the one value that connects them.

What it checks

Four calls mint order ids. Each is exercised only when capabilities/0 declares it active, and the ids it mints are collected:

  • place_order/3 must answer {:ok, %Types.Order{}} with a non-empty binary :id.
  • place_orders/3 must answer {:ok, list} with one entry per request. An entry that is a %Types.Order{} (bare, or as {:ok, order}) must carry a non-empty binary :id, which is collected. An entry that is a refusal ({:error, _} or {:refused, _}) is the venue refusing that one order — the callback documents the partial batch as the expected shape — and is not a round-trip failure. A bare map (venue JSON, which is all [map()] promises) names an id this check cannot read without guessing the key, so it is not asked about.
  • replace_order/4 is exercised on an order the check places for the purpose (needs place_order/3 active), and must answer {:ok, %Types.Order{}} with an id. The id the replace answered is collected; the order that was replaced is not, because a venue may legitimately have retired it.
  • close_position/3 answers an order like place_order/3 does. The suite opens no position, so a refusal ({:error, _} or {:refused, _}) is an answer DpExchange.Core.Venue.close_position/3's result/1 type allows, and the call is not applicable rather than failed. {:error, :not_supported} is not such an answer for an active endpoint, nor is any other shape.

Every distinct collected id is then offered to the fake, reads first, then cancels:

  1. If {:get_order, 3} is active, get_order/3 must answer {:ok, %Types.Order{}}.
  2. If {:cancel_order, 3} is active, cancel_order/3 must answer {:ok, %Types.Order{}} or {:ok, :cancelled} — the two shapes DpExchange.Core.Venue.cancel_order/3 allows.

All reads run before any cancel, so a fake that forgets an order on cancel is not blamed for a read.

What it deliberately does not do

  • It does not require the answered order to carry the placed id. A venue may key an order by a client id on the way in and its own id on the way out, and the contract does not say which. The id is checked for being accepted, not for being echoed.
  • It does not fail a venue that declares get_order/3 or cancel_order/3 :unsupported. Those are not asked at all; their {:error, :not_supported} is group 12's.
  • It does not run a minting call that is :unsupported. There is no id to carry. run/3 answers :skipped only when none of the four ran, and a caller reading that as a pass is right.
  • It does not exercise replace_order/4 without place_order/3. The suite has no order it could honestly say is open to replace; place_order/3 being :unsupported makes the replace not applicable here.
  • It does not read ids out of venue JSON. A place_orders/3 entry that is a bare map is skipped, not guessed at.
  • It does not prove the real venue behaves this way. It drives a fake. A fake that accepts its own ids says the fake is self-consistent, nothing more.

Fail closed

An active minting call that does not answer as above is not a skip. This check answers {:error, reason} and the caller fails, because treating a refusal as "nothing to round-trip" is how three packages once passed assertions that never ran on them (see docs/reference/core/assertion-coverage.md, "Second axis"). The one exception is close_position/3's refusal, for the reason above. The remedy is in the message the suite attaches: either the endpoint is not really active, or the fake needs an option the call did not carry — declare it in endpoint_opts:.

A raise or an exit from the fake is captured and reported as the answer; it does not escape and take the rest of the test with it.

Summary

Types

The arguments for each call. place_order, place_orders and close_position are full argument lists; replace_order, get_order and cancel_order are functions from an order id to the full argument list, so the id lands in whichever position the caller's own call shape puts it. Every key but place_order is optional; a missing key means that call is not made.

A follow-up the fake refused: the id, the call that minted it, and what was answered.

Functions

Renders run/3 violations as one line per refused follow-up, for a failure message.

Renders a run/3 error, for a failure message.

Mints order ids on fake and offers each to get_order/3 and cancel_order/3.

Types

calls()

@type calls() :: %{
  :place_order => [term()],
  :get_order => (String.t() -> [term()]),
  :cancel_order => (String.t() -> [term()]),
  optional(:place_orders) => [term()],
  optional(:replace_order) => (String.t() -> [term()]),
  optional(:close_position) => [term()]
}

The arguments for each call. place_order, place_orders and close_position are full argument lists; replace_order, get_order and cancel_order are functions from an order id to the full argument list, so the id lands in whichever position the caller's own call shape puts it. Every key but place_order is optional; a missing key means that call is not made.

violation()

@type violation() :: %{
  step: :get_order | :cancel_order,
  id: String.t(),
  minted_by: :place_order | :place_orders | :replace_order | :close_position,
  answered: term()
}

A follow-up the fake refused: the id, the call that minted it, and what was answered.

Functions

format(violations)

@spec format([violation()]) :: String.t()

Renders run/3 violations as one line per refused follow-up, for a failure message.

format_error(other)

@spec format_error(term()) :: String.t()

Renders a run/3 error, for a failure message.

run(fake, caps, calls)

@spec run(module(), DpExchange.Core.Capabilities.t(), calls()) ::
  :skipped | {:ok, [violation()]} | {:error, term()}

Mints order ids on fake and offers each to get_order/3 and cancel_order/3.

caps is the venue's Capabilities, read to decide which calls are active.

Returns :skipped when none of place_order/3, place_orders/3, replace_order/4 and close_position/3 could run, {:ok, violations} when at least one did ([] is a pass), and {:error, reason} when an active minting call did not answer as the contract requires.