# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Status: EXPERIMENTAL

Stated here rather than only per-release, because a reader arriving at a specific
version needs it as much as one reading the top.

This package has not run in production. While it is `0.x` the API may change without a
major version — pin three-part (`~> 0.1.0`). Coverage is uneven by design: fakes and
live public endpoints are well covered, order placement and authenticated flows are
not.

**Whenever an endpoint moves to `:proven`, the entry that does it states the evidence** —
which venue, what was run against it, and when. "Marked proven" with no evidence is not
an acceptable changelog line.

## [Unreleased]

### Added
- **`cancel_all_orders/2` on `Venue`.** Gemini publishes two bulk cancels and the family had
  no facade for either.

  **`opts[:scope]` is required and has no default.** `:session` cancels what this
  credential's session opened; `:account` cancels everything the account has open,
  including orders placed by another key or by a person at the venue's own web interface.
  A default would make the wider, destructive reading the answer to a question nobody
  asked, and the narrower one would silently leave orders running. The caller states it.

  It is not `get_orders/2` plus `cancel_order/3` in a loop: that is N requests with N
  partial outcomes and cannot reach an order that appeared between the listing and the
  cancels.

  Returns `%{cancelled: [id], rejected: [id]}`. **A non-empty `rejected` is not a failed
  call** — the venue answered and some orders were already gone.

- **`preview_replace/4` and `close_position/3` on `Venue`.** Both are Coinbase endpoints
  the family had no facade for, and both are the kind that cannot be assembled from the
  calls that already exist.

  **`preview_replace/4` is not `preview_order/3` with an order id.** The venue prices an
  amendment against the resting order's own state, including whatever of it has already
  filled. A caller who asks what a fresh order would cost is asking a different question
  and getting a different number. Without it the choice is committing to an irreversible
  amendment blind, or cancel-then-place — which reopens the window `replace_order/4`
  exists to close.

  **`close_position/3` is not `get_positions/1` plus `place_order/3`.** The size a caller
  computes is the size as of the caller's last read; the venue's is the size now. On a
  position that moved in between, the caller's arithmetic leaves a residue or overshoots
  into a position the other way. Only the venue flattens to exactly zero, which is why it
  returns an `Order` — it *is* an order, placed on the caller's behalf with a side and size
  the caller never states.

  Both are peripheral, both record which of the two tests they fail, and every venue that
  does not serve them returns `not_supported()` as before.

### Changed

- **`Types.Order`'s `side`, `order_type`, `quantity` and `status` admit `nil` in the
  typespec.** They always could in practice — a venue sending a status this package does
  not recognise has produced `nil` since the beginning — and the typespec said otherwise,
  which meant dialyzer accepted the wrong thing and rejected the right one.

  Coinbase's `close_position/3` is where it surfaced: the venue never states the side of a
  closing order, and the type left no way to say so. The keys stay enforced, so a
  constructor must still decide; the types now allow that decision to be "the venue did not
  say".

- **BREAKING: `Core.Types.Quote` no longer carries `:bid` and `:ask`.** They are order book
  data — resting orders — and `Quote` is trade data. Every venue package in the family was
  filling them, and one read `price || ask` from a best-bid/ask endpoint, producing a quote
  whose `price` was a resting order. Every value was real; only the meaning was wrong.

  A caller wanting the top of the book calls `get_top_of_book/2`. A caller wanting what
  traded calls `get_price/2`. Neither can stand in for the other.

- `Core.Types.Quote`'s `:timestamp` guarantee is unchanged and now load-bearing: **the
  venue's own, used as-is**. Observation time lives on `TopOfBook.observed_at`, in a field
  that says what it is.

### Added
- **Options.** `Types.OptionContract` (identity only — no prices), `Types.OptionGreeks`
  (model output, with the theoretical value named `:model_price` because it is the field
  most easily mistaken for a price), `Types.OptionChain` (**two-dimensional**, expiry →
  strike → `{call, put}`, a one-sided strike keeping `nil` rather than a missing key), and
  `Types.OrderLeg`. Callbacks `get_option_chain/2`, `get_option_expirations/2`,
  `get_option_greeks/2`.

  A chain row carrying bid, ask, last, mark and theoretical value offers five plausible
  prices and no help choosing, so it is split three ways: identity here, book on
  `TopOfBook`, last trade on `Quote`. **`:multiplier` of `nil` does not mean 100.** A venue
  that cannot trade multi-leg must **refuse**, never decompose — a caller left holding one
  filled leg has naked risk it never chose.

- **BREAKING: `get_historical_prices/4` returns `[Types.Candle.t()]`**, not
  `[Types.Quote.t()]`. It declared quotes, and the venue packages returned **bare untyped
  maps** with their own key sets — so the declared type was false and nothing compared one
  venue's candles to another's.

  `Types.Candle` names its time field **`:opened_at`**, because venues disagree about
  whether a bar is stamped at its open or its close and the difference is one whole
  interval — a series joined across both conventions is misaligned by a day with every
  value correct. `coherent?/1` catches a malformed bar at the boundary. `:volume` is `nil`
  when unpublished, never `0`.

- **`Types.Order` gains `:time_in_force` and `:legs`.** `Capabilities.supported_time_in_force`
  declared what a venue accepts while the order type had no field for it, so a caller
  reading an order back could not tell an IOC that expired from a GTC still working.
- **Derivatives.** `Types.Funding` (settled `:amount` kept apart from `:estimated_amount` —
  a real response has them 40% apart) and `Types.ContractStats` (mark and index are separate
  prices, and neither is a traded price), with `get_funding/2` and `get_contract_stats/2`.
- **Conversions.** `Types.Conversion` plus `quote_conversion/4`, `commit_conversion/2` and
  `get_conversion/2` — the facade's only two-step write. `:expires_at` is the point:
  committing an expired quote can fill at the *current* rate, which looks like success.
  `expired?/2` returns `nil` when no expiry was stated — unknown, not valid.
- **Portfolios.** `Types.Portfolio` and `list_portfolios/1`. A portfolio is an **address**,
  not a value; balances, orders and positions are addressed with `portfolio: id` in `opts`
  rather than by adding a parameter to forty signatures.
- **Money movement, write side.** `Types.DepositAddress`, `Types.ApprovedAddress`,
  `Types.Withdrawal`, and `get_deposit_address/3`, `list_approved_addresses/1`,
  `estimate_withdrawal_fee/4`, `withdraw/5`.

  **`withdraw/5` is the only operation in this contract that cannot be undone.** The
  allow-list is first-class: `ApprovedAddress.usable?/2` returns `nil` for a pending address
  with no stated activation, because venues delay first use precisely so a stolen account
  cannot add an address and drain it. `DepositAddress.memo_required` is **tri-state** — a
  deposit missing a required memo is credited to nobody, so `nil` must never be defaulted to
  `false`. `:network` is enforced on both.
- **`Core.Types.Position`** and **`get_positions/1`** — exposure, distinct from a balance and
  not derivable from one. `:side` is explicit and `:quantity` always positive, because
  venues disagree about how to say "short" and a guessed sign convention yields a position
  that is exactly backwards while every number stays plausible. Realised and unrealised P&L
  are separate and never summed. **`:liquidation_price` of `nil` means the venue did not
  say, not that the position is safe.**
- **`data_kind` gains `:top_of_book`, `:candles` and `:positions`.** Measured against
  Gemini's AsyncAPI and Schwab's Streamer service list: all three are streamed by a venue in
  the family and had no kind. `:top_of_book` is deliberately not `:order_book` — venues
  stream them on separate channels because one carries a level and the other a book.
  `t:data_kind/0` records the full channel-to-kind mapping so it can be checked rather than
  trusted.
- **Custodial staking.** Six callbacks — `get_staking_rates/1`, `get_staking_balances/1`,
  `get_staking_rewards/1`, `get_staking_history/1`, `stake/3`, `unstake/3` — and a
  `has_staking` capability flag, which earlier notes recorded as shipped and which did not
  exist.

  **Custodial only.** A venue that returns an *unsigned transaction* for the caller to sign
  and broadcast is doing something else, and one venue publishes both. A caller believing it
  had staked when it holds an unsigned transaction nobody signed is the most expensive form
  of this family's recurring failure.

  Four types, shaped by the venues' published schemas:
  - `Types.StakingBalance` — keeps `staked`, `available_to_trade` and
    `available_for_withdrawal` **apart**; a real response has the whole position redeemable
    and none of it tradable. `by_provider` is carried, not summed: a redemption is addressed
    to a provider.
  - `Types.StakingRate` — percentages only, `rate_pct` and `apy_pct` both named. One venue
    publishes basis points, a simple percentage and an APY for the same position;
    `bps_to_pct/1` lives here so the 100× conversion is done once.
  - `Types.StakingReward` — carries its accrual period and the rate *at accrual*.
  - `Types.StakingTransaction` — carries the unbonding progression `amount` /
    `amount_paid_so_far` / `amount_remaining`. **`settled?/1` returns `nil` when the venue
    reports no progress** — unknown, not complete.
- **`Core.Types.TopOfBook`** — best bid and ask, with **no `price` field**. `bid_size` and
  `ask_size` are optional (`nil` means *not published*, never zero); `venue_time` is the
  venue's own or `nil`, since several BBO endpoints publish none; `observed_at` is required.
  `mid/1`, `spread/1` and `crossed?/1` are functions, not fields — a mid is derived, and a
  caller has to ask for it rather than find it sitting there looking like venue data.
- **`get_top_of_book/2`** on the `Venue` behaviour, registered in `peripheral_endpoints/0`.
- **Conformance assertion 14, "top of book is not a price"** — asserts the returned struct
  is a `TopOfBook`, that `observed_at` is set, that `venue_time` is the venue's or `nil`,
  and that `TopOfBook` has no `price` field and cannot grow one.

### Changed
- **`preview_order/3` and `replace_order/4` are now `Venue` callbacks**, and required
  rather than optional. §6.1's rule is that the facade is one fixed set, never extended
  per venue, and optionality is reserved for callbacks where requiring them would be pure
  ceremony. These two are not: whether a venue can preview an order, and whether it can
  amend one atomically, are things a consumer routes on — and `replace_order/4` is a claim
  about **risk**, since its absence means cancel-then-place, which opens a window in which
  no order is live.

  **Not a breaking change, because there is nothing to break yet.** No consumer implements
  this behaviour outside the family, and all five venue packages were updated in the same
  change. A venue that serves neither returns `Venue.not_supported()` and declares
  `supports_order_preview: false` / `supports_order_replace: false`. Once the host adopts
  these packages, adding a required callback *would* be breaking and would take the
  `0.2.0` seed §7.2 describes — that signal is deliberately not spent here.

### Added
- **Five capability fields and two facade callbacks**, closing every contract gap Schwab
  found. Each existed because a venue could not say something true about itself.
  - `ceiling` gained an optional **`:scope`** (`:credential | :account | :application`),
    and `:limit` became `non_neg_integer`. Both matter: a limiter keyed by credential
    **silently over-permits** a venue that counts per account, and a registration granted
    zero throughput is legal and is **not** `:unsupported` — the endpoint exists and the
    venue serves it; that application cannot use it, and the remedies differ.
  - **`supported_sessions`** — which trading session an order may name. `[]` is the
    continuous-market case and stays the default. `[:regular]` alone **raises**: it says
    nothing, and a consumer would build a session selector with one option.
  - **`supports_order_preview`**, **`supports_order_replace`**, **`supports_multi_leg_orders`**
    — all raise if claimed while `place_order/3` is `:unsupported`.
  - **`catalog_access`** (`:enumerable | :query_only`) — whether the catalogue can be
    listed at all. `:query_only` raises if `get_symbols/1` is `:unsupported`, because
    "searchable only" and "not served at all" are different facts.
  - **`preview_order/3`** and **`replace_order/4`** as **required** facade callbacks.
    Required rather than optional: the facade is one fixed set, and optionality is for
    ceremony. Both are peripheral, and `replace_order/4`'s reason states the risk —
    absence means cancel-then-place, which works and opens a window with no order live.
- **Four order types**: `:trailing_stop`, `:trailing_stop_limit`, `:market_on_close`,
  `:limit_on_close`. Real types Schwab accepts that Core had no word for, so a venue
  serving them had to under-declare — the safe direction, and still a lie.
- **Eight instrument types**: `:option`, `:future`, `:future_option`, `:index`,
  `:mutual_fund`, `:bond`, `:forex`, `:cash_equivalent`. `[:spot, :perp]` was the whole
  vocabulary while every venue was crypto; an option is not a spot instrument, so an
  equities broker declared `[:spot]` plus a comment saying that understated it. **A
  declaration that needs a comment to be true is what this struct exists to prevent.**
- Two conformance assertions: the order-shape claims must match what the facade answers,
  and `catalog_access` must match how `get_symbols/1` behaves without a query.



## [0.1.11] - 2026-08-31

### Fixed
- **The conformance suite refused `1w` and `1M` too.** `Capabilities.validate_history!/1`
  was fixed in 0.1.10 to check `Timeframe.nameable/0`, but `AdapterContract`'s assertion 2
  still checked `known/0` — so a venue serving weekly or monthly candles built its
  declaration successfully and then **failed Core's own conformance suite**. That is the
  worse of the two failures: the package looks correct right up until the suite it exists
  to satisfy rejects it. Second site of one defect; found running the suite against Schwab.

## [0.1.10] - 2026-08-31

### Added
- `Timeframe.nameable/0` and `Timeframe.nameable?/1` — the widths Core can read as a
  **label**, which is deliberately wider than `known/0`, the widths it can **bucket**.
  `1w` and `1M` are nameable and have no boundary rule, and never will: a weekly bar's
  start depends on which weekday the venue begins its week, and a month is not a fixed
  number of seconds.
- `max_leverage` accepts **`:per_account`** — a positive statement that the venue margins
  and the ceiling belongs to the account rather than to the venue. Reg-T forced it: a
  Schwab margin account carries five different buying powers that are not multiples of one
  another, and a cash account at the same venue carries none of them, so no scalar is true.
  `nil` with `supports_margin: true` still raises, because `nil` means "nobody said" — and
  the error now names `:per_account`, so a venue author discovers the option instead of
  inventing a number. Without it the only ways to ship were to declare
  `supports_margin: false`, which is false, or to invent a multiplier.

### Fixed
- `Capabilities` no longer refuses a venue that serves weekly or monthly candles.
  `validate_history!/1` checked `historical_timeframes` against `Timeframe.known()`,
  which is the set Core can *bucket* — so declaring `1w` raised, even though
  `Timeframe` already documents both as deliberately unbucketable and instructs callers
  to read "no boundary rule" as "cannot check" rather than "invalid". Core contradicted
  itself: `aligned?/2` tolerates an unmodelled width, `boundary/2` passes it through,
  and `Capabilities` rejected it outright. A venue serving a real weekly candle had two
  options, under-declare or not ship. It now checks `Timeframe.nameable/0`; a width Core
  cannot name at all, such as `3m`, is still refused. Found deriving Schwab's
  declaration.
- `Timeframe` now models `10m` (600 seconds). Its absence was **not** neutral:
  `aligned?/2` returns `true` for a width it cannot model — "no rule" must not read as
  "invalid" — so every 10-minute candle passed the authenticity check unexamined, and
  `boundary/2` was a no-op on it. Found deriving Schwab's declaration, where
  `/pricehistory` serves 1, 5, 10, 15 and 30-minute widths. Unlike `1w` and `1M`, which
  are deliberately absent because their boundaries are not fixed, 600 seconds is not
  ambiguous and there was no reason to leave it unmodelled.

## [0.1.9] - 2026-08-28

### Fixed
- `HttpClient.request/5`'s spec no longer advertises `{:error, :rate_limited,
  retry_after: seconds}`. **It never returned it.** Both rate-limit paths convert to a
  two-element error before returning, each deliberately and for a recorded reason — a
  venue 429 because a three-element tuple reaching a two-element `case` crashed 152
  collector tasks in one night, and our own limiter's refusal because the two used to
  share wording and a self-inflicted throttle was read as a flaky venue for weeks. The
  spec was corrected rather than the behaviour. This is the fourth wrong-spec defect
  found by a venue package, and it does the same damage as the others: dialyzer reports
  a caller's correct handling of the advertised shape as unreachable dead code.

### Added
- `HttpClient` accepts `raw_status: true`, returning `{:ok, response}` for a 4xx instead
  of flattening status and body into a message string. The contract makes
  `{:refused, reason}` permanent and `{:error, reason}` possibly transient, and a venue
  states which in its 4xx body — Gemini names `InvalidSymbol`, `InvalidParameterValue`.
  Without this a venue package has to recover the distinction by string-matching, and
  `String.contains?(message, "404")` also matches a body that happens to contain "404".
  Opt-in, because the string form is what existing callers match on. 5xx is unaffected: a
  server error is not a venue's considered answer.
- `Capabilities` ceilings may now carry an optional `:burst` — the depth a venue lets a
  caller run ahead of its rate before queueing. Found by the Gemini extraction: a GCRA
  limiter takes three parameters and this type carried two, so a venue that **publishes**
  its burst depth had nowhere to declare it and the package had to hardcode the number
  beside the declaration — the exact drift the struct exists to prevent. Gemini is the
  first venue in the family to publish one ("a burst rate of five additional requests
  that are queued"). Optional rather than required, because a venue that publishes no
  burst must not be made to invent one, and absence is distinguishable from a declared
  value. A present `:burst` must be a positive integer; zero is a limiter that never
  lets anything through.
- Repo foundation: toolchain pin, `.gitignore`, formatter, credo, license, `mix.exs`,
  config layout, CI workflow, design-docs scaffolding.
