Skip to content

[engine] Persist the per-currency settled/available/total balance pair into the cycle snapshot #719

Description

@eaitbrahim

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

  1. 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.
  2. 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.
  3. 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureNew capability (groups under Features)pythonPull requests that update python code

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions