Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 201 additions & 0 deletions keel/commands/balances.py
Original file line number Diff line number Diff line change
@@ -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)
32 changes: 23 additions & 9 deletions keel/commands/positions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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))

Expand Down
33 changes: 33 additions & 0 deletions keel/web/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand Down
78 changes: 78 additions & 0 deletions keel/web/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading