feat(broker-api): a product-catalogue read, the first of #524's two blockers - #561
Merged
Conversation
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First of the two blockers #524 names. Touches no live behaviour — nothing calls
get_instrumentyet; this gives the executor something to move onto.The gap
executor._base_increment_for(#516) needs the finestbase_sizea venue will accept, and getsit today by calling
broker.list_products()and picking through raw dicts forproduct_idandbase_increment. That is the pre-portCoinbaseClient's shape. The port had no catalogue readat 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_productsreturns ~900 rows on Coinbase and_base_increment_forcaches exactly one permiss, because
Repository.set_statecommits per call and caching all of them would mean ~900fsyncs inside the order-placement path. Coinbase's transport has carried
get_productall along.Noneis an answer, not a failure — which is why this differs fromget_order. An order idwas 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.
Instrumentcarriesproduct_idandbase_incrementand nothing else. Quote granularity andminimum 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 anon-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
get_product, unwrapping either envelope;Nonefor missing/unparseable/zero/negativeget_trading_pairs(symbol)— the venue'smin_order_sizeis this fact, and the port keeps keel's name for it rather than the venue'sNonefor a reservedNOT-LISTEDid so the conformance suite actually exercises the absent caseNotImplementedErrorAlpaca is the one worth reading twice. Alpaca has
/v2/assets/{symbol}carrying exactly this;this adapter's
Transportprotocol does not declare it. It raises rather than returningNonedeliberately —
Nonewould tell the executor a symbol is unlisted when the truth is that nobodywrote 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.
NotImplementedErrorsays 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_quoteprobes a dict-shapedget_accountsresponse whilethe port returns
list[Balance]. After that, the flip itself —_build_brokerthroughload_broker, and the executor placingOrderSpecvalues instead of raw dicts.🤖 Generated with Claude Code
https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2