# `DpExchange.Core.Instrument`
[🔗](https://github.com/DistortionPoint/dp-exchange-core/blob/v0.3.120/lib/dp_exchange/core/instrument.ex#L1)

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.

# `instrument_type`

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

# `status`

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

# `t`

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

# `instrument_from`

```elixir
@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`

```elixir
@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`

```elixir
@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 `t:instrument_type/0` and `t: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`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
