feat(web): the Balances view, from what a cycle recorded (#702) - #720
Merged
Conversation
#702 specified "a live adapter read". That was refused, deliberately and on the record. `keel serve` is a loopback reader over SQLite with no credentials, no broker handle and no outbound network -- `gather_status` says "no broker, no network", and even the Venues screen reads adapter declarations with "no broker handle, no network, no config, no credentials". A balances page is the obvious place to break that line, and breaking it would put venue credentials into the one process a browser can reach and hand an operator's rate limit to every tab left open on a view that re-polls every 15 seconds (`main.js`'s `POLL_MS`). Hundreds of REST calls an hour, during trading windows, for a number nobody asked to refresh. What replaced it is better rather than merely safer. Cash comes from `equity_points` (#698): the figure the agent READ AND SIZED AGAINST when it last evaluated the rails, stamped with when it read it. A fresher number the engine never saw explains nothing about why it did what it did. READ THROUGH THE MODE, ALWAYS. `equity_points` holds paper and live rows in one database, so the newest row FOR THE MODE IN FORCE is the answer -- the newest row of any mode would show a $10,000 synthetic balance on a live deployment holding $250. `paper_cash_usdc` is withheld entirely in live mode even though the key survives a paper->live flip: a synthetic balance beside real money is the most confusing thing this page could show. An unstamped mode, before the first cycle, yields no cash rather than a guess about which account to read. WHAT IS NOT RECORDED IS NAMED. #702 was written around settled-versus-total cash, and that distinction is not in the database: `equity_points.cash` comes from `_fetch_available_quote`, which reads `Balance.available` and stops, summed across every currency in play -- so the Alpaca adapter's T+1 clamp reaches storage with the distinction already flattened out of it. The tiles render as absent with `UNRECORDED IN CYCLE SNAPSHOT` beside them, rather than showing the available figure under a "settled" label that would answer the question this page exists to ask honestly. #719 records the pair; the report and payload were shaped so that lands as a value change, not a shape change. A PARTIAL SUM IS THE DANGEROUS SHAPE. An asset whose tranches are not all priced reports its QUANTITY and no value. Summing over the priced subset would look like a total and be a smaller one -- and a holding rendered as worth less than it is, on the page an operator checks to see what they have, is the failure worth engineering against. What is held is a fact; what it is worth was not observed. NO ACTION, ANYWHERE. #702's refusal, pinned on the payload AND on the view source: no buying power, no deposit, no withdraw, no transfer, not even an event listener. Cash is a fact, not an affordance, and keel is cash-spot by constitution -- a buying-power tile would advertise leverage the engine refuses to take. It is this page's version of the close button the Positions view also does not have. The per-asset rows fold `gather_positions` (#701) rather than re-reading the ledger, so Balances and Positions cannot come to disagree about what is held; every figure carries the instant it was recorded, including each asset's mark. One test-quality note worth keeping: the mode-partition test first passed against a report that ignored `mode` entirely, because its fixture made the live row the newest of the two. The fixture now makes the PAPER row newest, which is what makes the filter observable -- found by mutation, not by reading. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review findings on this branch. The first two are the ones worth reading; both are tests that passed for reasons other than the behaviour they name. THE MIXED-MARK GUARD COULD NOT FIRE, AND ITS TEST DID NOT REACH IT. `AssetBalanceRow` withholds a product's VALUE when any tranche of it lacks a mark, because a sum over the priced subset looks like a total and is a smaller one -- a holding rendered as worth less than it is, on the page an operator opens to see what they have. The test named for that seeded two different PRODUCTS rather than two tranches of one, so it never reached the branch: replacing `any(mark is None for mark in marks)` with `held[0].mark is None` left every test green. And the rule as written described a state that cannot occur. `gather_positions` reads the mark ONCE PER PRODUCT and hands every tranche of it the same figure, so `any(...)` is provably equivalent to the first row's mark -- through that caller. The guard is real but DEFENSIVE: what it protects is the fold, which a caller assembling rows from more than one read, or a mark cache that stopped being per-product, would reach immediately. Pinned now at `_assets_from` with hand-built rows, which is the only way to produce the state, and the prose says defensive instead of implying observed. THREE VIEWS HAD NO KEY-PARITY CHECK AT ALL. `balancesView` was missing from `_VIEW_ENDPOINTS` -- and so were `positionsView` and `ordersView`, from #701 and #700. Renaming `data.hwm` to `data.high_water_mark` in the balances view left the entire web suite green while the tile would have rendered blank forever, with nothing in the console naming the gap. That is precisely the failure `test_every_ported_view_reads_only_keys_its_ endpoint_sends` was written for. All three added; the scan finds 13, 4 and 11 keys respectively, so none of them is passing vacuously. THE PAGE PAID FOR A VERDICT IT NEVER SHOWS. Measured: three products cost 12 candle reads and a rules read per request -- four per product, three of them computing `entry_bar_ready` across every configured granularity -- on an endpoint the console re-polls every 15 seconds. Balances renders quantity, mark and value and no gate verdict. `gather_positions` now takes `with_readiness=False`, so the mark logic stays single-sourced rather than being copied here, and the skipped rows carry `ready=False, ready_reason=None` -- a pair `entry_bar_ready` never returns, so work that was skipped cannot be mistaken for a gate that refused. A DOCSTRING THAT OVER-CLAIMED. `mode` was described as "the partition every figure below is read through". True of cash, equity, unrealized, hwm and paper_cash; false of `assets`, because the `positions` table has no `mode` column. On a database that has flipped paper->live this page shows live cash beside holdings that may predate the flip. The docstring now says so and names the engine change that would fix it, rather than implying this report could infer it after the fact. Smaller: the "marked at" column declared `key: "mark_as_of"`, which `/api/balances` does not list as sortable -- a header that looks clickable and is not; product ordering and the per-product mark source are pinned rather than only argued (both mutations passed before); and the payload case where a cycle DID record a reading whose split was `None` -- `recorded` true and `cash` absent together, the distinction `has_recorded_cash` exists to draw -- is now covered. 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.
#702 specified "a live adapter read". That was refused, deliberately and on the record.
keel serveis a loopback reader over SQLite with no credentials, no broker handle and no outbound network --gather_statussays "no broker, no network", and even the Venues screen reads adapter declarations with "no broker handle, no network, no config, no credentials". A balances page is the obvious place to break that line, and breaking it would put venue credentials into the one process a browser can reach and hand an operator's rate limit to every tab left open on a view that re-polls every 15 seconds (main.js'sPOLL_MS). Hundreds of REST calls an hour, during trading windows, for a number nobody asked to refresh.What replaced it is better rather than merely safer. Cash comes from
equity_points(#698): the figure the agent READ AND SIZED AGAINST when it last evaluated the rails, stamped with when it read it. A fresher number the engine never saw explains nothing about why it did what it did.READ THROUGH THE MODE, ALWAYS.
equity_pointsholds paper and live rows in one database, so the newest row FOR THE MODE IN FORCE is the answer -- the newest row of any mode would show a $10,000 synthetic balance on a live deployment holding $250.paper_cash_usdcis withheld entirely in live mode even though the key survives a paper->live flip: a synthetic balance beside real money is the most confusing thing this page could show. An unstamped mode, before the first cycle, yields no cash rather than a guess about which account to read.WHAT IS NOT RECORDED IS NAMED. #702 was written around settled-versus-total cash, and that distinction is not in the database:
equity_points.cashcomes from_fetch_available_quote, which readsBalance.availableand stops, summed across every currency in play -- so the Alpaca adapter's T+1 clamp reaches storage with the distinction already flattened out of it. The tiles render as absent withUNRECORDED IN CYCLE SNAPSHOTbeside them, rather than showing the available figure under a "settled" label that would answer the question this page exists to ask honestly. #719 records the pair; the report and payload were shaped so that lands as a value change, not a shape change.A PARTIAL SUM IS THE DANGEROUS SHAPE. An asset whose tranches are not all priced reports its QUANTITY and no value. Summing over the priced subset would look like a total and be a smaller one -- and a holding rendered as worth less than it is, on the page an operator checks to see what they have, is the failure worth engineering against. What is held is a fact; what it is worth was not observed.
NO ACTION, ANYWHERE. #702's refusal, pinned on the payload AND on the view source: no buying power, no deposit, no withdraw, no transfer, not even an event listener. Cash is a fact, not an affordance, and keel is cash-spot by constitution -- a buying-power tile would advertise leverage the engine refuses to take. It is this page's version of the close button the Positions view also does not have.
The per-asset rows fold
gather_positions(#701) rather than re-reading the ledger, so Balances and Positions cannot come to disagree about what is held; every figure carries the instant it was recorded, including each asset's mark.One test-quality note worth keeping: the mode-partition test first passed against a report that ignored
modeentirely, because its fixture made the live row the newest of the two. The fixture now makes the PAPER row newest, which is what makes the filter observable -- found by mutation, not by reading.What & why
Tests-first evidence
Gates (all must pass)
uv run ruff checkcleanuv run mypycleanuv run pytest -qgreenScope check
leave checked only if true, and if so: cite the source and open the discussion
BEFORE review (CONTRIBUTING.md, "Governance: rulings vs. machinery").