Behaviour an exchange adapter implements so PaperEx can stay
exchange-agnostic.
The adapter is a translation layer, not a network client.
paper_ex does no IO; it never fetches a market or an order book.
Adapters turn exchange-specific data the caller already has into
normalized paper_ex structs (PaperEx.Instrument,
PaperEx.MarketSnapshot, PaperEx.Fill), and optionally provide an
exchange-specific fill simulator.
Required callbacks
normalize_instrument/1— take an exchange payload (whatever shape the adapter wants) and produce aPaperEx.Instrument.normalize_snapshot/2— take an exchange-specific order book snapshot and anInstrument, and produce aPaperEx.MarketSnapshotwhoseinstrument_idmatches.
Optional callbacks
normalize_fill/2— map an exchange-specific filled-trade payload onto aPaperEx.Fill. Use this when an adapter receives actual fills (e.g. for live-mirror reconciliation) rather than simulating its own.simulate_fill/3— simulate a fill for an order against a snapshot. Though listed in@optional_callbacks, it is effectively required for any adapter the engine simulates against: without itPaperEx.Engine.apply_order/4returns{:error, :adapter_does_not_simulate}. It is optional only for adapters that merely normalize/reconcile actual exchange fills (vianormalize_fill/2) and never ask the engine to simulate.fee/2— compute the fee for aFill. If absent the engine treats fee as0.reason_codes/0— return a bounded list of adapter-specific reason-code atoms. The engine never enforces this list; it's a documentation/analytics contract between adapter and consumer. SeePaperEx.ReasonCodesfor the generic engine codes that apply regardless of adapter.
Bounded reason codes
Reason codes — both engine ones from PaperEx.ReasonCodes and
adapter ones — must be short atoms or short strings. Long error
detail goes in PaperEx.Execution.metadata. This rule exists so
reason-code histograms remain useful for analytics and dashboards.
Implementing this behaviour
See test/support/paper_ex/mock_adapter.ex for a minimal
implementation used in tests. A real adapter — paper_ex_polymarket
— translates CLOB book snapshots and trade payloads into the
normalized structs.
An adapter that the engine simulates against must implement
simulate_fill/3; without it PaperEx.Engine.apply_order/4
returns {:error, :adapter_does_not_simulate}. simulate_fill/3
is listed under @optional_callbacks only so that normalize-only /
live-mirror-reconciliation adapters (which supply normalize_fill/2
instead and never simulate) can omit it.
Summary
Types
An adapter-specific payload of any shape the adapter chooses.
Callbacks
Compute the fee in quote currency for one Fill. The engine uses
this only if the adapter implements it; otherwise fee is 0.
Map an exchange-specific filled-trade payload onto a PaperEx.Fill.
Build a PaperEx.Instrument from exchange-specific data.
Build a PaperEx.MarketSnapshot from exchange-specific data plus
the already-normalized Instrument the snapshot belongs to.
Bounded list of adapter-specific reason-code atoms this adapter may emit. Documentation/analytics contract only — the engine does not validate.
Simulate a fill for order against snapshot, producing a list of
PaperEx.Fills and a status hint.
Types
@type exchange_payload() :: term()
An adapter-specific payload of any shape the adapter chooses.
Callbacks
@callback fee( PaperEx.Fill.t(), keyword() ) :: number()
Compute the fee in quote currency for one Fill. The engine uses
this only if the adapter implements it; otherwise fee is 0.
@callback normalize_fill(exchange_payload(), PaperEx.Instrument.t()) :: {:ok, PaperEx.Fill.t()} | {:error, term()}
Map an exchange-specific filled-trade payload onto a PaperEx.Fill.
Use this when the adapter is reconciling actual exchange fills (live mirror) rather than simulating its own. Optional.
@callback normalize_instrument(exchange_payload()) :: {:ok, PaperEx.Instrument.t()} | {:error, term()}
Build a PaperEx.Instrument from exchange-specific data.
@callback normalize_snapshot(exchange_payload(), PaperEx.Instrument.t()) :: {:ok, PaperEx.MarketSnapshot.t()} | {:error, term()}
Build a PaperEx.MarketSnapshot from exchange-specific data plus
the already-normalized Instrument the snapshot belongs to.
The adapter is responsible for sorting bids descending and asks
ascending; MarketSnapshot.new/1 does not re-sort.
@callback reason_codes() :: [atom()]
Bounded list of adapter-specific reason-code atoms this adapter may emit. Documentation/analytics contract only — the engine does not validate.
@callback simulate_fill(PaperEx.Order.t(), PaperEx.MarketSnapshot.t(), keyword()) :: {:ok, :filled | :partial, [PaperEx.Fill.t()]} | {:ok, :missed, atom() | String.t()} | {:error, term()}
Simulate a fill for order against snapshot, producing a list of
PaperEx.Fills and a status hint.
Return values:
{:ok, :filled, [Fill.t()]}— order filled (fully or per the adapter's policy). May be a single-element list for a single-level fill.{:ok, :partial, [Fill.t()]}— some quantity filled; remainder should be treated as missed or pending per engine config.{:ok, :missed, reason}— adapter chose not to fill.reasonmust be a bounded reason code (seePaperEx.ReasonCodesor the adapter's ownreason_codes/0).{:error, term()}— adapter failure; engine surfaces this as:adapter_rejected.
The opts keyword list carries engine-level config (price tolerance,
slippage budget, etc.). Adapters should ignore options they do not
understand.
Listed under @optional_callbacks, but effectively required for any
adapter the engine simulates against: if it is absent,
PaperEx.Engine.apply_order/4 returns
{:error, :adapter_does_not_simulate} rather than falling back to a
built-in simulator. Omit it only for a normalize-only adapter that
reconciles actual fills via normalize_fill/2.