DpExchange.Core.Instrument (DpExchangeCore v0.3.120)

Copy Markdown View Source

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

instrument_type()

@type instrument_type() :: :spot | :perp | :future | :unknown

status()

@type status() :: :tradable | :delisted | :unknown

t()

@type t() :: %DpExchange.Core.Instrument{
  base: String.t() | nil,
  instrument: instrument_type(),
  quote: String.t() | nil,
  status: status(),
  symbol: String.t()
}

Functions

instrument_from(type)

@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.

instrument_from(type, 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.

new(fields)

@spec new(keyword()) :: t()

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.

status_from(status)

@spec status_from(String.t() | nil) :: status()

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.