Skip to content

Commit 71b0ebe

Browse files
eaitbrahimclaude
andauthored
console: Pulse home view with HALT; Status, Activity and Gates views retired (#13 PR1) (#956)
* dca: name the cadence expression once (is_cadence_day, next_cadence_day_ts) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * commands: pulse service (today, why-not, next DCA cadence day, unbracketed) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * web: pulse_payload Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * pulse: name rail 17 wherever status says entries are halted Every non-paper mode, and suspended as well as expired/unattested -- the same set status._rail17_line renders as "entries halted". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * web: /api/pulse replaces /api/status, /api/activity and /api/gates read_pulse composes one status report, one log window scoped to today and the live DCA rules, and carries the halt's gate token on this endpoint only. status_payload, gates_payload and _capability_payload go with their routes; the shared _equity/_drawdown/_rule_counts helpers stay (pulse uses them) and so does _status_report (insights and journal read it). The client route tables still name the retired views until the next commit, so test_the_api_routes_cover_every_html_route_that_reads is red here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * console: Pulse home view with HALT; Status, Activity and Gates retired pulseView is ROUTES[0] and the default: state cards, today's counts, why keel didn't trade, next DCA cadence day, open positions, rules, freshness, subscriptions and today's cycles, all from /api/pulse. The HALT form posts to /api/gates/halt behind one native confirm; while the kill switch is engaged the same card is a HALTED line naming `keel resume` from the payload. The client has no resume control and names no resume route. api.js gains runHalt over a shared post(); actions.js holds the gate token beside the CSRF token. statusView, activityView, gatesView and their route entries, nav links, tests and pins are removed in lockstep. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: console views after PR1; plan copy The runbook names Pulse and the views that remain, says the console is being slimmed to four, and documents halting from the browser. Its view-name pin now reads staticfiles.CLIENT_ROUTES instead of a literal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * console: the halt sends no body, because its route reads none Found by the browser check: the halt POST carried `{}` and the halt route never reads a body, so on a kept-alive connection the unread bytes prefixed the next request line -- the repaint's GET /api/pulse arrived as `{}GET` and was refused 501. post() now sends a body (and the JSON content type) only when given one; setup actions still send theirs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * web: retired client routes still serve the shell, so old links land on Pulse without a service worker (F1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * console: Pulse never shows an unread log as zeros; the key checks see every Pulse read (F2-F7) - read_pulse keeps a non-ok window's own status and detail (F2) - today figures are absent when the log is unread, warn when the window began after midnight, and the empty-reasons sentence is the service's own (F3) - pulseView's exact key set is pinned; the feed caveats read data.activity.* so the scan sees them (F4, F5) - the CSRF-token-absent check runs on a deployment with a real pulse body (F6) - comment and test-name drift from the rename; plan copy no longer claims the SW cache is keyed to the commit (F7, H5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * console: Pulse's empty-today card is the service's sentence, judged per basis (R2-F1); plan and comment drift (R2-F2, H7) - PulseReport.no_cycles_line is empty when a cycle ran or the log could not be read - render.js shows the card on the string's presence and no longer words 'keel has not run' - duplicate plan sentence removed; 'seven names' comments no longer hard-code a count Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * console: Pulse's cycle table claims nothing about the engine; the client half of R2-F1 and the Rules section are pinned (R3-F1..F4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent dca1d1f commit 71b0ebe

29 files changed

Lines changed: 2854 additions & 871 deletions

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ keel/ the agent and CLI
244244
├── execution/sizing.py position sizing
245245
├── compliance/screen.py attested allowlist admission (fails closed)
246246
├── research/ the thirteen modules behind `keel research`
247-
├── web/ the local console `keel serve` renders, read-only
247+
├── web/ the local console `keel serve` renders — reads, setup steps and a halt
248248
├── mcp/ the read-only MCP server `keel mcp` speaks
249249
└── commands/ CLI command implementations
250250

‎docs/operator-runbook.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -836,9 +836,11 @@ Rewards item: account settings no rail can see, re-checked after any account cha
836836

837837
## The operator console, in a browser
838838

839-
`keel serve` opens keel's **operator console**: a local web page showing Status, Setup, Activity,
840-
Insights, Rules, Venues and Gates, over the same `keel/commands/*` service layer the CLI commands
841-
call. An architectural test (`tests/commands/test_console_thinness.py`) pins that thinness -- the
839+
`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
841+
same `keel/commands/*` service layer the CLI commands call. It is being slimmed to four engine
842+
views -- Pulse, Holdings, Rules, Audit & Setup; see
843+
`docs/superpowers/plans/2026-10-10-console-four-views.md`. An architectural test (`tests/commands/test_console_thinness.py`) pins that thinness -- the
842844
front-end renders and dispatches, and every behaviour comes from the services.
843845

844846
```bash
@@ -848,6 +850,12 @@ keel --config config.live-sandbox.yaml --db keel-live.db serve
848850
It binds loopback and prints a URL carrying a one-time token for that run. Stopping the server
849851
invalidates it: the token is new every run.
850852

853+
**Halting from the browser.** Pulse carries a HALT button. It engages the kill switch through the
854+
same operation `keel kill` performs (`POST /api/gates/halt`), after one confirm dialog and nothing
855+
else: stopping must never be slower than starting. It needs the console session and the gate token
856+
Pulse hands the page, and it works from a tunnel as well as from loopback. There is no browser
857+
resume: once halted, Pulse shows a HALTED line, and resuming is `keel resume` at a terminal.
858+
851859
**Whether that token touches disk depends on who is watching (#756).** Started from a terminal --
852860
the command above -- nothing is written, and the URL exists only in your scrollback. Started
853861
*detached*, where nothing can read stdout, `keel serve` records the address in

‎docs/superpowers/plans/2026-10-10-console-four-views.md‎

Lines changed: 615 additions & 0 deletions
Large diffs are not rendered by default.

‎keel/commands/plans.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -257,9 +257,9 @@ def every_claim() -> Iterator[Claim]:
257257

258258
@dataclass(frozen=True)
259259
class PlansReport:
260-
#: NO TIMESTAMP, deliberately, and the sibling settles it: `gates_payload` describes capability
261-
#: rather than deployment and takes no `now_ts` at all. Every other view stamps itself because
262-
#: its figures were observed at a moment; nothing on this page was observed. The first cut sent
260+
#: NO TIMESTAMP, deliberately: this describes the project rather than a deployment and takes no
261+
#: `now_ts` at all. Every other view stamps itself because its figures were observed at a
262+
#: moment; nothing on this page was observed. The first cut sent
263263
#: `as_of` and `generated_at` and rendered neither -- a stamp nobody shows is a stamp that will
264264
#: eventually be shown, and it would date a page whose content changes only when a document
265265
#: does.

‎keel/commands/pulse.py‎

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
"""The console's Pulse home view, as a service -- the glance page's few new judgements.
2+
3+
A VIEW over `StatusReport`, `ActivityFeed` and `existing_dca_rules`: it reads nothing the CLI
4+
cannot and computes only what no existing report holds -- today's sums, the why-not list, the
5+
next DCA cadence day, the unbracketed roll-call. The halt is not here: `keel/web/gates.py` owns
6+
it ("stopping must never be slower than starting"), and this module only names the command that
7+
undoes it (`RESUME_COMMAND`), so the client never has to.
8+
9+
**A pure service: no click, no clock** (the split `keel/commands/dca_plan.py` documents). `now_ts`
10+
is the caller's one read of the clock, and the activity feed arrives already scoped to "today" --
11+
this module never decides which day it is. `gather_pulse` is the seam `keel/web/api.py` calls;
12+
`build_pulse` is the same composition over already-gathered inputs, which is what lets a test
13+
hand it a report without standing up a ledger.
14+
15+
**Why-not is a list of the engine's own sentences, never a diagnosis.** Every entry is either a
16+
standing blocker this module reads off `StatusReport` (kill switch, rail 11, rail 17) or a
17+
highlight `activity.summarise_cycle` already wrote for a cycle (`_WHY_PREFIXES`). Nothing here
18+
re-derives a rail's verdict: a reason that is not in the log or the report is not offered.
19+
20+
**The next DCA buy is a cadence DAY, not an order time.** `Dca.detect` keys the buy off the
21+
COMPLETED daily bar's day (`is_cadence_day`); the order is placed by the first cycle that sees
22+
that bar, and when that cycle runs is the scheduler's business, which this module does not know.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
from dataclasses import dataclass
28+
from decimal import Decimal
29+
30+
from keel.commands.activity import ActivityFeed
31+
from keel.commands.dca_plan import ExistingDcaRule, existing_dca_rules
32+
from keel.commands.status import OpenPositionStatus, StatusReport, gather_status
33+
from keel.commands.trading import KILL_ENGAGED_LINE
34+
from keel.config import Config
35+
from keel.data.repository import Repository
36+
from keel.strategy.rules.dca import next_cadence_day_ts
37+
38+
#: What undoes a halt. The CLI's own command name; the console shows it and never performs it.
39+
RESUME_COMMAND = "keel resume"
40+
41+
#: The `activity.summarise_cycle` highlights that say why an entry did not happen -- matched on
42+
#: their leading words, the way that function writes them. `ENTERED`/`EXITED`/`error:`/`external
43+
#: flow` highlights are notable but are not reasons, so they are not here.
44+
_WHY_PREFIXES = (
45+
"rail veto: ",
46+
"not placed: ",
47+
"gate rejected: ",
48+
"entries withheld",
49+
"cycle skipped: ",
50+
"stale feed: ",
51+
)
52+
53+
54+
#: The attestation states rail 17 halts entries on -- the ones `status._rail17_line` renders
55+
#: with "entries halted". "attested" passes; "unknown" is unreadable, and status says so itself.
56+
_RAIL17_HALTING = frozenset({"suspended", "expired", "unattested"})
57+
58+
59+
@dataclass(frozen=True)
60+
class TodayCounts:
61+
"""The scoped cycles' five counts (plus the cycle count), summed. `blocked` is the wide one
62+
`summarise_cycle` documents: entries withheld, rail-vetoed or otherwise not placed.
63+
64+
Every figure is `None` when the log could not be read (`today_basis == "unavailable"`): "not
65+
recorded" is not "recorded as zero", and summing nothing into six zeros is the always-passing
66+
fee rail's shape (#198)."""
67+
68+
cycles: int | None
69+
signals: int | None
70+
blocked: int | None
71+
entered: int | None
72+
exited: int | None
73+
errors: int | None
74+
75+
76+
@dataclass(frozen=True)
77+
class NextDcaBuy:
78+
rule_id: int
79+
product_id: str
80+
cadence_days: int
81+
budget_usd: Decimal
82+
#: 00:00 UTC of the cadence day (`dca.next_cadence_day_ts`) -- a day, not an order time.
83+
next_cadence_day_ts: int
84+
#: `ExistingDcaRule.budget_inferred`, carried through: the budget is the rule's default, not
85+
#: one the row stored.
86+
budget_inferred: bool
87+
88+
89+
@dataclass(frozen=True)
90+
class PulseReport:
91+
now_ts: int
92+
status: StatusReport
93+
#: Already scoped to "today" by the caller (`activity.apply_scope`).
94+
feed: ActivityFeed
95+
today: TodayCounts
96+
#: How much of the day `today` and `why_not` cover: `"complete"` (the log was read back past
97+
#: midnight, or it is a real empty file), `"partial"` (the bounded read began after midnight,
98+
#: so the sums are a lower bound -- `ActivityFeed.scope_fully_covered`) or `"unavailable"`
99+
#: (the log could not be read at all). Judged HERE, because the empty-reasons sentence below
100+
#: is only true for `"complete"`.
101+
today_basis: str
102+
#: The sentence for an empty `why_not`, worded for `today_basis`. Sent so the client never
103+
#: infers "nothing stopped keel" from the emptiness of a list.
104+
why_not_none: str
105+
#: The line for a Pulse with no cycle today, worded for `today_basis`: `""` when a cycle ran
106+
#: (nothing to say), when the log could not be read (the status card already says so, and
107+
#: "keel has not run" would be an engine claim the log never made) -- else the sentence. The
108+
#: client shows its card on the PRESENCE of this string, as the TUI says
109+
#: `activity.describe_empty_scope` only when the read was fine.
110+
no_cycles_line: str
111+
#: Newest cycle today, else the last one before the scope, else None (keel has no log yet).
112+
last_cycle_ts: float | None
113+
#: Ordered, de-duplicated sentences: standing blockers first, then today's cycle reasons.
114+
why_not: tuple[str, ...]
115+
#: LIVE `dca` rules only, in rule-id order.
116+
next_dca: tuple[NextDcaBuy, ...]
117+
#: Live-mode tranches whose bracket order is absent. Empty in paper, which never has one by
118+
#: construction (the judgement `payload._bracket_field` already makes).
119+
unbracketed: tuple[OpenPositionStatus, ...]
120+
#: `len(unbracketed)`, taken HERE: `payload.py` never counts (its Rule 6e).
121+
unbracketed_count: int
122+
#: `RESUME_COMMAND` when the kill switch is engaged, else "". Its presence is the client's
123+
#: only branch between a HALT button and a HALTED line.
124+
resume_command: str
125+
126+
127+
#: `ActivityFeed.status` values whose `cycles` are a real reading of the log (an `empty` file is
128+
#: a true zero). Every other status -- missing, unreadable, unparseable, oversized -- means the
129+
#: log says nothing about today.
130+
_READ_STATUSES = frozenset({"ok", "empty"})
131+
132+
_NOTHING_STOPPED = "Nothing stopped keel today."
133+
_WHY_NOT_NONE = {
134+
"complete": _NOTHING_STOPPED,
135+
"partial": (
136+
"No reason in the part of today's log that was read, and the log may not reach "
137+
"back to midnight, so reasons may be missing."
138+
),
139+
"unavailable": "Today's log could not be read, so reasons may be missing.",
140+
}
141+
142+
143+
_NO_CYCLES_LINE = {
144+
"complete": "keel has not run yet today.",
145+
"partial": "No cycle in the part of today's log that was read.",
146+
"unavailable": "",
147+
}
148+
149+
150+
def _today_basis(feed: ActivityFeed) -> str:
151+
if feed.status not in _READ_STATUSES:
152+
return "unavailable"
153+
if not feed.scope_fully_covered:
154+
return "partial"
155+
return "complete"
156+
157+
158+
def _today_counts(feed: ActivityFeed) -> TodayCounts:
159+
if feed.status not in _READ_STATUSES:
160+
return TodayCounts(
161+
cycles=None, signals=None, blocked=None, entered=None, exited=None, errors=None
162+
)
163+
cycles = feed.cycles
164+
return TodayCounts(
165+
cycles=len(cycles),
166+
signals=sum(c.signals for c in cycles),
167+
blocked=sum(c.blocked for c in cycles),
168+
entered=sum(c.entered for c in cycles),
169+
exited=sum(c.exited for c in cycles),
170+
errors=sum(c.errors for c in cycles),
171+
)
172+
173+
174+
def _last_cycle_ts(feed: ActivityFeed) -> float | None:
175+
if feed.cycles:
176+
return max(c.started_ts for c in feed.cycles)
177+
if feed.last_cycle_before_scope is not None:
178+
return feed.last_cycle_before_scope.started_ts
179+
return None
180+
181+
182+
def _why_not(status: StatusReport, feed: ActivityFeed) -> tuple[str, ...]:
183+
"""Standing blockers, then every reason today's cycles recorded, newest cycle first, each
184+
sentence once. Rail 17 is named in every mode but paper, for every state that
185+
`status._rail17_line` says halts entries (suspended, expired, never attested): it is a
186+
LIVE_STATE rail, not evaluated in paper, so a lapsed attestation there halts nothing."""
187+
reasons: list[str] = []
188+
if status.kill_switch_engaged:
189+
reasons.append(KILL_ENGAGED_LINE)
190+
if status.rail11_status == "HALTED":
191+
reasons.append(f"rail 11: {status.rail11_status}")
192+
attestation = status.withdrawal_attestation.state
193+
if status.mode != "paper" and attestation in _RAIL17_HALTING:
194+
reasons.append(f"rail 17: withdrawal attestation {attestation}")
195+
# `feed.cycles` is newest-first (`feed_from_lines`), so iteration order is the order asked for.
196+
for cycle in feed.cycles:
197+
for highlight in cycle.highlights:
198+
if highlight.startswith(_WHY_PREFIXES) and highlight not in reasons:
199+
reasons.append(highlight)
200+
return tuple(reasons)
201+
202+
203+
def _next_dca(rules: tuple[ExistingDcaRule, ...], now_ts: int) -> tuple[NextDcaBuy, ...]:
204+
return tuple(
205+
NextDcaBuy(
206+
rule_id=r.rule_id,
207+
product_id=r.product_id,
208+
cadence_days=r.cadence_days,
209+
budget_usd=r.budget_usd,
210+
next_cadence_day_ts=next_cadence_day_ts(now_ts, r.cadence_days),
211+
budget_inferred=r.budget_inferred,
212+
)
213+
for r in rules
214+
if r.status == "live"
215+
)
216+
217+
218+
def build_pulse(
219+
status: StatusReport,
220+
feed: ActivityFeed,
221+
dca_rules: tuple[ExistingDcaRule, ...],
222+
*,
223+
now_ts: int,
224+
) -> PulseReport:
225+
"""`gather_pulse`'s composition over inputs already in hand. PURE."""
226+
unbracketed = (
227+
()
228+
if status.mode == "paper"
229+
else tuple(p for p in status.open_positions if not p.has_bracket)
230+
)
231+
basis = _today_basis(feed)
232+
return PulseReport(
233+
now_ts=now_ts,
234+
status=status,
235+
feed=feed,
236+
today=_today_counts(feed),
237+
today_basis=basis,
238+
why_not_none=_WHY_NOT_NONE[basis],
239+
no_cycles_line="" if feed.cycles else _NO_CYCLES_LINE[basis],
240+
last_cycle_ts=_last_cycle_ts(feed),
241+
why_not=_why_not(status, feed),
242+
next_dca=_next_dca(dca_rules, now_ts),
243+
unbracketed=unbracketed,
244+
unbracketed_count=len(unbracketed),
245+
resume_command=RESUME_COMMAND if status.kill_switch_engaged else "",
246+
)
247+
248+
249+
def gather_pulse(
250+
repo: Repository, config: Config, feed: ActivityFeed, *, now_ts: int
251+
) -> PulseReport:
252+
"""The Pulse view's report: `gather_status` and `existing_dca_rules` read from the ledger,
253+
`feed` (scoped to "today" by the caller) read from the log. No broker, no network."""
254+
return build_pulse(
255+
gather_status(repo, config, now_ts), feed, existing_dca_rules(repo), now_ts=now_ts
256+
)

‎keel/strategy/rules/dca.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,26 @@
2626
_SECONDS_PER_DAY = 86_400
2727

2828

29+
def is_cadence_day(ts: int, cadence_days: int) -> bool:
30+
"""Whether the daily bar stamped `ts` is a cadence boundary. THE expression `Dca.detect`
31+
keys the buy off (`(ts // 86400) % cadence_days == 0`), in one place so a console that says
32+
"next buy" and the engine that places it cannot drift. `agent.py` (the stale-daily-bar
33+
gate) and `dca_plan.py` (the plan's cadence arithmetic) cite this expression in prose."""
34+
if cadence_days <= 0:
35+
raise ValueError("cadence_days must be positive")
36+
return (ts // _SECONDS_PER_DAY) % cadence_days == 0
37+
38+
39+
def next_cadence_day_ts(now_ts: int, cadence_days: int) -> int:
40+
"""00:00 UTC of the first cadence day on or after `now_ts`'s UTC day. A cadence DAY, not an
41+
order time: the buy is placed by the first cycle that sees that day's bar COMPLETED."""
42+
if cadence_days <= 0:
43+
raise ValueError("cadence_days must be positive")
44+
day = now_ts // _SECONDS_PER_DAY
45+
remainder = day % cadence_days
46+
return (day if remainder == 0 else day + cadence_days - remainder) * _SECONDS_PER_DAY
47+
48+
2949
class Dca(Rule):
3050
"""Cadence-based dip-buy accumulation rule.
3151
@@ -110,7 +130,7 @@ def detect(self, candles_by_tf: dict[Granularity, list[Candle]]) -> Setup | None
110130

111131
latest = candles[-1]
112132
cadence_days = self.params["cadence_days"]
113-
if (latest.ts // _SECONDS_PER_DAY) % cadence_days != 0:
133+
if not is_cadence_day(latest.ts, cadence_days):
114134
return None # off-cadence: no buy today
115135

116136
budget_usd: Decimal = self.params["budget_usd"]

0 commit comments

Comments
 (0)