diff --git a/keel/commands/balances.py b/keel/commands/balances.py
new file mode 100644
index 0000000..7af680d
--- /dev/null
+++ b/keel/commands/balances.py
@@ -0,0 +1,201 @@
+"""What the account holds, as the last cycle recorded it -- issue #702.
+
+**NO BROKER CALL, AND THAT IS THE DESIGN.** A balances page is the obvious place to reach for a
+live venue read, and this module deliberately does not. `keel serve`'s defining property is that
+it is a loopback reader over SQLite with no credentials, no broker handle and no outbound
+network: putting a venue read behind a page that re-polls every 15 seconds (`main.js`'s
+`POLL_MS`) would hand an operator's rate limit to every browser tab left open, and would put
+credentials into the one process a browser can reach. Every other read route already holds that
+line (`gather_status`: "no broker, no network"; `list_installed_brokers`: "no broker handle, no
+network, no config, no credentials"), and a balances view is not the place to break it.
+
+WHAT IS SHOWN INSTEAD IS BETTER, NOT MERELY SAFER. Cash comes from `equity_points` (#698) --
+the figure the agent read and SIZED AGAINST when it evaluated the rails that cycle -- stamped
+with when it read it. A fresher number the engine never saw would explain nothing about why it
+did what it did.
+
+WHAT IS NOT RECORDED IS SAID, NOT GUESSED. `equity_points.cash` comes from
+`executor._fetch_available_quote`, which reads `Balance.available` and stops there; the venue's
+settled-versus-total pair is never written down. This report carries that absence explicitly
+rather than presenting the available figure under a label implying the distinction was checked
+(see `settled_breakdown_recorded`). When a cycle records the pair, this becomes a read.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+from decimal import Decimal
+
+from keel.commands.positions import PositionRow, gather_positions
+from keel.config import Config
+from keel.data.repository import Repository
+
+
+@dataclass(frozen=True)
+class AssetBalanceRow:
+ """One PRODUCT's holding, summed across its tranches.
+
+ Per asset, because that is the question a balances page answers. The tranche breakdown is
+ the Positions view's, and both read `gather_positions` so the two cannot disagree about what
+ is held.
+ """
+
+ product_id: str
+
+ #: Quantity still held, summed over every open tranche of this product.
+ qty: Decimal
+
+ #: The mark those tranches were valued at, and when it was read. `None` when the product has
+ #: no cached candle -- the same absence `PositionRow.mark` carries, for the same reason.
+ mark: Decimal | None
+ mark_as_of: int | None
+
+ #: `qty * mark`, or `None` if ANY tranche of this product lacks a mark.
+ #:
+ #: A partial sum is the most dangerous shape available here: it looks like a total and is not
+ #: one, so a holding half of which could not be priced would render as a SMALLER holding
+ #: rather than an unknown one. Unknown is the only reading that cannot be misread.
+ #:
+ #: Through `gather_positions` that state cannot arise -- it reads the mark once per product
+ #: and hands every tranche of it the same figure -- so the guard in `_assets_from` is
+ #: DEFENSIVE, not a description of something observed. It is kept, and pinned at the fold
+ #: level rather than through `gather_balances`, because what it protects is the FOLD: a
+ #: caller assembling rows from more than one read, or a mark cache that stops being
+ #: per-product, reaches it immediately.
+ market_value: Decimal | None
+
+
+@dataclass(frozen=True)
+class BalancesReport:
+ now_ts: int
+
+ #: `paper`, `live`, or `""` before the first cycle stamps one.
+ #:
+ #: The partition the CASH BLOCK is read through -- `equity_points` holds both modes in one
+ #: database, so cash, equity, unrealized, hwm and paper_cash are all selected by it.
+ #:
+ #: **It does NOT partition `assets`.** The `positions` table has no `mode` column: a tranche
+ #: is a tranche, whichever mode opened it. On a database that has flipped paper->live (which
+ #: `agent._clear_live_mode_if_needed` exists to handle) this page therefore shows live cash
+ #: beside holdings that may predate the flip. Recording a mode per tranche is the fix, and it
+ #: is an engine change, not something this report can infer after the fact.
+ mode: str
+
+ #: The newest recorded reading FOR THAT MODE, and the instant it was recorded. `cash` is
+ #: `None` when nothing has been recorded, and also when the recorded cycle knew its total
+ #: but not its split -- both are absences, never zero.
+ cash: Decimal | None
+ cash_as_of: int | None
+ equity: Decimal | None
+ unrealized: Decimal | None
+ hwm: Decimal | None
+
+ #: Whether ANY reading exists for this mode. Distinct from `cash is None`: a deployment that
+ #: has never completed a cycle and one whose last cycle could not read a split are different
+ #: facts, and only the first is "this page has nothing to show yet".
+ has_recorded_cash: bool
+
+ #: The synthetic account's CURRENT cash, in paper mode only (`agent_state`'s
+ #: `paper_cash_usdc`). It moves on every paper fill, so it answers "what does the paper
+ #: account hold now" beside `cash`'s "what did the cycle act on". `None` in live mode even
+ #: though the key survives a paper->live flip: a synthetic balance beside real money would be
+ #: the most confusing thing this page could show.
+ paper_cash: Decimal | None
+
+ #: The venue's settled/total split. Both `None` and `settled_breakdown_recorded` False,
+ #: always, today -- see the module docstring. They are fields rather than an omission so the
+ #: page can SAY the distinction is unrecorded, and so that recording it later is a change to
+ #: the producer rather than to this shape.
+ settled_cash: Decimal | None
+ total_cash: Decimal | None
+ settled_breakdown_recorded: bool
+
+ assets: tuple[AssetBalanceRow, ...]
+
+ @property
+ def asset_count(self) -> int:
+ """How many products this report holds. Derived, and held here rather than measured by a
+ renderer: Rule 6e bans `len()` in `keel/web/payload.py`."""
+ return len(self.assets)
+
+
+def gather_balances(repo: Repository, config: Config, *, now_ts: int) -> BalancesReport:
+ """Everything the account holds, from what a cycle wrote down. No broker, no network.
+
+ The mode is read FIRST and everything else is read through it. `equity_state_mode` is the
+ same stamp `agent._clear_live_mode_if_needed` maintains, and an unstamped one (before the
+ first cycle) yields no cash at all rather than a guess about which account to show.
+ """
+ mode = str(repo.get_state("equity_state_mode") or "")
+
+ reading = None
+ if mode:
+ recorded = repo.get_equity_points(mode=mode, limit=1)
+ # `limit=1` keeps the MOST RECENT reading (`get_equity_points`' own contract), so this is
+ # one row off an index rather than the whole series read to take its last element.
+ reading = recorded[-1] if recorded else None
+
+ # `with_readiness=False`: this page shows quantity, mark and value and never the entry
+ # gate, so computing one would be three of every four candle reads plus a rules read and
+ # a rule construction, per request, on a view the console re-polls every 15 seconds.
+ positions = gather_positions(repo, config, now_ts=now_ts, with_readiness=False)
+ return BalancesReport(
+ now_ts=now_ts,
+ mode=mode,
+ cash=None if reading is None else reading.cash,
+ cash_as_of=None if reading is None else reading.ts,
+ equity=None if reading is None else reading.equity,
+ unrealized=None if reading is None else reading.unrealized,
+ hwm=None if reading is None else reading.hwm,
+ has_recorded_cash=reading is not None,
+ paper_cash=repo.get_state("paper_cash_usdc") if mode == "paper" else None,
+ settled_cash=None,
+ total_cash=None,
+ settled_breakdown_recorded=False,
+ assets=_assets_from(positions.rows, positions.products),
+ )
+
+
+def _assets_from(
+ rows: Sequence[PositionRow], products: tuple[str, ...]
+) -> tuple[AssetBalanceRow, ...]:
+ """Fold the per-tranche rows into one row per product, in the report's own product order.
+
+ `products` comes from `PositionsReport`, not from a set built here: two answers to "which
+ products does this book hold" is one too many, and a set would reorder the page between
+ reads for no reason a reader could see.
+ """
+ by_product: dict[str, list[PositionRow]] = {product: [] for product in products}
+ for row in rows:
+ by_product.setdefault(row.product_id, []).append(row)
+
+ assets: list[AssetBalanceRow] = []
+ for product in products:
+ held = by_product.get(product) or []
+ if not held:
+ continue
+ qty = sum((row.qty for row in held), Decimal("0"))
+ marks = [row.mark for row in held]
+ # ANY missing mark makes the VALUE unknown -- never a sum over the priced subset. The
+ # quantity is still known and still shown: what is held is a fact, what it is worth is
+ # the part nobody observed. Defensive against a caller whose rows do not share one mark
+ # per product; `gather_positions` does, so this cannot fire through it (see
+ # `AssetBalanceRow.market_value`).
+ if any(mark is None for mark in marks):
+ value: Decimal | None = None
+ else:
+ value = sum((row.market_value or Decimal("0") for row in held), Decimal("0"))
+ # Any tranche will do for the mark: `gather_positions` reads it ONCE PER PRODUCT and
+ # hands the same figure to every tranche of it, so "the first" and "the newest" are the
+ # same row here. Picking a maximum would imply they could differ.
+ assets.append(
+ AssetBalanceRow(
+ product_id=product,
+ qty=qty,
+ mark=held[0].mark,
+ mark_as_of=held[0].mark_ts,
+ market_value=value,
+ )
+ )
+ return tuple(assets)
diff --git a/keel/commands/positions.py b/keel/commands/positions.py
index 8c60717..bee59c3 100644
--- a/keel/commands/positions.py
+++ b/keel/commands/positions.py
@@ -256,7 +256,9 @@ def _granularity_rank(granularity: Granularity) -> int:
return agent_mod._GRANULARITY_ORDER.get(granularity, 0)
-def gather_positions(repo: Repository, config: Config, *, now_ts: int) -> PositionsReport:
+def gather_positions(
+ repo: Repository, config: Config, *, now_ts: int, with_readiness: bool = True
+) -> PositionsReport:
"""Every OPEN tranche, marked and judged.
Open only: a closed tranche is a `trade_outcomes` row and belongs to the journal, which
@@ -266,14 +268,23 @@ def gather_positions(repo: Repository, config: Config, *, now_ts: int) -> Positi
The candle cache is read ONCE PER PRODUCT rather than once per tranche -- a book can hold
several tranches of the same product (that is what tranches are for), and a per-row read
would be one query per tranche for one answer they all share.
+
+ `with_readiness=False` skips the entry-gate verdict entirely: no rules read, no rule built,
+ and no `entry_bar_ready` call -- which on the default three-granularity config is three of
+ the four candle reads this function makes per product. It exists for `gather_balances`
+ (#702), which renders quantity, mark and value and never shows a gate verdict, on an
+ endpoint the console re-polls every 15 seconds. Rows then carry `ready=False` with
+ `ready_reason=None`: NOT COMPUTED, and deliberately not the shape of any real verdict --
+ `entry_bar_ready` never returns `(False, None)`, so a caller that skipped the work cannot
+ have its rows mistaken for a product the gate refused.
"""
mark_granularity = agent_mod._finest_granularity(list(config.market_data.granularities))
# ONE read of the rules table and ONE build per rule, before the row loop -- see
# `_gate_granularities`. A lookup per tranche would be one query and one constructor call per
# row for an answer the rows share, and would be invisible on any fixture small enough to
- # read.
- gates = _gate_granularities(repo, config)
- fallback = _fallback_granularity(config)
+ # read. Skipped entirely when the caller does not render a verdict.
+ gates = _gate_granularities(repo, config) if with_readiness else {}
+ fallback = _fallback_granularity(config) if with_readiness else None
marks: dict[str, tuple[Decimal | None, int | None]] = {}
# Keyed on (product, gate granularity), NOT on product alone: one product can hold tranches
@@ -287,12 +298,15 @@ def gather_positions(repo: Repository, config: Config, *, now_ts: int) -> Positi
marks[product_id] = _mark_for(repo, product_id, mark_granularity)
# `.get(...) or fallback` collapses the two unresolvable cases onto one answer: a name
# nothing matches, and a name two rules answer to with different granularities.
- gate = gates.get(str(raw.get("rule_name") or "")) or fallback
- key = (product_id, gate)
- if key not in readiness:
- readiness[key] = _readiness_for(repo, product_id, gate, config, now_ts)
+ if with_readiness:
+ gate = gates.get(str(raw.get("rule_name") or "")) or fallback
+ key = (product_id, gate)
+ if key not in readiness:
+ readiness[key] = _readiness_for(repo, product_id, gate, config, now_ts)
+ ready, ready_reason = readiness[key]
+ else:
+ ready, ready_reason = False, None
mark, mark_ts = marks[product_id]
- ready, ready_reason = readiness[key]
rows.append(_row_from_dict(raw, mark, mark_ts, ready, ready_reason))
return PositionsReport(now_ts=now_ts, rows=tuple(rows))
diff --git a/keel/web/api.py b/keel/web/api.py
index 845adfd..c611268 100644
--- a/keel/web/api.py
+++ b/keel/web/api.py
@@ -331,6 +331,33 @@ def read_positions(cfg: ServeConfig, _query: Query, _state: Any, now_ts: int) ->
return payload.positions_payload(report)
+def read_balances(cfg: ServeConfig, _query: Query, _state: Any, now_ts: int) -> dict[str, Any]:
+ """What the account holds, as the last cycle recorded it (#702).
+
+ **NO BROKER CALL, and this route is the one where that had to be decided on purpose.** A
+ balances page is the obvious place to read the venue live, and doing so would put credentials
+ into the process a browser talks to and hand an operator's rate limit to every tab left open
+ on a view that re-polls every 15 seconds. `keel serve` is a loopback reader over SQLite; every
+ other route here holds that line, and this one does too. `keel/commands/balances.py` carries
+ the full reasoning.
+
+ READ ONLY, with no write route on this path and no action in the payload. #702's refusal:
+ cash is a fact, not an affordance -- no buying power, no deposit, no transfer.
+
+ No `?limit=`: an account's asset list is bounded by what it holds, and a cap here would hide
+ a holding an operator is looking for.
+ """
+ from keel.commands.balances import gather_balances
+
+ repo = open_repo(cfg.db_path)
+ try:
+ config = load_config(cfg.config_path)
+ report = gather_balances(repo, config, now_ts=now_ts)
+ finally:
+ close_repo(repo)
+ return payload.balances_payload(report)
+
+
def read_insights(cfg: ServeConfig, _query: Query, _state: Any, now_ts: int) -> dict[str, Any]:
"""The per-rule track records, the promotion-gate distances, and the account-equity series.
@@ -602,6 +629,12 @@ class ApiRoute:
"stop_distance_pct",
),
),
+ "/api/balances": ApiRoute(
+ html_route="/balances",
+ read=read_balances,
+ collection="assets",
+ sortable=("product_id", "qty", "mark", "market_value"),
+ ),
"/api/rules": ApiRoute(
html_route="/rules",
read=read_rules,
diff --git a/keel/web/payload.py b/keel/web/payload.py
index 1420fc3..600ca23 100644
--- a/keel/web/payload.py
+++ b/keel/web/payload.py
@@ -120,6 +120,7 @@
if TYPE_CHECKING: # pragma: no cover - typing only
from keel.commands.activity import ActivityCycle, ActivityEvent, ActivityFeed
+ from keel.commands.balances import AssetBalanceRow, BalancesReport
from keel.commands.insights import (
AccountSummary,
EquityCurve,
@@ -1638,6 +1639,83 @@ def positions_payload(report: PositionsReport) -> dict[str, Any]:
}
+#: What the settled/unsettled split says while nothing records it (#702). A FIXED sentence, not
+#: a figure: `equity_points.cash` is `Balance.available` and only that, so the distinction is not
+#: a number this deployment has. Rendering the available figure under a "settled" label would
+#: answer the question the page exists to ask honestly.
+_SETTLED_UNRECORDED = "UNRECORDED IN CYCLE SNAPSHOT -- only the available figure is written down"
+
+
+def _asset_balance_payload(row: AssetBalanceRow) -> dict[str, Any]:
+ """One product's holding.
+
+ `qty` without a `market_value` is a real and common row: the holding is recorded, the price
+ was not observed. It is never a zero -- a worthless holding and an unpriced one look the same
+ on a page and are not the same fact.
+ """
+ return {
+ "product_id": row.product_id,
+ "qty": quantity(row.qty),
+ "mark": money(row.mark),
+ "mark_as_of": moment(row.mark_as_of),
+ "market_value": money(row.market_value),
+ }
+
+
+def balances_payload(report: BalancesReport) -> dict[str, Any]:
+ """`gather_balances`'s `BalancesReport`, as JSON (#702).
+
+ **Every figure here was recorded by a cycle, and every one carries when.** `keel serve` makes
+ no network call -- see `keel/commands/balances.py` for why that is the design and not a
+ limitation -- so the as-of stamps are what keep a recorded page honest rather than merely
+ stale-looking. A tile with no time on it is a claim about now that was made at some other now.
+
+ **No buying power, no deposit, no transfer, and no action of any kind.** #702's refusal:
+ cash is a fact, not an affordance, and this codebase is cash-spot by constitution
+ (`CashAccountRequired`, #372) -- a "buying power" figure would invite exactly the leverage the
+ engine refuses to take.
+
+ `settled_cash` and `total_cash` cross as ABSENT with `settled_breakdown` saying why, rather
+ than being omitted: a client that had to notice a missing key would be inferring from payload
+ shape, and the day a cycle records the pair this becomes a value change rather than a shape
+ change.
+ """
+ return {
+ "as_of": iso(report.now_ts),
+ "generated_at": moment(report.now_ts),
+ # A bare string, like `scope` and `mode` elsewhere: an enum word with no precision hazard
+ # and no judgement of its own.
+ "mode": report.mode,
+ "cash": money(report.cash),
+ "cash_as_of": moment(report.cash_as_of),
+ "equity": money(report.equity),
+ "unrealized": money(report.unrealized, signed=True),
+ "hwm": money(report.hwm),
+ "paper_cash": money(report.paper_cash),
+ "settled_cash": money(report.settled_cash),
+ "total_cash": money(report.total_cash),
+ "settled_breakdown": flag(
+ report.settled_breakdown_recorded,
+ on="settled and unsettled recorded",
+ off=_SETTLED_UNRECORDED,
+ on_state=NEUTRAL,
+ # UNKNOWN and not WARN: nothing is wrong, and nothing is late. The venue reports the
+ # split and no cycle writes it down, which is a gap in what keel records rather than
+ # a condition an operator can act on.
+ off_state=UNKNOWN,
+ ),
+ "recorded": flag(
+ report.has_recorded_cash,
+ on="as recorded by the last cycle",
+ off="no cycle has recorded a balance yet",
+ on_state=NEUTRAL,
+ off_state=UNKNOWN,
+ ),
+ "asset_count": count(report.asset_count),
+ "assets": [_asset_balance_payload(row) for row in report.assets],
+ }
+
+
# -- the envelope (#534) -------------------------------------------------------------------------
#
# Every `GET /api/*` success is wrapped in the same four keys, so #536's single `fetch` wrapper
diff --git a/keel/web/static/index.html b/keel/web/static/index.html
index 0aac406..b759e04 100644
--- a/keel/web/static/index.html
+++ b/keel/web/static/index.html
@@ -119,6 +119,7 @@
Activity
Orders
Positions
+ Balances
Insights
Rules
Venues
diff --git a/keel/web/static/js/main.js b/keel/web/static/js/main.js
index 123975e..752271a 100644
--- a/keel/web/static/js/main.js
+++ b/keel/web/static/js/main.js
@@ -45,6 +45,7 @@ import {
insightsView,
modeBadge,
ordersView,
+ balancesView,
positionsView,
refusedView,
rulesView,
@@ -99,6 +100,7 @@ const ROUTES = [
{ name: "activity", label: "Activity", endpoints: ["activity"] },
{ name: "orders", label: "Orders", endpoints: ["orders"] },
{ name: "positions", label: "Positions", endpoints: ["positions"] },
+ { name: "balances", label: "Balances", endpoints: ["balances"] },
{ name: "insights", label: "Insights", endpoints: ["insights", "journal"] },
{ name: "rules", label: "Rules", endpoints: ["rules"] },
{ name: "venues", label: "Venues", endpoints: ["venues"] },
@@ -427,6 +429,7 @@ function mount(route, readings) {
);
}
if (route.name === "positions") return positionsView(data, primary.sort, onSort);
+ if (route.name === "balances") return balancesView(data, primary.sort, onSort);
if (route.name === "rules") return rulesView(data, primary.sort, onSort);
if (route.name === "venues") return venuesView(data, primary.sort, onSort);
if (route.name === "gates") return gatesView(data);
diff --git a/keel/web/static/js/render.js b/keel/web/static/js/render.js
index ff9cce7..cda9ed6 100644
--- a/keel/web/static/js/render.js
+++ b/keel/web/static/js/render.js
@@ -1138,6 +1138,99 @@ function jobPanel(job) {
* @param {(scope: string) => void} onScope
* @returns {DocumentFragment}
*/
+/**
+ * The Balances view (#702): what the account holds, as the last cycle recorded it.
+ *
+ * ── EVERY FIGURE IS STAMPED, BECAUSE EVERY FIGURE IS RECORDED ────────────────────────────────
+ *
+ * Nothing here came from a network call -- `keel serve` reads SQLite and nothing else, and
+ * `keel/commands/balances.py` carries why that is the design rather than a limitation. What
+ * makes that honest instead of merely quiet is the as-of stamp beside each tile: this is the
+ * cash the engine sized against when it last evaluated the rails, and the page says when.
+ *
+ * ── NO BUYING POWER, NO DEPOSIT, NO TRANSFER, NO CTA ─────────────────────────────────────────
+ *
+ * #702's refusal. Cash is a fact, not an affordance. keel is cash-spot by constitution, so a
+ * "buying power" tile would advertise leverage the engine refuses to take, and a deposit button
+ * would be this page's version of the close button the Positions view also does not have.
+ * Pinned by `tests/web/test_balances_view.py`.
+ *
+ * ── THE SETTLED SPLIT IS NAMED AS MISSING, NOT OMITTED ───────────────────────────────────────
+ *
+ * `settled_breakdown` is a field whose whole content is "unrecorded". Leaving the tiles out
+ * would let a reader assume the available figure IS the settled one; saying so is the only
+ * rendering that cannot be misread.
+ *
+ * @param {any} data `/api/balances`'s `data`.
+ * @param {any} sort
+ * @param {(column: string) => void} onSort
+ * @returns {DocumentFragment}
+ */
+export function balancesView(data, sort, onSort) {
+ const fragment = document.createDocumentFragment();
+ fragment.append(el("h1", undefined, "Balances"));
+
+ const sub = el("p", "sub");
+ sub.append(field(data.generated_at), " · ", field(data.recorded));
+ fragment.append(sub);
+
+ fragment.append(
+ gridCard([
+ kv("mode", plain(data.mode) || "unstamped"),
+ kv("available cash", data.cash),
+ kv("as of", data.cash_as_of),
+ kv("equity", data.equity),
+ kv("unrealized", data.unrealized),
+ kv("high water mark", data.hwm),
+ ]),
+ );
+
+ fragment.append(heading("h-settled", "Settled and unsettled"));
+ const settled = el("p", "note");
+ settled.append(field(data.settled_breakdown));
+ fragment.append(settled);
+ fragment.append(
+ gridCard([kv("settled", data.settled_cash), kv("total", data.total_cash)]),
+ );
+
+ if (plain(data.mode) === "paper") {
+ fragment.append(heading("h-paper", "Synthetic account"));
+ const note = el("p", "note");
+ note.append("The paper account's cash right now, beside the cycle's recorded reading above.");
+ fragment.append(note);
+ fragment.append(gridCard([kv("paper cash", data.paper_cash)]));
+ }
+
+ fragment.append(heading("h-assets", "Held assets"));
+ const assets = data.assets || [];
+ fragment.append(
+ table(
+ "h-assets",
+ [
+ { label: "product", numeric: false, key: "product_id" },
+ { label: "qty held", numeric: true, key: "qty" },
+ { label: "mark", numeric: true, key: "mark" },
+ // No `key`: `/api/balances` does not declare `mark_as_of` sortable, and a key the
+ // server will not order by renders a header that looks clickable and is not.
+ { label: "marked at", numeric: false },
+ { label: "value", numeric: true, key: "market_value" },
+ ],
+ assets.map(/** @param {any} row */ (row) => [
+ plain(row.product_id) || "—",
+ row.qty,
+ row.mark,
+ row.mark_as_of,
+ row.market_value,
+ ]),
+ "No held assets. keel is holding nothing right now.",
+ { sort: sort, onSort: onSort },
+ ),
+ );
+
+ return fragment;
+}
+
+
/**
* The Positions view (#701): what is held, what it is worth, and how close it is to its stop.
*
diff --git a/keel/web/staticfiles.py b/keel/web/staticfiles.py
index abac86d..7b68307 100644
--- a/keel/web/staticfiles.py
+++ b/keel/web/staticfiles.py
@@ -142,6 +142,7 @@ def resolve_static_asset(root: Path, url_path: str) -> Path | None:
"activity",
"orders",
"positions",
+ "balances",
"insights",
"rules",
"venues",
diff --git a/tests/commands/test_balances.py b/tests/commands/test_balances.py
new file mode 100644
index 0000000..5316c71
--- /dev/null
+++ b/tests/commands/test_balances.py
@@ -0,0 +1,391 @@
+"""The balances report -- issue #702.
+
+**`keel serve` makes no network call, and this report is why that stays true.** A balances page
+is the obvious place to reach for a live venue read, and doing so would put credentials into the
+web process, hand venue latency to a page that re-polls every 15 seconds, and spend an operator's
+rate limit for every browser tab left open. Every figure here comes from what a CYCLE recorded:
+`equity_points` (#698) for cash, the positions report (#701) for the per-asset rows.
+
+That is not a consolation prize. The cash this shows is the cash the engine actually sized
+against when it evaluated the rails, stamped with when it read it -- which is a more useful
+answer than a fresher number the engine never saw.
+"""
+
+from __future__ import annotations
+
+from decimal import Decimal
+from pathlib import Path
+from typing import Any
+
+import pytest
+from keel_core.types import Candle, EquityReading, Granularity
+
+from keel.commands.balances import gather_balances
+from keel.config import Config
+from keel.data.db import connect, migrate
+from keel.data.repository import Repository
+from tests.conftest import VALID_CONFIG_YAML
+
+NOW_TS = 1_800_000_000
+DAY = 86_400
+FINEST = Granularity.FIFTEEN_MINUTE
+
+
+@pytest.fixture()
+def repo(tmp_path: Path) -> Repository:
+ conn = connect(str(tmp_path / "keel.db"))
+ migrate(conn)
+ return Repository(conn)
+
+
+def _config(tmp_path: Path) -> Config:
+ from keel.config import load_config
+
+ path = tmp_path / "config.yaml"
+ path.write_text(VALID_CONFIG_YAML, encoding="utf-8")
+ return load_config(str(path))
+
+
+def _reading(ts: int, mode: str, cash: str | None, equity: str = "10000") -> EquityReading:
+ return EquityReading(
+ ts=ts,
+ mode=mode,
+ equity=Decimal(equity),
+ cash=None if cash is None else Decimal(cash),
+ unrealized=Decimal("0"),
+ hwm=Decimal(equity),
+ )
+
+
+def _candle(ts: int, close: str) -> Candle:
+ price = Decimal(close)
+ return Candle(ts=ts, open=price, high=price, low=price, close=price, volume=Decimal("1"))
+
+
+def _tranche(repo: Repository, product_id: str, qty: str, **overrides: Any) -> int:
+ row: dict[str, Any] = {
+ "product_id": product_id,
+ "rule_name": "turtle_breakout",
+ "opened_at": NOW_TS - DAY,
+ "qty": Decimal(qty),
+ "entry_fill": Decimal("100"),
+ "entry_fee": Decimal("1"),
+ "initial_stop": Decimal("90"),
+ }
+ row.update(overrides)
+ return repo.open_position(**row)
+
+
+# -- cash comes from the cycle that recorded it ---------------------------------------------------
+
+
+def test_cash_is_the_newest_reading_for_the_mode_in_force(repo: Repository, tmp_path: Path) -> None:
+ """The mode partition, again. `equity_points` holds paper and live rows in one database, and
+ a page that took the newest row of ANY mode would show a $10,000 synthetic balance on a live
+ deployment that holds $250."""
+ repo.set_state("equity_state_mode", "live")
+ # The PAPER row is the newer of the two, deliberately: an unfiltered "newest reading" read
+ # would return it, so this fixture is what makes the mode filter observable. With the live
+ # row newest, the test would pass against a report that ignored `mode` entirely.
+ repo.record_equity_point(_reading(NOW_TS - 3600, "live", "250.10", equity="250.10"))
+ repo.record_equity_point(_reading(NOW_TS - 60, "paper", "9000", equity="10000"))
+
+ report = gather_balances(repo, _config(tmp_path), now_ts=NOW_TS)
+
+ assert report.mode == "live"
+ assert report.cash == Decimal("250.10"), "the live reading, not the newer paper one"
+ assert report.cash_as_of == NOW_TS - 3600
+
+
+def test_the_newest_reading_wins_within_the_mode(repo: Repository, tmp_path: Path) -> None:
+ repo.set_state("equity_state_mode", "live")
+ repo.record_equity_point(_reading(NOW_TS - DAY, "live", "100"))
+ repo.record_equity_point(_reading(NOW_TS - 60, "live", "300"))
+
+ assert gather_balances(repo, _config(tmp_path), now_ts=NOW_TS).cash == Decimal("300")
+
+
+def test_a_deployment_that_has_recorded_nothing_says_so(repo: Repository, tmp_path: Path) -> None:
+ """A fresh deployment, or one that has not completed a cycle since the v19 upgrade. Absent,
+ never zero: a zero cash balance is a fact about an account, and this is the absence of any
+ fact at all."""
+ repo.set_state("equity_state_mode", "live")
+
+ report = gather_balances(repo, _config(tmp_path), now_ts=NOW_TS)
+
+ assert report.cash is None
+ assert report.cash_as_of is None
+ assert report.has_recorded_cash is False
+
+
+def test_a_reading_whose_split_was_never_recorded_reports_no_cash(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """`equity_points.cash` is nullable -- a cycle can know its total and not the split. The
+ equity is still shown; the cash is not invented from it."""
+ repo.set_state("equity_state_mode", "live")
+ repo.record_equity_point(_reading(NOW_TS - 60, "live", None, equity="250"))
+
+ report = gather_balances(repo, _config(tmp_path), now_ts=NOW_TS)
+
+ assert report.cash is None
+ assert report.equity == Decimal("250")
+ assert report.cash_as_of == NOW_TS - 60, "the reading still has a time, it just has no split"
+
+
+def test_the_recorded_equity_and_unrealized_ride_along(repo: Repository, tmp_path: Path) -> None:
+ """One reading, read once. Taking cash from one cycle and equity from another would show two
+ moments as though they were one."""
+ repo.set_state("equity_state_mode", "live")
+ repo.record_equity_point(_reading(NOW_TS - 60, "live", "250.10", equity="1250.10"))
+
+ report = gather_balances(repo, _config(tmp_path), now_ts=NOW_TS)
+
+ assert report.equity == Decimal("1250.10")
+ assert report.hwm == Decimal("1250.10")
+
+
+# -- what is NOT recorded -------------------------------------------------------------------------
+
+
+def test_the_settled_breakdown_is_reported_as_unrecorded(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """#702's centrepiece, and the honest answer to it today. `equity_points.cash` comes from
+ `_fetch_available_quote`, which reads `Balance.available` and nothing else -- the venue's
+ settled-versus-total pair is never written down. The report says so rather than presenting
+ the available figure under a label that implies the distinction was checked."""
+ repo.set_state("equity_state_mode", "live")
+ repo.record_equity_point(_reading(NOW_TS - 60, "live", "250.10"))
+
+ report = gather_balances(repo, _config(tmp_path), now_ts=NOW_TS)
+
+ assert report.settled_cash is None
+ assert report.total_cash is None
+ assert report.settled_breakdown_recorded is False
+
+
+# -- paper mode ------------------------------------------------------------------------------------
+
+
+def test_paper_mode_reads_the_synthetic_cash_beside_the_recorded_one(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """`paper_cash_usdc` is the live value of the synthetic account, which moves the moment a
+ paper fill happens -- while the recorded reading is from the last cycle. Both are shown: they
+ answer "what does the paper account hold now" and "what did the cycle act on"."""
+ repo.set_state("equity_state_mode", "paper")
+ repo.set_state("paper_cash_usdc", Decimal("9500.25"))
+ repo.record_equity_point(_reading(NOW_TS - 3600, "paper", "9000"))
+
+ report = gather_balances(repo, _config(tmp_path), now_ts=NOW_TS)
+
+ assert report.paper_cash == Decimal("9500.25")
+ assert report.cash == Decimal("9000")
+
+
+def test_live_mode_reports_no_paper_cash_even_if_the_key_survives(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """`paper_cash_usdc` outlives a paper->live flip in `agent_state`. Showing it on a live page
+ would put a synthetic balance beside real money."""
+ repo.set_state("equity_state_mode", "live")
+ repo.set_state("paper_cash_usdc", Decimal("9500.25"))
+
+ assert gather_balances(repo, _config(tmp_path), now_ts=NOW_TS).paper_cash is None
+
+
+# -- the per-asset rows ----------------------------------------------------------------------------
+
+
+def test_tranches_of_one_product_are_summed_into_one_asset_row(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """A balances page answers "what do I hold", which is per ASSET. The tranche breakdown is the
+ Positions view's job, and the two read the same report so they cannot disagree."""
+ repo.set_state("equity_state_mode", "live")
+ _tranche(repo, "BTC-USD", "2")
+ _tranche(repo, "BTC-USD", "3")
+ repo.upsert_candles("BTC-USD", FINEST, [_candle(NOW_TS - 900, "150")])
+
+ rows = gather_balances(repo, _config(tmp_path), now_ts=NOW_TS).assets
+
+ assert [row.product_id for row in rows] == ["BTC-USD"]
+ assert rows[0].qty == Decimal("5")
+ assert rows[0].market_value == Decimal("5") * Decimal("150")
+
+
+def test_an_asset_with_an_unmarked_tranche_reports_no_value_rather_than_a_partial_sum(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """A partial sum is the most dangerous shape here: it looks like a total and is not one. If
+ any tranche of a product has no mark, the product's value is unknown -- and saying so is the
+ only reading that cannot be mistaken for a smaller holding."""
+ repo.set_state("equity_state_mode", "live")
+ _tranche(repo, "BTC-USD", "2")
+ _tranche(repo, "ETH-USD", "3")
+ repo.upsert_candles("BTC-USD", FINEST, [_candle(NOW_TS - 900, "150")])
+
+ by_product = {
+ row.product_id: row
+ for row in gather_balances(repo, _config(tmp_path), now_ts=NOW_TS).assets
+ }
+
+ assert by_product["BTC-USD"].market_value == Decimal("300")
+ assert by_product["ETH-USD"].qty == Decimal("3"), "the holding is known"
+ assert by_product["ETH-USD"].market_value is None, "its value is not"
+
+
+def test_the_asset_mark_carries_the_time_it_was_read(repo: Repository, tmp_path: Path) -> None:
+ """Every figure on this page is stamped. A mark with no time is a claim about now that was
+ made at some other now."""
+ repo.set_state("equity_state_mode", "live")
+ _tranche(repo, "BTC-USD", "2")
+ repo.upsert_candles("BTC-USD", FINEST, [_candle(NOW_TS - 900, "150")])
+
+ row = gather_balances(repo, _config(tmp_path), now_ts=NOW_TS).assets[0]
+
+ assert row.mark == Decimal("150")
+ assert row.mark_as_of == NOW_TS - 900
+
+
+def test_no_open_positions_is_an_empty_asset_list_not_an_error(
+ repo: Repository, tmp_path: Path
+) -> None:
+ repo.set_state("equity_state_mode", "live")
+
+ assert gather_balances(repo, _config(tmp_path), now_ts=NOW_TS).assets == ()
+
+
+def test_an_unstamped_mode_reports_no_cash_rather_than_guessing(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """Before the first cycle, `equity_state_mode` is unset. Picking a mode to read would be
+ choosing which account's balance to show on a deployment that has not said."""
+ repo.record_equity_point(_reading(NOW_TS - 60, "live", "250"))
+
+ report = gather_balances(repo, _config(tmp_path), now_ts=NOW_TS)
+
+ assert report.mode == ""
+ assert report.cash is None
+ assert report.has_recorded_cash is False
+
+
+# -- the mixed-mark guard, at the level where it can exist (#702 review) ------------------------
+
+
+def _position_row(product_id: str, qty: str, mark: str | None) -> Any:
+ from keel.commands.positions import PositionRow
+
+ price = None if mark is None else Decimal(mark)
+ return PositionRow(
+ id=1,
+ product_id=product_id,
+ rule_name="turtle_breakout",
+ opened_at=NOW_TS - DAY,
+ qty=Decimal(qty),
+ entry_fill=Decimal("100"),
+ entry_fee=Decimal("1"),
+ mark=price,
+ mark_ts=None if price is None else NOW_TS - 900,
+ market_value=None if price is None else Decimal(qty) * price,
+ unrealized_pnl=None,
+ initial_stop=None,
+ stop_distance=None,
+ stop_distance_pct=None,
+ realized_qty=Decimal("0"),
+ realized_proceeds=Decimal("0"),
+ realized_fees=Decimal("0"),
+ ready=True,
+ ready_reason=None,
+ )
+
+
+def test_one_unmarked_tranche_makes_the_whole_asset_value_unknown() -> None:
+ """The guard is DEFENSIVE and this is the only way to reach it.
+
+ `gather_positions` reads the mark once per product and hands every tranche of it the same
+ figure, so a product whose tranches disagree cannot arise through that caller -- which means
+ a test going through `gather_balances` cannot exercise this branch, and one that seeds two
+ different PRODUCTS (as the first version of this test did) is not exercising it either.
+
+ Driven through `_assets_from` directly, because the guard protects the FOLD, not that
+ caller: a future caller that assembles rows from more than one read, or a mark cache that
+ stops being per-product, would produce exactly this state -- and a sum over the priced subset
+ would render a holding as worth less than it is, which is the failure worth engineering
+ against on the page an operator checks to see what they have.
+ """
+ from keel.commands.balances import _assets_from
+
+ rows = [
+ _position_row("BTC-USD", "2", "150"),
+ _position_row("BTC-USD", "3", None),
+ ]
+
+ assets = _assets_from(rows, ("BTC-USD",))
+
+ assert assets[0].qty == Decimal("5"), "the holding is known"
+ assert assets[0].market_value is None, "its value is not -- never the priced subset's sum"
+
+
+def test_a_fully_marked_asset_still_sums(tmp_path: Path) -> None:
+ """The other side of the guard: nothing is withheld when every tranche has a mark."""
+ from keel.commands.balances import _assets_from
+
+ rows = [
+ _position_row("BTC-USD", "2", "150"),
+ _position_row("BTC-USD", "3", "150"),
+ ]
+
+ assert _assets_from(rows, ("BTC-USD",))[0].market_value == Decimal("750")
+
+
+def test_the_asset_order_follows_the_reports_product_order(tmp_path: Path) -> None:
+ """Pinned rather than argued. Sorted or set-ordered, the page would reorder itself between
+ reads for no reason a reader could see."""
+ from keel.commands.balances import _assets_from
+
+ rows = [
+ _position_row("SOL-USD", "1", "10"),
+ _position_row("BTC-USD", "2", "150"),
+ ]
+
+ assets = _assets_from(rows, ("SOL-USD", "BTC-USD"))
+
+ assert [row.product_id for row in assets] == ["SOL-USD", "BTC-USD"]
+
+
+# -- the read cost of one request (#702 review) ------------------------------------------------
+
+
+def test_balances_does_not_pay_for_the_entry_gate_it_never_renders(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """This page shows quantity, mark and value. It does not show the entry-gate verdict, and it
+ must not pay to compute one.
+
+ Measured before the fix: three products cost 12 candle reads and a rules read per request --
+ four reads per product, three of them for `entry_bar_ready` across every configured
+ granularity, on an endpoint the console re-polls every 15 seconds. The mark needs one read
+ per product and nothing else.
+ """
+ repo.set_state("equity_state_mode", "live")
+ repo.record_equity_point(_reading(NOW_TS - 3600, "live", "250"))
+ repo.insert_rule("turtle_breakout", {"product_id": "BTC-USD", "granularity": "ONE_DAY"})
+ for product in ("BTC-USD", "ETH-USD", "SOL-USD"):
+ _tranche(repo, product, "2")
+ repo.upsert_candles(product, FINEST, [_candle(NOW_TS - 900, "150")])
+
+ reads = {"candles": 0}
+ original = repo.get_candles
+
+ def counting(*args: Any, **kwargs: Any) -> Any:
+ reads["candles"] += 1
+ return original(*args, **kwargs)
+
+ repo.get_candles = counting # type: ignore[method-assign]
+ gather_balances(repo, _config(tmp_path), now_ts=NOW_TS)
+
+ assert reads["candles"] == 3, (
+ f"one mark read per product and no more; got {reads['candles']} for 3 products"
+ )
diff --git a/tests/web/test_api.py b/tests/web/test_api.py
index c36e804..3893333 100644
--- a/tests/web/test_api.py
+++ b/tests/web/test_api.py
@@ -59,6 +59,7 @@
"/api/activity",
"/api/orders",
"/api/positions",
+ "/api/balances",
"/api/insights",
"/api/journal",
"/api/rules",
diff --git a/tests/web/test_balances_view.py b/tests/web/test_balances_view.py
new file mode 100644
index 0000000..e5124b2
--- /dev/null
+++ b/tests/web/test_balances_view.py
@@ -0,0 +1,226 @@
+"""The balances view, on the wire -- issue #702.
+
+The service decides; this pins that the browser gets the decision unaltered. The property that
+matters most here is negative: **no figure on this page came from a network call**, and the
+as-of stamps are what make that honest rather than hidden.
+"""
+
+from __future__ import annotations
+
+import json
+from decimal import Decimal
+from pathlib import Path
+from typing import Any
+
+from keel.commands.balances import gather_balances
+from keel.data.db import connect, migrate
+from keel.data.repository import Repository
+from keel.web import payload as web_payload
+
+BAL_NOW_TS = 1_800_000_000
+
+
+def _walk(node: Any, path: str = "$") -> list[tuple[str, Any]]:
+ if isinstance(node, dict):
+ out: list[tuple[str, Any]] = []
+ for key, value in node.items():
+ out.extend(_walk(value, f"{path}.{key}"))
+ return out
+ if isinstance(node, list):
+ out = []
+ for index, value in enumerate(node):
+ out.extend(_walk(value, f"{path}[{index}]"))
+ return out
+ return [(path, node)]
+
+
+def _report(tmp_path: Path, *, mode: str = "live", cash: str | None = "250.10", **kw: Any) -> Any:
+ from tests.commands.test_balances import FINEST, _candle, _config, _reading, _tranche
+
+ conn = connect(str(tmp_path / "balances.db"))
+ migrate(conn)
+ repo = Repository(conn)
+ repo.set_state("equity_state_mode", mode)
+ if cash is not None:
+ repo.record_equity_point(_reading(BAL_NOW_TS - 3600, mode, cash))
+ if kw.get("paper_cash") is not None:
+ repo.set_state("paper_cash_usdc", Decimal(kw["paper_cash"]))
+ if kw.get("holding", True):
+ _tranche(repo, "BTC-USD", "2")
+ repo.upsert_candles("BTC-USD", FINEST, [_candle(BAL_NOW_TS - 900, "150")])
+ return gather_balances(repo, _config(tmp_path), now_ts=BAL_NOW_TS)
+
+
+def test_no_wire_value_in_the_balances_payload_is_ever_a_json_number(tmp_path: Path) -> None:
+ document = json.loads(json.dumps(web_payload.balances_payload(_report(tmp_path))))
+ numbers = [
+ path
+ for path, leaf in _walk(document)
+ if not isinstance(leaf, bool) and isinstance(leaf, (int, float))
+ ]
+ assert numbers == [], numbers
+
+
+def test_every_recorded_figure_carries_the_instant_it_was_recorded(tmp_path: Path) -> None:
+ """The as-of stamp is what makes a recorded page honest instead of stale. A cash figure with
+ no time on it is a claim about now that was made at some other now."""
+ built = web_payload.balances_payload(_report(tmp_path))
+
+ assert built["cash_as_of"]["value"].endswith("Z")
+ assert built["assets"][0]["mark_as_of"]["value"].endswith("Z")
+
+
+def test_the_settled_breakdown_says_it_is_unrecorded_rather_than_showing_available_as_settled(
+ tmp_path: Path,
+) -> None:
+ """#702's centrepiece. `equity_points.cash` is the AVAILABLE figure and nothing else, so the
+ settled/unsettled split is not a number this deployment has. Labelling the available figure
+ "settled" would answer the question the page was built to ask honestly."""
+ built = web_payload.balances_payload(_report(tmp_path))
+
+ assert built["settled_cash"]["state"] == "unknown"
+ assert built["total_cash"]["state"] == "unknown"
+ assert "UNRECORDED" in built["settled_breakdown"]["display"].upper()
+ assert built["settled_breakdown"]["state"] == "unknown"
+
+
+def test_a_deployment_with_no_recorded_cycle_says_so(tmp_path: Path) -> None:
+ """Not "$0.00", and not a blank tile. A deployment that has not completed a cycle since the
+ series began recording has nothing to show, which is a different fact from an empty account."""
+ built = web_payload.balances_payload(_report(tmp_path, cash=None))
+
+ assert built["cash"]["state"] == "unknown"
+ assert built["recorded"]["value"] == "false"
+ assert built["recorded"]["display"]
+
+
+def test_a_recorded_cycle_with_no_split_reads_as_recorded_and_absent(tmp_path: Path) -> None:
+ """The combination `has_recorded_cash` exists to draw: a cycle DID run and record a reading,
+ and that reading had no cash split. "Nothing has been recorded" and "the last cycle could not
+ read a split" are different facts, and only the first means this page has nothing yet."""
+ from tests.commands.test_balances import _config, _reading
+
+ conn = connect(str(tmp_path / "split.db"))
+ migrate(conn)
+ repo = Repository(conn)
+ repo.set_state("equity_state_mode", "live")
+ repo.record_equity_point(_reading(BAL_NOW_TS - 3600, "live", None, equity="250"))
+
+ built = web_payload.balances_payload(
+ gather_balances(repo, _config(tmp_path), now_ts=BAL_NOW_TS)
+ )
+
+ assert built["recorded"]["value"] == "true", "a cycle did record a reading"
+ assert built["cash"]["state"] == "unknown", "it just had no split to record"
+ assert built["equity"]["value"] == "250"
+ assert built["cash_as_of"]["value"].endswith("Z"), "the reading still has a time"
+
+
+def test_paper_cash_crosses_only_in_paper_mode(tmp_path: Path) -> None:
+ live = web_payload.balances_payload(_report(tmp_path, mode="live", paper_cash="9500"))
+ nested = tmp_path / "p"
+ nested.mkdir()
+ paper = web_payload.balances_payload(_report(nested, mode="paper", paper_cash="9500"))
+
+ assert live["paper_cash"]["state"] == "unknown"
+ assert paper["paper_cash"]["value"] == "9500"
+
+
+def test_the_asset_rows_carry_quantity_and_value(tmp_path: Path) -> None:
+ row = web_payload.balances_payload(_report(tmp_path))["assets"][0]
+
+ assert row["product_id"] == "BTC-USD"
+ assert row["qty"]["value"] == "2"
+ assert row["market_value"]["value"] == "300"
+
+
+def test_an_unpriced_asset_shows_its_holding_and_no_value(tmp_path: Path) -> None:
+ """The quantity is a fact; its worth is not. A zero here would read as a worthless holding."""
+ from tests.commands.test_balances import _config, _reading, _tranche
+
+ conn = connect(str(tmp_path / "b.db"))
+ migrate(conn)
+ repo = Repository(conn)
+ repo.set_state("equity_state_mode", "live")
+ repo.record_equity_point(_reading(BAL_NOW_TS - 3600, "live", "250"))
+ _tranche(repo, "ETH-USD", "3")
+
+ built = web_payload.balances_payload(
+ gather_balances(repo, _config(tmp_path), now_ts=BAL_NOW_TS)
+ )
+ row = built["assets"][0]
+
+ assert row["qty"]["value"] == "3"
+ assert row["market_value"]["state"] == "unknown"
+
+
+def test_the_mode_and_counts_come_off_the_report(tmp_path: Path) -> None:
+ """Rule 6e: no `len()` in the serialiser."""
+ built = web_payload.balances_payload(_report(tmp_path))
+
+ assert built["mode"] == "live"
+ assert built["asset_count"]["value"] == "1"
+
+
+def test_the_payload_offers_no_buying_power_and_no_action(tmp_path: Path) -> None:
+ """#702's refusal, on the wire rather than only in the view. "Buying power" is a leverage
+ invitation, and a balances page that carried one would be inviting the operator to spend
+ money the cash-spot constitution refuses to lend them."""
+ text = json.dumps(web_payload.balances_payload(_report(tmp_path))).lower()
+
+ for banned in ("buying_power", "buying power", "deposit", "withdraw", "transfer"):
+ assert banned not in text, banned
+
+
+# -- the view, in the client -----------------------------------------------------------------
+
+
+def _source(name: str) -> str:
+ from keel.web import staticfiles
+
+ return (Path(staticfiles.__file__).parent / "static" / "js" / name).read_text(encoding="utf-8")
+
+
+def _code(name: str) -> str:
+ from tests.web.test_client_assets import _comments_only
+
+ return _comments_only(_source(name))
+
+
+def _view_body() -> str:
+ after = _code("render.js").split("export function balancesView")[1]
+ end = after.find("export function ")
+ return after if end == -1 else after[:end]
+
+
+def test_the_balances_view_is_wired_into_the_client_router() -> None:
+ from keel.web import staticfiles
+
+ assert "balances" in staticfiles.CLIENT_ROUTES
+ main_code = _code("main.js")
+ assert "balancesView" in main_code
+ assert 'route.name === "balances"' in main_code
+ assert "export function balancesView" in _code("render.js")
+
+
+def test_the_balances_view_offers_no_action_of_any_kind() -> None:
+ """#702's refusal, pinned on the source. Cash is a fact, not an affordance -- and this is the
+ page where a deposit button or a "buying power" tile would look most like a courtesy."""
+ body = _view_body().lower()
+
+ for banned in ("buying", "deposit", "withdraw", "transfer", "button", "addeventlistener"):
+ assert banned not in body, f"the balances view must offer no {banned}"
+
+
+def test_the_view_stamps_the_recorded_figures() -> None:
+ """A recorded page is honest only if it says when. Both stamps are read from the payload --
+ the cash reading's, and each asset's mark."""
+ body = _view_body()
+
+ assert "cash_as_of" in body
+ assert "mark_as_of" in body
+
+
+def test_the_view_names_the_settled_split_as_unrecorded() -> None:
+ """Omitting the tiles would let a reader take the available figure for the settled one."""
+ assert "settled_breakdown" in _view_body()
diff --git a/tests/web/test_client_assets.py b/tests/web/test_client_assets.py
index 6f4f81b..48fe04c 100644
--- a/tests/web/test_client_assets.py
+++ b/tests/web/test_client_assets.py
@@ -1256,6 +1256,13 @@ def _status_view_keys() -> list[str]:
_VIEW_ENDPOINTS: tuple[tuple[str, str, str], ...] = (
("setupView", "data", "/api/setup"),
("activityView", "data", "/api/activity"),
+ # #659/#701/#702's views were added to the client without being added here, so nothing
+ # checked that they read keys their endpoint actually sends. Demonstrated on #702: renaming
+ # `data.hwm` to `data.high_water_mark` in `balancesView` left the whole web suite green, and
+ # the tile would have rendered blank forever with nothing in the console naming the gap.
+ ("ordersView", "data", "/api/orders"),
+ ("positionsView", "data", "/api/positions"),
+ ("balancesView", "data", "/api/balances"),
("insightsView", "insights", "/api/insights"),
("insightsView", "journal", "/api/journal"),
("rulesView", "data", "/api/rules"),