Skip to content

Commit 52a9143

Browse files
eaitbrahimclaude
andauthored
console: Rules view absorbs Insights and Research; charts and journal retired (#13 PR3) (#963)
* console: Rules view absorbs Insights and Research; charts and journal retired (#953) /api/rules carries the track records and promotion-gate distances (build_insights_report, unsorted: `rules` stays the one sortable collection). The four research endpoints keep their readers and empty sort surface and retarget html_route to /rules. /api/insights, /api/journal, chart.js, the #602 zoom/pan/export block, the .chart CSS, the closed-trade table and the discretionary-journal section leave the console; services and CLI unchanged. /insights and /research join RETIRED_CLIENT_ROUTES and land on Pulse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: console views after PR3 (#953) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * console: Rules prints the research intro once, pins read-only and the failed-read guards (#953 F1-F3) The intro sentence sat in rulesView and again in trialsSection, and above a failed trials read. Keep trialsSection's. Add structural tests: the sentence occurs once, no Rules builder has a button/fetch/handler, and each research failure guard ends in its own return. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * console: Rules shows win rate as a real percentage and unknown stats as dashes (#953 F4, F5) Win rate and its floor are 0..1 fractions, but percent() takes percent units, so 40% read as 0.4%. The report now carries win_rate_pct / min_win_rate_pct (converted in insights.py, as slippage.py does for bp); the payload only suffixes. A rule with zero closed trades renders its win rate, averages, expectancy, profit factor and max dd as absent, not zero. CLI output and gate decisions are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * console: pin the CLI summary line exactly; correct stale float comments (#953 R2 N1, N3) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(console): true _decimalise docstring; reseed record-order test non-alphabetically Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a33581f commit 52a9143

26 files changed

Lines changed: 1910 additions & 3784 deletions

‎docs/operator-runbook.md‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -837,8 +837,8 @@ 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, Holdings, Balances, Timeline, Insights, Research, Rules, Venues and Plans, over the
841-
same `keel/commands/*` service layer the CLI commands call. It is being slimmed to four engine
840+
Setup, Holdings, Balances, Timeline, Rules, Venues and Plans, over the same `keel/commands/*`
841+
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
844844
front-end renders and dispatches, and every behaviour comes from the services.
@@ -858,6 +858,19 @@ placement and the fill figures read "unknown", never zero. Below it, keel's rece
858858
the same scope and status tabs the Orders view had. There is no close and no cancel: `keel orders
859859
cancel` and the exit path stay at the terminal.
860860

861+
**Rules.** Everything keel knows about its rules, on one page, and nothing on it is a control:
862+
promotion happens in the CLI. The rule ledger and each rule's parameters come first, then the
863+
track records of the rules in the promotion pipeline (candidate and paper) with each paper rule's
864+
distance to the promotion gate -- unsorted, because a sorted record is a leaderboard -- then the
865+
DCA plan proposal (`/rules?budget=500&buffer=0.1` fills it; it is applied by the `keel dca plan`
866+
command it shows, at a terminal), then the research record: every trial, the evidence matrix, the
867+
gauntlet and the slippage assumptions. Track records, promotion-gate distances and the research
868+
record used to be the Insights and Research views. The equity charts and the closed-trade table
869+
left the console: the closed-trade journal is `keel insights journal`, your own journal is `keel
870+
journal list`, and in the browser every fill is still a Timeline `trade` row and every journal
871+
entry a Timeline `attestation` row. A live rule's track record is `keel insights summary --mode
872+
live`.
873+
861874
**Halting from the browser.** Pulse carries a HALT button. It engages the kill switch through the
862875
same operation `keel kill` performs (`POST /api/gates/halt`), after one confirm dialog and nothing
863876
else: stopping must never be slower than starting. It needs the console session and the gate token

‎keel/commands/insights.py‎

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -101,18 +101,23 @@ def shown_count(self) -> int:
101101
102102
It lives HERE rather than in a front-end because `total_count` alone does not answer "am I
103103
looking at a page"; the pair does, and the pair is the report's statement about itself.
104-
Every renderer -- the CLI's lines, the HTML, `keel/web/payload.py`'s JSON -- reads it
105-
instead of measuring the list again, so none of them can disagree about how many rows the
106-
same report held. `asdict()` does not include properties, so `--json`'s shape is
107-
unchanged.
104+
Every renderer -- the CLI's lines today, and `keel/web/payload.py`'s JSON while the console
105+
served a journal (until console PR3) -- reads it instead of measuring the list again, so
106+
none of them can disagree about how many rows the same report held. `asdict()` does not
107+
include properties, so `--json`'s shape is unchanged.
108108
"""
109109
return len(self.entries)
110110

111111

112112
# -- the equity curve (#537) --------------------------------------------------------------------
113113
#
114-
# The browser's one chart. It lives HERE, in the report layer, rather than in `keel/web/` -- and
115-
# the coordinates live here too, which is the part that looks misplaced and is not.
114+
# **No front-end draws this today.** The console drew it (and `build_equity_series` below) on its
115+
# Insights view until console PR3 retired the charts; the builders and their tests stay, by that
116+
# PR's ruling, so a chart can come back as a client module alone, with no server work. The notes
117+
# below describe the contract any such chart must keep.
118+
#
119+
# It lives HERE, in the report layer, rather than in `keel/web/` -- and the coordinates live here
120+
# too, which is the part that looks misplaced and is not.
116121
#
117122
# **Scaling a chart is a judgement about the data, not a drawing detail.** Where the vertical
118123
# axis starts decides what the picture says: a curve plotted between its own min and max makes a
@@ -124,10 +129,10 @@ def shown_count(self) -> int:
124129
#
125130
# The second reason is arithmetic. Normalising a series means subtracting, dividing and comparing
126131
# it, and the client is the one place in this system where those operations happen in IEEE-754
127-
# doubles over values that were exact `Decimal`s a moment earlier. `keel/web/static/js/chart.js`
128-
# consequently contains no arithmetic at all and `tests/web/test_client_assets.py` scans it for
129-
# the absence, exactly as it scans `render.js` -- which is only possible because the numbers it
130-
# draws with arrive finished.
132+
# doubles over values that were exact `Decimal`s a moment earlier. The console's `chart.js`
133+
# (deleted in console PR3) consequently contained no arithmetic at all and was scanned for the
134+
# absence exactly as `render.js` still is -- which was only possible because the numbers it drew
135+
# with arrived finished.
131136
#
132137
# What it would take to move: a chart with a zoom or a pan control. That is interaction over a
133138
# range the server did not choose, and at that point the range becomes a client concern and this
@@ -558,6 +563,18 @@ def _plot_x(index: int, total: int) -> Decimal:
558563
return (PLOT_WIDTH * Decimal(index) / Decimal(total - 1)).quantize(_COORD)
559564

560565

566+
def _percent_units(fraction: float) -> Decimal:
567+
"""A 0..1 fraction in percent units (`0.415` -> `41.5`), with the multiply's scale artifact
568+
removed.
569+
570+
The conversion lives HERE, in the report, for the reason `commands/slippage.py::_bp` gives:
571+
`payload.percent` takes a figure already in percent units and the serialiser must not
572+
rescale -- that would be "the serialiser computing a figure the report never held". The
573+
shortest round-trip repr goes in, so `0.3` is `30` and not `30.000000000000004`.
574+
"""
575+
return (Decimal(repr(fraction)) * 100).normalize()
576+
577+
561578
@dataclass(frozen=True)
562579
class GateDistance:
563580
rule_name: str
@@ -574,6 +591,16 @@ class GateDistance:
574591
passing: bool
575592
blocking_reasons: list[str]
576593

594+
@property
595+
def win_rate_pct(self) -> Decimal:
596+
"""`win_rate` in percent units, for the web page. The CLI keeps printing the fraction."""
597+
return _percent_units(self.win_rate)
598+
599+
@property
600+
def min_win_rate_pct(self) -> Decimal:
601+
"""`min_win_rate` in percent units, for the web page."""
602+
return _percent_units(self.min_win_rate)
603+
577604

578605
@dataclass(frozen=True)
579606
class RuleTrackRecord:
@@ -596,6 +623,11 @@ class RuleTrackRecord:
596623
"""`None` for any status other than `paper` -- a `candidate` hasn't backtested yet and a
597624
`live`/`disabled` rule has already cleared (or been pulled from) the gate this measures."""
598625

626+
@property
627+
def win_rate_pct(self) -> Decimal:
628+
"""`win_rate` in percent units, for the web page. The CLI keeps printing the fraction."""
629+
return _percent_units(self.win_rate)
630+
599631

600632
@dataclass(frozen=True)
601633
class AccountSummary:

‎keel/commands/journal.py‎

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
33
The `journal` table has been in the schema from the beginning with no repository method and no
44
caller -- dead schema, which is worse than no schema because a reader assumes a declared table is
5-
a used one. This is its wiring: a CLI that writes it, a report both front-ends read, and a place
6-
in the audit chain beside everything else a human swore to.
5+
a used one. This is its wiring: a CLI that writes it, a report `keel journal list` reads, and a
6+
place in the audit chain beside everything else a human swore to -- which is where the console
7+
shows it, as Timeline `attestation` rows (its own section left with the Insights view in console
8+
PR3).
79
810
── WHY THIS RECORD IS DIFFERENT FROM EVERY OTHER RECORD HERE ────────────────────────────────────
911
@@ -75,7 +77,7 @@
7577

7678
@dataclass(frozen=True)
7779
class JournalEntry:
78-
"""One entry, as both front-ends read it.
80+
"""One entry, as `keel journal list` reads it.
7981
8082
Every field but `id`/`ts` is optional, and `None` means DID NOT SAY -- never a zero, an empty
8183
string, or a `False`. `rules_followed` in particular is three-valued: `False` is the operator
@@ -93,15 +95,6 @@ class JournalEntry:
9395
screenshot_ref: str | None
9496

9597

96-
#: How many entries the console shows without being asked for more.
97-
#:
98-
#: The journal has its OWN cap and is deliberately not paged by `/api/journal`'s `?limit=`. That
99-
#: parameter is the closed-trade table's page control; applying it here was a coincidence of the
100-
#: two records sharing a route, and it meant narrowing to one trade silently hid 300 of an
101-
#: operator's 301 notes.
102-
DEFAULT_NOTES_LIMIT = 50
103-
104-
10598
@dataclass(frozen=True)
10699
class JournalReport:
107100
now_ts: int
@@ -147,8 +140,8 @@ def gather_journal(repo: Repository, *, now_ts: int, limit: int | None = None) -
147140
148141
A NEGATIVE OR ZERO LIMIT IS REFUSED rather than obeyed. SQLite reads a negative `LIMIT` as
149142
unbounded, so `--limit -1` would silently print everything; a zero returns no rows, and an
150-
empty result is indistinguishable from an empty journal on both front-ends -- which is exactly
151-
the hazard `keel/web/api.py::_journal_limit` was written to name. Refusing is the only reading
143+
empty result is indistinguishable from an empty journal to whoever reads it -- which is exactly
144+
the hazard `keel/web/api.py::_whole_number` was written to name. Refusing is the only reading
152145
that cannot lie.
153146
"""
154147
if limit is not None and limit < 1:

‎keel/commands/orders.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,9 @@
8383
#: table, with the same reasoning, as `activity._SCOPE_DAYS`.
8484
_SCOPE_DAYS: dict[str, int] = {"today": 1, "7d": 7}
8585

86-
#: Rows served when a caller names no limit. The journal's cap (`api.DEFAULT_JOURNAL_LIMIT`),
87-
#: for the same reason: a bounded read against a book that may grow for years, sized so the
88-
#: common case is never truncated at all.
86+
#: Rows served when a caller names no limit. The cap the console's journal used (retired in
87+
#: console PR3), for the same reason: a bounded read against a book that may grow for years,
88+
#: sized so the common case is never truncated at all.
8989
DEFAULT_ORDERS_LIMIT = 50
9090

9191
#: The ceiling on a caller-supplied limit. A row count from a query string is a memory

‎keel/commands/slippage.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -209,11 +209,11 @@ def gather_slippage(repo: Repository, config: Config, *, now_ts: int) -> Slippag
209209
"""The configured universe, each product priced from its own cached liquidity.
210210
211211
Rows come back ordered by PRODUCT ID -- the same order `keel simulate` already prints its
212-
slippage table in. Not by cost, and that is deliberate under the Strathern rail this whole
213-
`/research` surface sits under: a cost table sorted cheapest-first reads as a shortlist of
214-
what to trade, and a page that ranks the universe by anything is doing selection while
215-
calling itself reporting. Alphabetical is also stable, so the table does not reshuffle
216-
between polls as candles arrive.
212+
slippage table in. Not by cost, and that is deliberate under the Strathern rail the whole
213+
research record (on the console's Rules view) sits under: a cost table sorted cheapest-first
214+
reads as a shortlist of what to trade, and a page that ranks the universe by anything is
215+
doing selection while calling itself reporting. Alphabetical is also stable, so the table does
216+
not reshuffle between polls as candles arrive.
217217
"""
218218
from keel.commands._products import _default_sim_products
219219
from keel.strategy import backtest as backtest_mod

0 commit comments

Comments
 (0)