Skip to content

Commit a33581f

Browse files
eaitbrahimclaude
andauthored
console: Holdings view replaces Positions and Orders (#13 PR2) (#959)
* commands: holdings service (tranche ↔ entry order join, per-tranche cost) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * web: holdings_payload Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * web: /api/holdings; /api/orders drops the cancel classification Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * holdings: a tranche closed between reads is left out; orders badge stops claiming a read-only console The console halts since PR1 and has always run setup steps, so the orders page's 'state-changing actions run through the CLI' was false. The badge now says what is true: it places and cancels no orders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * console: Holdings view replaces Positions and Orders holdingsView draws the open tranches (one table per rule, cost, entry gate, attestation, placement, expected/actual/slippage; a stop column only when stop_rows_present is non-empty) and a compact recent-orders section read from /api/orders as a secondary endpoint. No close, no cancel, no control of its own. positionsView, ordersView, cancelCell, openCancelHelp and orderDetail go, with /api/positions, positions_payload, the cancel-help CSS, and the orders/positions client routes (now retired names that still serve the shell). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: console views after PR2 (Holdings) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * commands: operator text points at the Holdings view, not the retired Positions view Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * console: Holdings pairs every header with its own cell and ties a tranche id to its row Pin the tranche and recent-orders tables by header/cell pairing (counts alone let expected/actual, entry gate/attestation and side/status swaps ship). Add a tranche column and name the product in each realized-legs summary, so the id keel positions close takes can be read off its row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * orders: /api/orders stops classifying cancels the console no longer draws Drop CANCEL_NOTES (no reader since the cancel modal left) and correct the comments that still described the modal. gather_orders takes classify_cancels; the api reader passes False, the CLI keeps the default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * orders: comments say only what is true now about cancel classification and headlines Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * orders: cancel comments no longer claim a lock or a sole printed line Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 71b0ebe commit a33581f

26 files changed

Lines changed: 2096 additions & 1069 deletions

‎docs/operator-runbook.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -837,7 +837,7 @@ Rewards item: account settings no rail can see, re-checked after any account cha
837837
## The operator console, in a browser
838838

839839
`keel serve` opens keel's **operator console**: a local web page showing Pulse (the home view),
840-
Setup, Orders, Positions, Balances, Timeline, Insights, Research, Rules, Venues and Plans, over the
840+
Setup, Holdings, Balances, Timeline, Insights, Research, Rules, Venues and Plans, over the
841841
same `keel/commands/*` service layer the CLI commands call. It is being slimmed to four engine
842842
views -- Pulse, Holdings, Rules, Audit & Setup; see
843843
`docs/superpowers/plans/2026-10-10-console-four-views.md`. An architectural test (`tests/commands/test_console_thinness.py`) pins that thinness -- the
@@ -850,6 +850,14 @@ keel --config config.live-sandbox.yaml --db keel-live.db serve
850850
It binds loopback and prints a URL carrying a one-time token for that run. Stopping the server
851851
invalidates it: the token is new every run.
852852

853+
**Holdings.** One table of open tranches per rule: per-tranche cost (what the tranche paid to
854+
open, entry fee included), the entry-gate verdict, the asset attestation, the bracket, the stop
855+
only when a tranche carries one, and the entry order's placement and expected-vs-actual fill. The
856+
entry order is found by the fill price the tranche copied from it; when no order matches, the
857+
placement and the fill figures read "unknown", never zero. Below it, keel's recent orders, with
858+
the same scope and status tabs the Orders view had. There is no close and no cancel: `keel orders
859+
cancel` and the exit path stay at the terminal.
860+
853861
**Halting from the browser.** Pulse carries a HALT button. It engages the kill switch through the
854862
same operation `keel kill` performs (`POST /api/gates/halt`), after one confirm dialog and nothing
855863
else: stopping must never be slower than starting. It needs the console session and the gate token

‎keel/commands/balances.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class AssetBalanceRow:
4949
"""One PRODUCT's holding, summed across its tranches.
5050
5151
Per asset, because that is the question a balances page answers. The tranche breakdown is
52-
the Positions view's, and both read `gather_positions` so the two cannot disagree about what
52+
the Holdings view's, and both read `gather_positions` so the two cannot disagree about what
5353
is held.
5454
"""
5555

‎keel/commands/doctor.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1573,7 +1573,7 @@ def ledger_drift_findings(
15731573
WARN,
15741574
f"{len(drifted)} product(s) where the ledger and the orders log disagree",
15751575
detail + " -- a filled entry with no tranche (#799) or an unbooked sale",
1576-
"inspect the console's Positions view and `keel orders list`: a filled entry with no "
1576+
"inspect the console's Holdings view and `keel orders list`: a filled entry with no "
15771577
"tranche needs its tranche recorded by hand (#799); a sale the orders log already "
15781578
"holds needs booking against its tranche by hand -- not `keel positions close`, "
15791579
"which would write a second SELL",

‎keel/commands/evidence_matrix.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@
5050
CSCV_KIND = "cscv"
5151

5252
#: The command that would populate this page. Composed in Python and placed by the client (the
53-
#: rule #707's cancel modal follows), and it names a REAL session: `--session all` filters to a
54-
#: session literally called "all", finds nothing, and prints a refusal.
53+
#: rule #707's cancel modal followed; the modal is gone, the rule stands), and it names a REAL
54+
#: session: `--session all` filters to a session literally called "all", finds nothing, and
55+
#: prints a refusal.
5556
MATRIX_INVOCATION = "keel trials pbo --session {session}"
5657

5758
#: How many synchronous columns a session needs before `trials pbo` can say anything about it.

‎keel/commands/holdings.py‎

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""The console's Holdings view, as a service -- open tranches with what they cost and how they
2+
were entered.
3+
4+
A VIEW over `gather_positions` and the orders book. The one thing it adds is the join the ledger
5+
does not store: a tranche's ENTRY ORDER (`positions` carries no order id; no backfill exists
6+
yet). Nothing here reaches a venue; `keel/web/__init__.py`'s posture is unchanged.
7+
8+
**The join is the fill, not the clock.** `agent._open_tranche` opens a tranche with
9+
`opened_at=now_ts` (the cycle's clock) and copies two things verbatim from the entry order:
10+
`entry_fill = order["actual_fill"]` and `rule_id = order.get("rule_id")`. The timestamps do NOT
11+
reliably agree: live shares the cycle's `now_ts` (`executor._order_row`), but `PaperTrader` writes
12+
the paper order with `created_at=signal.ts` -- the BAR's ts, possibly days before the cycle that
13+
acted on it. So a candidate for a tranche is a BUY on the same product, created no later than the
14+
tranche opened, whose `actual_fill` equals the tranche's `entry_fill` EXACTLY (`Decimal`
15+
equality), and whose `rule_id` does not contradict the tranche's (a `None` on either side is
16+
"unknown", not a mismatch -- pre-v21 tranches carry none).
17+
18+
**Closest before, claimed once.** Several tranches of one product can share a fill (a DCA buying
19+
on consecutive days); the house reasoning is `sleeve.unverified_fill_orders`' -- the candidate
20+
nearest BEFORE the lot's `opened_at` (ties: the higher order id) owns it, and each order is
21+
claimed by at most one tranche, tranches walked oldest-first. "Oldest order first" would hand
22+
yesterday's order to today's tranche.
23+
24+
**Unknown, never wrong.** A fill later rewritten by reconciliation of a partial fill no longer
25+
equals what the tranche copied, so the tranche reads "entry order not identified". That is the
26+
honest outcome: this module names an order only when the tranche's own copied figures vouch for
27+
it, and says so when none does. The match is a strong inference, not a stored foreign key.
28+
29+
**Pure, no clock** (the split `pulse.py` documents): `now_ts` is the caller's one read. The orders
30+
book is read ONCE PER PRODUCT, not per tranche, and rows are built with `orders._row_from_dict`
31+
WITHOUT a `repo`, so no cancel classification ever runs for a join.
32+
"""
33+
34+
from __future__ import annotations
35+
36+
from dataclasses import dataclass
37+
from decimal import Decimal
38+
from typing import Any
39+
40+
from keel.commands.orders import OrderRow
41+
from keel.commands.orders import _row_from_dict as _order_row_from_dict
42+
from keel.commands.positions import PositionRow, gather_positions
43+
from keel.config import Config
44+
from keel.data.repository import Repository
45+
from keel.types import Side
46+
47+
#: Said when no order vouches for a tranche. Worded for what was checked, not for a cause: the
48+
#: order may be missing, or its fill may have been rewritten after the tranche copied it.
49+
ENTRY_ORDER_NOT_IDENTIFIED = (
50+
"entry order not identified -- no buy order on this product matches this tranche's fill "
51+
"price before it opened"
52+
)
53+
54+
55+
@dataclass(frozen=True)
56+
class HoldingRow:
57+
position: PositionRow
58+
#: `positions.bracket_order_id is not None`.
59+
has_bracket: bool
60+
#: What the tranche paid to OPEN: `(qty + realized_qty) * entry_fill + entry_fee`. `qty` is
61+
#: what is still held after scale-outs while `entry_fee` is the whole entry's fee, so
62+
#: `qty * fill + fee` would mix the two once a partial exit has happened.
63+
cost: Decimal
64+
#: The matched entry order, or `None`. Its `cancel` is the inert default, never classified.
65+
entry_order: OrderRow | None
66+
#: `""` when matched, else `ENTRY_ORDER_NOT_IDENTIFIED`.
67+
entry_order_note: str
68+
69+
70+
@dataclass(frozen=True)
71+
class HoldingsReport:
72+
now_ts: int
73+
#: The deployment's `auto_trade.mode`, which the bracket judgement needs.
74+
mode: str
75+
#: Oldest tranche first.
76+
rows: tuple[HoldingRow, ...]
77+
78+
@property
79+
def open_count(self) -> int:
80+
"""Derived, not stored: `payload.py` may not call `len()` (Rule 6e)."""
81+
return len(self.rows)
82+
83+
@property
84+
def rules(self) -> tuple[str, ...]:
85+
"""Distinct rule names in first-seen (oldest-first) order, the unattributed `""` last --
86+
what a grouped view keys on, so no renderer builds its own list."""
87+
seen: list[str] = []
88+
for row in self.rows:
89+
if row.position.rule_name not in seen:
90+
seen.append(row.position.rule_name)
91+
return tuple(sorted(seen, key=lambda name: name == ""))
92+
93+
@property
94+
def stop_row_ids(self) -> tuple[int, ...]:
95+
"""Tranches that carry a stop, so the view can draw the stop column only when one does."""
96+
return tuple(r.position.id for r in self.rows if r.position.initial_stop is not None)
97+
98+
99+
def _rule_consistent(tranche_rule_id: Any, order: dict[str, Any]) -> bool:
100+
order_rule_id = order.get("rule_id")
101+
if tranche_rule_id is None or order_rule_id is None:
102+
return True
103+
return int(tranche_rule_id) == int(order_rule_id)
104+
105+
106+
def _entry_order(
107+
raw: dict[str, Any], candidates: list[dict[str, Any]], claimed: set[int]
108+
) -> dict[str, Any] | None:
109+
opened_at = int(raw["opened_at"])
110+
matches = [
111+
o
112+
for o in candidates
113+
if int(o["id"]) not in claimed
114+
and o["side"] == Side.BUY.value
115+
and o.get("created_at") is not None
116+
and int(o["created_at"]) <= opened_at
117+
and o.get("actual_fill") is not None
118+
and o["actual_fill"] == raw["entry_fill"]
119+
and _rule_consistent(raw.get("rule_id"), o)
120+
]
121+
if not matches:
122+
return None
123+
return max(matches, key=lambda o: (int(o["created_at"]), int(o["id"])))
124+
125+
126+
def gather_holdings(repo: Repository, config: Config, *, now_ts: int) -> HoldingsReport:
127+
"""Every open tranche with its cost and, where one vouches for it, its entry order."""
128+
positions = gather_positions(repo, config, now_ts=now_ts)
129+
raw_by_id = {int(r["id"]): r for r in repo.get_open_positions()}
130+
rule_names = {int(r["id"]): str(r["kind"]) for r in repo.get_rules(None)}
131+
book: dict[str, list[dict[str, Any]]] = {}
132+
claimed: set[int] = set()
133+
rows: list[HoldingRow] = []
134+
for position in positions.rows:
135+
raw = raw_by_id.get(position.id)
136+
if raw is None:
137+
# Closed between the two reads of the open book (the agent writes from its own
138+
# process). It is no longer held, so it is left out rather than shown with a bracket
139+
# and rule id nobody read.
140+
continue
141+
if position.product_id not in book:
142+
book[position.product_id] = repo.get_orders(product_id=position.product_id)
143+
match = _entry_order(raw, book[position.product_id], claimed)
144+
if match is not None:
145+
claimed.add(int(match["id"]))
146+
rows.append(
147+
HoldingRow(
148+
position=position,
149+
has_bracket=raw.get("bracket_order_id") is not None,
150+
cost=(position.qty + position.realized_qty) * position.entry_fill
151+
+ position.entry_fee,
152+
entry_order=None if match is None else _order_row_from_dict(match, rule_names),
153+
entry_order_note="" if match is not None else ENTRY_ORDER_NOT_IDENTIFIED,
154+
)
155+
)
156+
return HoldingsReport(now_ts=now_ts, mode=str(config.auto_trade.mode), rows=tuple(rows))

0 commit comments

Comments
 (0)