Context (verified 2026-09-04, on main at the #717 merge)
#702's Balances view shipped reading what a cycle records. The one thing it could not show is the thing the issue was named for: settled versus total cash.
equity_points.cash (#698) is written from agent._mark_to_market_parts, which sums executor._fetch_available_quote across the currencies in play. That function reads Balance.available and stops there — its own docstring calls it the "live available balance". The venue's total is never read into the engine, and neither figure is stored per currency: the recorded cash is one number, summed across every currency the deployment touches.
So the T+1 clamp the Alpaca adapter applies (available clamped at settled cash) reaches equity_points as a single available figure with the distinction already flattened out of it, and the per-currency breakdown is gone too.
The Balances view therefore renders settled_cash and total_cash as absent, with UNRECORDED IN CYCLE SNAPSHOT beside them. That is honest and useless, in that order, and this issue is how it becomes useful.
keel serve must not read the venue to fix this. That was decided explicitly on #702: the web process is a loopback SQLite reader with no credentials and no outbound network, and the view re-polls every 15 seconds, so a live read would put credentials in a browser-facing process and spend an operator's rate limit per open tab. The fix belongs in the cycle, which already holds a broker handle and already runs once per interval.
Scope
- Record the balance rows the cycle already fetches.
_mark_to_market_parts calls get_balances() through _fetch_available_quote per currency and keeps only the summed available figure. Persist the pair, per currency, at the same point the cycle writes its equity_points row:
currency, available, total, and the ts of the reading
- one row per currency per cycle, or a per-currency table keyed on
(mode, currency) holding the newest — either shape works; the constraint is that available and total stay SEPARATE and stay PER CURRENCY.
- NULL means not observed, never zero. A venue that reports no
total, an adapter that raises, a currency with no account — all of those are absences. _fetch_available_quote already returns None on any failure so rail 13 fails closed; the recorded row must preserve that rather than writing a zero balance, which would read as an empty account.
- Paper writes nothing here. Paper has no settlement and no venue: a synthetic settled/unsettled split would be a fabricated distinction in the column the real one lives in. Same posture as
orders.submit_best_bid on paper rows.
Refusals
- No live read from
keel serve, ever. See above; this is the constraint that shapes the whole issue.
- No backfill. Every
equity_points row already written was produced without the pair being read, and stamping one now would manufacture the claim the columns exist to hold.
- No deriving
total from available plus open orders. That is a reconstruction, not an observation, and it would be wrong in exactly the T+1 window the distinction exists to show.
Then
gather_balances reads the newest per-currency row for the mode in force and fills settled_cash/total_cash; settled_breakdown_recorded flips to True and the view's UNRECORDED IN CYCLE SNAPSHOT note becomes the real pair. The report shape and the payload were built for that swap — no view change should be needed.
Acceptance
- A live cycle records
available and total per currency, with the reading's timestamp.
- A venue that reports no total records NULL, and the Balances view renders it absent rather than equal to available.
- Paper cycles record nothing here.
- Tests: NULL-means-unobserved, the per-currency split surviving a multi-currency deployment, and
gather_balances reading the newest row for the mode in force rather than across modes.
Split out of #702, where the distinction was found to be unrecorded rather than merely unexposed. Third of the same shape, after #715 (order provenance and idempotency) and #718 (attestation windows).
Context (verified 2026-09-04, on
mainat the #717 merge)#702's Balances view shipped reading what a cycle records. The one thing it could not show is the thing the issue was named for: settled versus total cash.
equity_points.cash(#698) is written fromagent._mark_to_market_parts, which sumsexecutor._fetch_available_quoteacross the currencies in play. That function readsBalance.availableand stops there — its own docstring calls it the "live available balance". The venue'stotalis never read into the engine, and neither figure is stored per currency: the recordedcashis one number, summed across every currency the deployment touches.So the T+1 clamp the Alpaca adapter applies (
availableclamped at settled cash) reachesequity_pointsas a single available figure with the distinction already flattened out of it, and the per-currency breakdown is gone too.The Balances view therefore renders
settled_cashandtotal_cashas absent, withUNRECORDED IN CYCLE SNAPSHOTbeside them. That is honest and useless, in that order, and this issue is how it becomes useful.keel servemust not read the venue to fix this. That was decided explicitly on #702: the web process is a loopback SQLite reader with no credentials and no outbound network, and the view re-polls every 15 seconds, so a live read would put credentials in a browser-facing process and spend an operator's rate limit per open tab. The fix belongs in the cycle, which already holds a broker handle and already runs once per interval.Scope
_mark_to_market_partscallsget_balances()through_fetch_available_quoteper currency and keeps only the summed available figure. Persist the pair, per currency, at the same point the cycle writes itsequity_pointsrow:currency,available,total, and thetsof the reading(mode, currency)holding the newest — either shape works; the constraint is thatavailableandtotalstay SEPARATE and stay PER CURRENCY.total, an adapter that raises, a currency with no account — all of those are absences._fetch_available_quotealready returnsNoneon any failure so rail 13 fails closed; the recorded row must preserve that rather than writing a zero balance, which would read as an empty account.orders.submit_best_bidon paper rows.Refusals
keel serve, ever. See above; this is the constraint that shapes the whole issue.equity_pointsrow already written was produced without the pair being read, and stamping one now would manufacture the claim the columns exist to hold.totalfromavailableplus open orders. That is a reconstruction, not an observation, and it would be wrong in exactly the T+1 window the distinction exists to show.Then
gather_balancesreads the newest per-currency row for the mode in force and fillssettled_cash/total_cash;settled_breakdown_recordedflips to True and the view'sUNRECORDED IN CYCLE SNAPSHOTnote becomes the real pair. The report shape and the payload were built for that swap — no view change should be needed.Acceptance
availableandtotalper currency, with the reading's timestamp.gather_balancesreading the newest row for the mode in force rather than across modes.Split out of #702, where the distinction was found to be unrecorded rather than merely unexposed. Third of the same shape, after #715 (order provenance and idempotency) and #718 (attestation windows).