Skip to content

feat(tui): add the v activity feed -- what keel has been DOING, cycle by cycle - #235

Merged
eaitbrahim merged 1 commit into
mainfrom
feat/tui-activity-overlay
Aug 11, 2026
Merged

feat(tui): add the v activity feed -- what keel has been DOING, cycle by cycle#235
eaitbrahim merged 1 commit into
mainfrom
feat/tui-activity-overlay

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

The problem

keel has been running fine for weeks and has not traded. The dashboard makes that look like death — and it cannot help, because every one of its six panels reports state, and state is exactly what a correctly-idle deployment and a crashed one have in common: the same zeroes, the same empty position list, the same flat equity.

What is missing is the narrative, and the narrative already exists. Every cycle emits agent.cycle_start, agent.feed_polled, agent.signals_evaluated, engine.setup_detected/setup_rejected, guards.check_failed, agent.enter_evaluated into the structured JSONL engine log, all correlated by the cycle_id keel/agent.py binds via keel_core.telemetry.bind_cycle. Nothing surfaced it.

What this adds

v — a chronological activity feed, newest first, one row per engine cycle, expandable to the events inside it. A quiet cycle still gets a row: the run of quiet cycles is the answer to "is it alive", and a feed that omitted them would reproduce the very impression it exists to fix.

  • keel/commands/activity.py — new, pure, no curses: bounded tail read, parse, group by cycle_id, summarise, render to plain text. The whole testable core.
  • keel/commands/tui.py — the v overlay, styling what those renderers already produced, exactly as i/s/p/d do for insights/admission. New key v (verified free), help section, footer row.

Real-data proof

Run against the actual deployment log, ~/keel/logs/keel.log (815 KB, read-only, untouched):

# /Users/…/keel/logs/keel.log  (815357 bytes)
# read+group+summarise: 3.7 ms   status=ok  records=329  skipped=0  cycles=77

   when                 mode    sig  blk  ent  exi  err  what happened
 ▸ 2026-08-11 09:00:09  paper     0    0    0    0    0  5 products / turtle_breakout  gate rejected: choppy_regime (PAXG-USD)
 ▸ 2026-08-10 09:00:01  paper     0    0    0    0    0  5 products / turtle_breakout  quiet -- looked, nothing to do
 ▸ 2026-08-09 09:00:04  paper     0    0    0    0    0  5 products / turtle_breakout  quiet -- looked, nothing to do
 ▸ 2026-08-08 09:00:06  paper     1    1    0    0    0  5 products / turtle_breakout  rail veto: per_asset_concentration_cap | rail veto: monthly_subscription_allowance | not placed: paper: vetoed by rails
 ▸ 2026-08-07 09:00:05  paper     0    0    0    0    0  5 products / turtle_breakout  quiet -- looked, nothing to do
 ▸ 2026-08-06 09:00:03  paper     0    0    0    0    0  5 products / turtle_breakout  quiet -- looked, nothing to do
 ▸ 2026-08-05 09:00:01  paper     0    0    0    0    0  5 products / turtle_breakout  quiet -- looked, nothing to do
 ▸ 2026-08-05 08:12:58  --        0    0    0    0    2  uncorrelated events (no cycle_id)  error: cb_client.accounts_fetch_failed | error: executor.quote_fetch_failed

That is the answer to "why hasn't it traded", in eight lines: it looks every day, it found exactly one setup in a week, two rails vetoed it, and the account reads have been failing separately the whole time.

Enter on the 2026-08-08 row:

>▾ 2026-08-08 09:00:06  paper     1    1    0    0    0  5 products / turtle_breakout  rail veto: per_asset_concentration_cap | rail veto: monthly_subscription_allowance | not placed: paper: vetoed by rails
      09:00:06  agent.cycle_start               cycle begins
      09:00:11  agent.feed_polled               polled 605 candles, 5 rules: ADA-USD, BTC-USD, ETH-USD, PAXG-USD, XLM-USD
      09:00:11  agent.mode_resolved             mode=paper
      09:00:11  agent.paper_equity              equity=11000 drawdown total=0 weekly=0
      09:00:11  engine.no_signal                ADA-USD turtle_breakout: no signal at gate 'donchian_high' -- close=0.20076 entry_level=0.21146 gap=5.32%
      09:00:11  agent.signals_evaluated         ADA-USD: 0 signal(s) from 1 rule(s)
      09:00:11  engine.no_signal                BTC-USD turtle_breakout: no signal at gate 'donchian_high' -- close=64891.61 entry_level=66923.95 gap=3.13%
      09:00:11  agent.signals_evaluated         BTC-USD: 0 signal(s) from 1 rule(s)
      09:00:11  engine.no_signal                ETH-USD turtle_breakout: no signal at gate 'donchian_high' -- close=1913.15 entry_level=1979 gap=3.44%
      09:00:11  agent.signals_evaluated         ETH-USD: 0 signal(s) from 1 rule(s)
      09:00:11  engine.setup_detected           PAXG-USD turtle_breakout/signal_candle cts=5 entry=4342.52 stop=4197.09 target=5215.07
      09:00:11  agent.signals_evaluated         PAXG-USD: 1 signal(s) from 1 rule(s)
      09:00:11  guards.check_failed             PAXG-USD BUY VETOED -- per_asset_concentration_cap: PAXG exposure 3284.671252850915628790264696 exceeds 0.5 of max_exposure_usd (2500.0)
      09:00:11  guards.check_failed             PAXG-USD BUY VETOED -- monthly_subscription_allowance: month-to-date BUY spend 0 + 3284.671252850915628790264696 = 3284.671252850915628790264696 exceeds the allowance cap 500 -- remaining allowance 500
      09:00:11  agent.enter_evaluated           PAXG-USD turtle_breakout/signal_candle cts=5 -> NOT PLACED -- paper: vetoed by rails
      09:00:11  engine.no_signal                XLM-USD turtle_breakout: no signal at gate 'donchian_high' -- close=0.161297 entry_level=0.215471 gap=33.58%
      09:00:11  agent.signals_evaluated         XLM-USD: 0 signal(s) from 1 rule(s)

And the 2026-08-11 gate rejection:

>▾ 2026-08-11 09:00:09  paper     0    0    0    0    0  5 products / turtle_breakout  gate rejected: choppy_regime (PAXG-USD)
      09:00:09  agent.cycle_start               cycle begins
      09:00:13  agent.feed_polled               polled 605 candles, 5 rules: ADA-USD, BTC-USD, ETH-USD, PAXG-USD, XLM-USD
      09:00:14  engine.setup_rejected           PAXG-USD turtle_breakout: REJECTED by gate 'choppy_regime'

Design decisions

Source is the log, not the database, and not a new table. A table would have been tidier and wrong: it would start empty on precisely the deployment this exists to explain, and stay uninformative for however many weeks it took to fill. The log is already months deep. No schema change, no migration, no engine change.

Why a file read is admissible under the TUI's iron rule. The rule (in tui.py's own docstring, for the s overlay) is "DB reads only; never builds a broker, never touches the network." Reading a local file is neither: no broker is constructed, no socket opened, no name resolved. The rule exists so that opening an overlay can never place an order, spend money, or block on a remote host — a bounded read of a file on the same disk violates none of that. It is the same latitude p propose already takes for config.proposals_dir. The network-exception count stands unchanged at three, and a test asserts the overlay adds zero broker constructions.

The read is bounded, because the log grows without limit and the overlay rebuilds every poll. A dashboard whose responsiveness degrades with deployment age would be a worse bug than the one being fixed. Named constants: newest 1 MiB, 5000 lines, 200 cycles, 400 events/cycle. 1 MiB covers the whole current 815 KB log while staying a fixed few-ms read against a 25 MB rotated one. When a cap bites the feed says so instead of presenting its window as history.

Log path comes from logging.file in config.yaml (keel_core.config.LoggingConfig, default logs/keel.log) — the same setting configure_logging attaches its handler to, so no new setting was invented. It is relative, resolved against the working directory, which is why running from ~/keel works and why a missing file reports the resolved absolute path and names the working-directory trap explicitly.

Degradation — each with a test

case behaviour
file missing "No engine log found at <abs path>" + the cwd trap + how to set an absolute logging.file
file empty says so, and points at logging.verbose: false being the default that records only errors
nothing parseable unparseable — distinguished from empty, because the fixes differ
partial JSON line (crash mid-write) skipped, counted, reported in the footer; surrounding records unaffected
line is valid JSON but not an object same
no cycle_id (the 64 fetch failures) grouped by contiguity + a 60 s gap into synthetic rows, never dropped
missing ts borrows the neighbour's (append-ordered file); skipped only if there is no predecessor
ts = inf / nan / year 5,000,000 treated as missing — strftime raises on these, and the renderer runs on the repaint path
signal_count: "inf" int(inf) raises OverflowError, not ValueError — caught, so one bad line cannot empty a whole feed
unexpected/missing fields every access is a .get with a ? fallback; an unknown event renders its own key=value fields
rotated log reopened by path each build, so the new small file just reads; rotated-away history is not chased
non-UTF-8 bytes errors="replace" — one replacement character, not a dead overlay
window holds no whole record oversized, not empty — "engine log is empty (815357 bytes)" would be self-contradictory
unreadable / a directory / permissions one sentence with the reason
open_state() raising mid-poll the branch wraps the render as well as the build, so nothing escapes curses.wrapper

A broken log always degrades to an informative message. Never a traceback, never a blank overlay — a blank overlay would be indistinguishable from the dead-looking dashboard this replaces.

Keys

v opens (verified unused). Inside: up/k and down/j move the selected row, PgUp/PgDn/Home/End by page or to either end, Enter/Space expand or collapse, q/Esc/v close. Up/down move a row rather than a line because a row can be one line or seventy — scrolling by lines through an expanded cycle would make selecting the next cycle a matter of counting its events.

Style follows the dashboard's existing convention: quiet is muted, withheld/vetoed is warn (the system working as designed, not an emergency — the same distinction _admission_line_style draws for a REJECT), a real fill is ok, an ERROR run is alert.

Gates

$ uv run ruff check keel tests packages scripts
All checks passed!

$ uv run pytest -q
2607 passed, 1 skipped in 34.80s
SKIPPED [1] packages/keel-broker-api/keel_broker_api/conformance/suite.py:254:
           adapter serves no granularity the suite could exercise

$ uv run mypy
Success: no issues found in 221 source files

Baseline on main was 2456 passed / 1 skipped; this adds 151 tests and the same single expected skip.

🤖 Generated with Claude Code

…le by cycle

keel has been running fine for weeks and has not traded, and the dashboard
makes that look like death. It cannot help: every one of its six panels
reports STATE, and state is exactly what a correctly-idle deployment and a
crashed one have in common -- the same zeroes, the same empty position list,
the same flat equity. What is missing is the narrative.

The narrative already exists. Every cycle emits `agent.cycle_start`,
`agent.feed_polled`, `agent.signals_evaluated`, `engine.setup_detected` /
`setup_rejected`, `guards.check_failed` and `agent.enter_evaluated` into the
structured JSONL engine log, all correlated by the `cycle_id` `keel/agent.py`
binds via `keel_core.telemetry.bind_cycle`. Nothing surfaced it.

`v` does. A chronological feed, newest first, ONE ROW PER CYCLE, expandable
to the events inside it. A quiet cycle still gets a row -- the run of quiet
cycles IS the answer to "is it alive", and a feed that omitted them would
reproduce the very impression it exists to fix.

The source is the LOG, not the database, and not a new table. A table would
have been tidier and wrong: it would start EMPTY on precisely the deployment
this feature exists to explain, and stay uninformative for however many weeks
it took to fill. The log is already months deep. No schema change, no
migration, no engine change -- `keel/commands/activity.py` only reads.

That makes `v` the one overlay that reads a file rather than the DB, so the
module and the TUI docstring both say plainly why that is admissible. The
dashboard's iron rule is "DB reads only; never builds a broker, never touches
the network". Reading a local file is NEITHER: no broker, no socket, no name
resolution. The rule exists so opening an overlay can never place an order,
spend money, or block on a remote host, and a bounded local read violates
none of that -- the same latitude `p` propose already takes for
`config.proposals_dir`. The network-exception count stands at three.

The read is BOUNDED because the log grows without limit and this rebuilds on
every poll: the newest 1 MiB, 5000 lines, 200 cycles, 400 events per cycle,
all named constants. On the real deployment's 815 KB log that is the whole
file in 3.7 ms; against a 25 MB rotated one the cost is unchanged. When a cap
bites, the feed says so rather than quietly presenting its window as history.

Everything degrades to a sentence. Missing file (with the working-directory
trap named, since that is the commonest cause), empty file, permissions
error, a partial JSON line from a crash mid-write, a line that is valid JSON
but not an object, a record with no `ts`, a record with no `cycle_id` at all
(the 64 uncorrelated fetch failures in the real log are grouped by contiguity
rather than dropped), a `ts` no `strftime` can format, a garbled count, a
rotated log, a window holding no whole record -- each has a defined outcome
and a test. Never a traceback, and never a blank overlay.

The parsing, grouping and summarising are pure functions in a separate module
with 116 tests built from the real event shapes; `tui.py` only styles what
they render, exactly as it already does for `insights` and `admission`.
@eaitbrahim eaitbrahim added the enhancement New feature or request label Aug 11, 2026
@eaitbrahim
eaitbrahim merged commit 8829781 into main Aug 11, 2026
1 check passed
@eaitbrahim
eaitbrahim deleted the feat/tui-activity-overlay branch August 11, 2026 15:32
eaitbrahim added a commit that referenced this pull request Aug 11, 2026
The `v` activity feed (#235) showed every cycle the bounded read window held
-- 77 of them on the real deployment, going back three weeks. The instruction
is that the feed shows TODAY: "what has keel been doing" means today unless
asked otherwise, and a fortnight of scrollback is not an answer to it.

The scoping lives in the pure layer (`apply_scope`), not in the curses code,
and takes its "now" as a parameter so the day boundary is injectable and every
test of it is deterministic on whatever day it runs. "Today" is the local
CALENDAR day -- derived through `datetime.date` in the same local clock
`_stamp` renders timestamps in, so it survives DST (and errs early rather than
late in the zones where midnight itself does not exist), and is not a rolling
24 hours, which would put yesterday's 09:00 cycle on screen every morning and
drop it every afternoon.

`t` inside the overlay cycles today -> 7 days -> all, and the scope resets to
today on every open and every close. A widened view answers one question once;
it never becomes tomorrow's default.

The delicate part is that the deployment runs ONCE A DAY at 09:00, so "today"
holds at most one row and none at all before 09:00 -- and a blank panel would
be strictly worse than the state-only dashboard this whole feature exists to
fix, since a blank panel and a dead agent look identical. So an empty day is
never blank. It says, in this order: that keel has not run YET today; the
timestamp of the last cycle and how long ago that was; and when the next one
is due, inferred from that cycle's own time of day (or that its usual time has
passed, which is the case worth acting on). Naming one timestamp is a status
line, not a feed -- no historical row is rendered.

The read bounds are unchanged (1 MiB / 5000 lines / 200 cycles / 400 events),
and their interaction with a day filter is reported rather than glossed:
`scope_fully_covered` records whether the window PROVED it reached back past
midnight, and both the footer and the empty state say so when it did not,
instead of letting an unread morning read as a quiet one.

Real deployment log, unchanged on disk, at 11:53 today:

    scope: today (2026-08-11)  ·  1 cycle  ·  76 older hidden  ·  press t to widen
     ▸ 2026-08-11 09:00:09  paper  0 0 0 0 0  gate rejected: choppy_regime (PAXG-USD)

and the same log as it stood at 07:00 this morning, before that cycle ran:

    keel has not run yet today.
    Last cycle: 2026-08-10 09:00:01 -- yesterday, 21h 59m ago.
    Next cycle due today around 09:00 local -- in 2h 00m.

Gates: ruff clean, mypy clean, 2664 passed / 1 skipped (from 2607 on main --
57 new tests, same single expected skip).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim added a commit that referenced this pull request Aug 11, 2026
…the CTS scoring fix (#241)

A minor bump, not a patch, for three reasons that each require operator action or
change behaviour the deployment is currently relying on.

SCHEMA. `SCHEMA_VERSION` goes 9 -> 10 (#223). Both deployed databases are at 9 and
must be migrated before this build can use them.

BEHAVIOUR REQUIRING OPERATOR ACTION. #223 adds a second attested claim -- what
CONTRACT a venue listing is, not only what the underlying asset is. It fails closed
with no backfill, deliberately, so after this lands `keel assets screen` REJECTS
every product with `instrument_wrapper: UNATTESTED` until `keel assets
attest-instrument` is run once per product. Live trading is unaffected: rail 1 gates
buys on `config.allowlist`, not on the screen.

LIVE SCORING CHANGED. #227 fixed `is_round_number`, which returned True for every
2dp-quoted price and so handed BTC/ETH/PAXG a free CTS point on every bar. Scores on
those three assets are genuinely lower under this build than under 0.5.7.

Also ships: the Robinhood crypto adapter behind the broker port (#216/#218/#222/#229,
not wired to the live path), the TUI activity feed (#235/#237), the CTS factor
collinearity study (#224), `Preview.synthetic` at the confirm gate (#221), rail 9
seeing a bracket's own stop (#212), and CI gating merges on the `test` check
(#234/#238).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant