feat(executor): the quote balance comes from the port's shape, and only available counts - #562
Merged
Conversation
…ly `available` counts #524's second blocker. Like the first, this changes no live behaviour -- the same numbers reach rail 13 by the same path -- and it removes the dual-shape probe the issue lists as part of the payoff. ── ONE QUESTION, ONE ANSWER ─────────────────────────────────────────────────── `_fetch_available_quote` called `broker.get_accounts()` and then probed each row TWICE -- dict key or attribute, `currency` or `.currency`, `available_balance` or `.available_balance` -- because it did not know whether it held the pre-port `CoinbaseClient` or a port adapter. The port answers `list[Balance]`; the client answered venue-shaped dicts. `CoinbaseClient.get_balances()` now answers in the port's type as well, computing `total` as `available + hold` exactly as `keel_broker_coinbase.adapter` does -- Coinbase exposes no single "total" field, and two implementations of one word must not disagree while both exist. The executor asks one question and the fork is gone. When `_build_broker` finally resolves through `load_broker`, this path needs no further change. `get_accounts` stays for `keel assets holdings` (`cli.py:557`), which reads it through `gather_holdings`. Moving that is the flip's business, not this blocker's. ── A GAP THE MIGRATION EXPOSED, AND THE TEST THAT NOW HOLDS IT ──────────────── `Balance` carries `available` AND `total`, which the dict shape did not distinguish. Swapping `.available` for `.total` in `_fetch_available_quote` PASSED THE ENTIRE SUITE -- verified by making that change -- because every fake in the repository sets the two equal. They are not the same number. `available` is what the venue will let an order draw on; `total` includes funds on hold: settling proceeds, collateral behind a resting order. Reading `total` would let rail 13 pass an order the account cannot fund -- precisely the failure the rail exists to prevent -- and silently, because both are plausible balances. Pinned now with a balance whose figures differ (100 available of 1000 total), plus the case-insensitive currency match and the no-account-for-this-currency case that `None` means. The mutation that passed before fails now. Gates: 4298 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
6 tasks
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.
#524's second and last blocker. Like the first (#561), this changes no live behaviour — the
same numbers reach rail 13 by the same path — and it removes one of the dual-shape probes the
issue lists as the payoff.
One question, one answer
_fetch_available_quotecalledbroker.get_accounts()and then probed each row twice — dictkey or attribute,
currencyor.currency,available_balanceor.available_balance— becauseit did not know whether it held the pre-port
CoinbaseClientor a port adapter. The port answerslist[Balance]; the client answered venue-shaped dicts.CoinbaseClient.get_balances()now answers in the port's type as well, computingtotalasavailable + holdexactly askeel_broker_coinbase.adapterdoes — Coinbase exposes no single"total" field, and two implementations of one word must not disagree while both exist. The executor
asks one question and the fork is gone. When
_build_brokerfinally resolves throughload_broker,this path needs no further change.
get_accountsstays forkeel assets holdings(cli.py:557), which reads it throughgather_holdings. Moving that is the flip's business, not this blocker's.A gap the migration exposed
Balancecarriesavailableandtotal, which the dict shape never distinguished. Imutation-tested the change and found that swapping
.availablefor.totalpassed the entiresuite — because every fake in the repository sets the two equal.
They are not the same number.
availableis what the venue will let an order draw on;totalincludes funds on hold — settling proceeds, collateral behind a resting order. Reading
totalwouldlet rail 13 pass an order the account cannot fund, which is precisely the failure that rail
exists to prevent, and it would do it silently because both are plausible balances.
Now pinned with a balance whose figures differ (100 available of 1000 total), plus the
case-insensitive currency match and the no-account-for-this-currency case that
Nonemeans. Themutation that passed before fails now.
On the fakes
Converting them surfaced the design point worth recording: the old fakes represented "balance
unknown" as a row carrying
available_balance: None.Balancecan't express that, and itshouldn't — in the port's model a currency with no account simply is not in the list.
_fetch_available_quotereturnsNoneeither way, by falling off the loop rather than testing anull field.
Verification
4298 passed / 3 skipped, ruff clean, mypy clean across 347 files. Both sides mutation-checked: a
wrong
totalin the client fails its test, and the.available/.totalswap in the executor nowfails too.
#524 after this
Both named blockers are closed. What remains is the flip itself —
_build_brokerresolving throughload_broker/discover_brokers, and the executor placingOrderSpecvalues instead of raw dicts —which is the part that rewrites every live order path and deletes
_bracket_order_configuration,the
ConfirmFndual arm, and thecapabilities()grandfather clause.🤖 Generated with Claude Code
https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2