One listing as the VENUE describes it: canonical symbol, base, quote, instrument type and trading status.
Why this exists
get_symbols/1 returns [String.t()]. A venue adapter fetches the venue's rich
listing payload and then throws away everything except the symbol — Coinbase
discards base_currency_id, quote_currency_id, product_type and status
on the line that maps & &1["product_id"].
The pair catalog needs exactly those discarded fields. Recovering them by
parsing the symbol string back apart is what this design explicitly rejects:
an earlier draft proposed regex-matching a PERP suffix and got Gemini's
quote distribution measurably wrong, missing its GBP, EUR, SOL and FIL quotes
entirely. Gemini is the sharp case — its symbols have no separator, so
BTCUSDCPERP cannot be split into base and quote at all without the venue's
own fields.
So this is a separate callback rather than a change to get_symbols/1, whose
callers only want names and shouldn't pay for the extra requests (Gemini's
details are per-symbol: 347 calls).
Optional by design
list_instruments/1 is an OPTIONAL callback. Webull and Robinhood publish
single-quote -USD catalogs where base and quote are trivially derivable and
no non-spot instruments exist, so requiring an implementation there would be
ceremony. A consumer building a catalogue falls back to get_symbols/1 plus its
own derivation for those, and marks anything it cannot resolve :unknown —
surfaced for review, never guessed.
@enforce_keys guards presence, not nil
Same trap as every Core.Types.* struct — see DpExchange.Core.Types.Validate. A
symbol: nil is PRESENT, so struct!/2 alone would build it without complaint even
though the typespec below declares symbol: String.t(), never String.t() | nil — the
one field this whole module exists to attach base/quote/status/type to. new/1 runs
through Validate.new!/3, the same constructor every Core.Types.* module uses, so an
explicit nil fails exactly the way an absent key already did.
Summary
Functions
Normalise a venue's product-type string.
Normalise a venue's product-type string together with the venue's own statement of whether the contract expires.
Build an instrument from already-normalised fields.
Normalise a venue's listing-status string.
Types
@type instrument_type() :: :spot | :perp | :future | :unknown
@type status() :: :tradable | :delisted | :unknown
@type t() :: %DpExchange.Core.Instrument{ base: String.t() | nil, instrument: instrument_type(), quote: String.t() | nil, status: status(), symbol: String.t() }
Functions
@spec instrument_from(String.t() | nil) :: instrument_type()
Normalise a venue's product-type string.
Recognises the forms the four venues actually publish. Anything else is
:unknown — the whole point is that an unrecognised type is visible. That includes a
bare "future", which may be dated or perpetual: use instrument_from/2 with the
venue's expiry kind.
@spec instrument_from(String.t() | nil, String.t() | nil) :: instrument_type()
Normalise a venue's product-type string together with the venue's own statement of whether the contract expires.
expiry_kind is the venue's word, case-insensitive: "perpetual" gives :perp and
"expiring" gives :future, whatever type says, provided type is itself a
derivative ("future", "perp", "perpetual", "swap"). Anything else, including nil,
falls back to instrument_from/1, so "future" with no readable expiry kind is
:unknown, never a guess. A "spot" or unrecognised type is never turned into a
derivative by an expiry kind.
Build an instrument from already-normalised fields.
This does not normalise anything: pass the venue's own strings through
instrument_from/1 and status_from/1 first. Those two are conservative, so an
unrecognised product type is :unknown (excluded from collection and surfaced for
review), NOT :spot, and a venue that invents a new contract type is not silently
admitted into a spot-only fleet.
A field you leave out takes the struct default, and both defaults are :unknown. status
used to default to :tradable, so an instrument built without one — a venue path that
forgot to pass it — was reported tradable on no evidence at all, the substitution
status_from/1 exists to refuse. Pass status: whenever the venue states one.
Raises ArgumentError if :symbol is absent OR nil — see this module's
"@enforce_keys guards presence, not nil" section. It also raises for an :instrument
or :status outside the closed vocabularies in instrument_type/0 and status/0
(a raw venue string such as "online", or :tradeable, is a plausible value with no
meaning to a consumer matching the atoms); pass the venue's string through
instrument_from/1 and status_from/1 first. A :base or :quote that is present must be
a non-blank string: an atom or a number there built, and a catalogue matching on the
strings found no instrument for it.
Normalise a venue's listing-status string.
limit_only counts as tradable: the venue still matches orders, and treating
it as delisted would silently drop live books. closed / delisted / offline are the
terminal states. Anything else, trading_disabled included, is :unknown: a disabled
market may come back, and reading it as delisted would be a guess.