Skip to content

feat(executor): the quote balance comes from the port's shape, and only available counts - #562

Merged
eaitbrahim merged 1 commit into
mainfrom
feat-524-balances
Aug 27, 2026
Merged

feat(executor): the quote balance comes from the port's shape, and only available counts#562
eaitbrahim merged 1 commit into
mainfrom
feat-524-balances

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

#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_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

Balance carries available and total, which the dict shape never distinguished. I
mutation-tested the change and found that swapping .available for .total passed the entire
suite
— 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, 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 None means. The
mutation 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. Balance can't express that, and it
shouldn't — in the port's model a currency with no account simply is not in the list.
_fetch_available_quote returns None either way, by falling off the loop rather than testing a
null field.

Verification

4298 passed / 3 skipped, ruff clean, mypy clean across 347 files. Both sides mutation-checked: a
wrong total in the client fails its test, and the .available/.total swap in the executor now
fails too.

#524 after this

Both named blockers are closed. What remains is the flip itself — _build_broker resolving through
load_broker/discover_brokers, and the executor placing OrderSpec values instead of raw dicts —
which is the part that rewrites every live order path and deletes _bracket_order_configuration,
the ConfirmFn dual arm, and the capabilities() grandfather clause.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2

…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
@eaitbrahim
eaitbrahim merged commit ce0df44 into main Aug 27, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the feat-524-balances branch August 27, 2026 08:52
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