Skip to content

feat(broker-api): a product-catalogue read, the first of #524's two blockers - #561

Merged
eaitbrahim merged 1 commit into
mainfrom
feat-524-port-gaps
Aug 26, 2026
Merged

feat(broker-api): a product-catalogue read, the first of #524's two blockers#561
eaitbrahim merged 1 commit into
mainfrom
feat-524-port-gaps

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

First of the two blockers #524 names. Touches no live behaviour — nothing calls
get_instrument yet; this gives the executor something to move onto.

The gap

executor._base_increment_for (#516) needs the finest base_size a venue will accept, and gets
it today by calling broker.list_products() and picking through raw dicts for product_id and
base_increment. That is the pre-port CoinbaseClient's shape. The port had no catalogue read
at all
, which is why the flip isn't a one-line change.

One product, not the catalogue

get_instrument(product_id) -> Instrument | None. The shape is the caller's own argument:
list_products returns ~900 rows on Coinbase and _base_increment_for caches exactly one per
miss, because Repository.set_state commits per call and caching all of them would mean ~900
fsyncs inside the order-placement path. Coinbase's transport has carried get_product all along.

None is an answer, not a failure — which is why this differs from get_order. An order id
was handed to the caller by this venue, so its absence is a real inconsistency. A product id
comes from an operator's allowlist and may simply not be listed here.

Instrument carries product_id and base_increment and nothing else. Quote granularity and
minimum sizes are the same class of fact and would sit here naturally, but nothing reads them, and
a field no caller reads is a field no test meaningfully checks. __post_init__ refuses a
non-positive increment: it is what a caller quantizes against, so a zero crossing the port is a
division error or a silent zero size on the exit path.

Five adapters, three different truths

adapter behaviour
coinbase get_product, unwrapping either envelope; None for missing/unparseable/zero/negative
robinhood get_trading_pairs(symbol) — the venue's min_order_size is this fact, and the port keeps keel's name for it rather than the venue's
fake a fixed increment, plus None for a reserved NOT-LISTED id so the conformance suite actually exercises the absent case
alpaca NotImplementedError
kraken the stub's standard refusal

Alpaca is the one worth reading twice. Alpaca has /v2/assets/{symbol} carrying exactly this;
this adapter's Transport protocol does not declare it. It raises rather than returning None
deliberately — None would tell the executor a symbol is unlisted when the truth is that nobody
wrote the read, which on the live path means silently skipping quantization for every equity.
Same distinction stage 1 of #502 drew about Alpaca's bracket support: "not written yet", not
"impossible".

Conformance

Two suite tests across all four registered adapters. No capability flag gates them, deliberately:
a product catalogue is not an optional venue feature, it is something every venue has and some
adapters have not been taught to read. NotImplementedError says which of those it is.

Mutation-checked — returning a raw venue dict fails the type assertion; a zero increment fails
before it can be returned at all.

Verification

4292 passed / 3 skipped, ruff clean, mypy clean across 347 files.

What's next on #524

The second blocker: _fetch_available_quote probes a dict-shaped get_accounts response while
the port returns list[Balance]. After that, the flip itself — _build_broker through
load_broker, and the executor placing OrderSpec values instead of raw dicts.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2

…lockers

#524 names two reasons the live path cannot move onto the port. This closes the
first and touches no live behaviour: nothing calls `get_instrument` yet.

── THE GAP ────────────────────────────────────────────────────────────────────

`executor._base_increment_for` (#516) needs the finest `base_size` a venue will
accept, and gets it today by calling `broker.list_products()` and picking through
raw dicts for `product_id` and `base_increment`. That is the pre-port
CoinbaseClient's shape. The port had no catalogue read at all, so there was
nothing for the executor to move ONTO.

── ONE PRODUCT, NOT THE CATALOGUE ─────────────────────────────────────────────

`get_instrument(product_id) -> Instrument | None`, and the shape is the caller's
own argument: `list_products` returns ~900 rows on Coinbase and
`_base_increment_for` caches exactly ONE per miss, because `Repository.set_state`
commits per call and caching all of them would mean ~900 fsyncs inside the
order-placement path. Coinbase's transport has carried `get_product` all along.

`None` means "this venue does not list that product" and is an ANSWER, not a
failure -- which is why this differs from `get_order`. An order id was handed to
the caller by this venue; a product id comes from an operator's allowlist and may
simply not be listed here.

`Instrument` carries `product_id` and `base_increment` and nothing else. Quote
granularity and minimum sizes are the same class of fact and would sit here
naturally, but nothing reads them, and a field no caller reads is a field no test
meaningfully checks.

`__post_init__` refuses a non-positive increment. It is what a caller quantizes
against, so a zero crossing the port is a division error or a silent zero size on
the exit path.

── FIVE ADAPTERS, THREE DIFFERENT TRUTHS ──────────────────────────────────────

  * coinbase -- `get_product`, unwrapping either envelope; `None` for missing,
    unparseable, zero or negative.
  * robinhood -- `get_trading_pairs(symbol)`; the venue's `min_order_size` IS
    this fact, and the port keeps keel's name for it rather than the venue's.
  * fake -- a fixed increment, and `None` for a reserved `NOT-LISTED` id so the
    conformance suite actually exercises the absent case.
  * alpaca -- `NotImplementedError`. Alpaca HAS `/v2/assets/{symbol}`; this
    adapter's Transport protocol does not declare it. Deliberately not `None`:
    `None` would tell the executor a symbol is unlisted when the truth is that
    nobody wrote the read, which on the live path means silently skipping
    quantization for every equity.
  * kraken -- the stub's standard refusal.

── CONFORMANCE ────────────────────────────────────────────────────────────────

Two suite tests, run against all four registered adapters. No capability flag
gates them, deliberately: a product catalogue is not an optional venue FEATURE,
it is something every venue has and some adapters have not been taught to read.
`NotImplementedError` says which of those it is.

Mutation-checked: returning a raw venue dict fails the type assertion, and a zero
increment fails before it can be returned at all.

Gates: 4292 passed / 3 skipped, ruff clean, mypy clean across 347 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2
@eaitbrahim
eaitbrahim merged commit e5b08b4 into main Aug 26, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the feat-524-port-gaps branch August 26, 2026 23:38
eaitbrahim added a commit that referenced this pull request Aug 27, 2026
…etching 900 products for one (#563)

#561 gave the port `get_instrument` and wired nothing to it. This wires it, which
was the point. Third additive step on #524 and, like the two before it, no live
behaviour changes: the same increments reach the same quantization by the same
cache.

── THE READ THE PORT WAS BUILT FOR ────────────────────────────────────────────

`_base_increment_for` called `broker.list_products()` -- about 900 rows on
Coinbase -- to use ONE field of ONE of them, then picked through raw dicts for
`product_id` and `base_increment`. That was the pre-port `CoinbaseClient`'s only
catalogue read.

`CoinbaseClient.get_instrument()` now answers the port's type too, the same bridge
`get_balances` is, so the executor asks one question whether it holds this client
or a real adapter. `list_products` stays exactly where it belongs: `keel assets
discover`, which genuinely wants the catalogue.

── A CACHING ARGUMENT THAT NO LONGER APPLIES ──────────────────────────────────

The old docstring defended writing one cache row per miss "even though the
response carries every product", because `Repository.set_state` commits per call
and caching all ~900 would mean ~900 fsyncs inside the order-placement path. The
argument was sound and the SHAPE was not: asking for one product removes the
temptation rather than resisting it, and there is no longer a catalogue to decline
to cache. The test that pinned it stays -- one venue answer must still write one
row -- with its reasoning updated rather than deleted.

── AND THE ADAPTER THAT REFUSES ───────────────────────────────────────────────

`keel-broker-alpaca` raises `NotImplementedError` from `get_instrument`,
deliberately (#561): `None` would tell a caller a symbol is unlisted when the
truth is that nobody wrote the read. On THIS path that must land as unknown, not
as a crash -- `_base_increment_for` never raises by contract, and an exit that
refused to place because a catalogue lookup was unimplemented would be a
protective order withheld over a missing convenience. Pinned.

Gates: 4304 passed / 3 skipped, ruff clean, mypy clean across 347 files.

── WHAT IS LEFT OF #524 ───────────────────────────────────────────────────────

Only order PLACEMENT. Every read the executor makes -- balances, increments --
now goes through port shapes and needs no further change at the flip. What remains
is `preview_order`/`place_order` taking `OrderSpec` instead of raw dicts, and
`_build_broker` resolving through `load_broker`. That one is not additive and
rewrites every live order path.


Claude-Session: https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant