Skip to content

feat(tui): reach the asset-admission gate from the dashboard (screen / propose / discover) - #176

Merged
eaitbrahim merged 4 commits into
mainfrom
feat/tui-admission-menu
Aug 7, 2026
Merged

feat(tui): reach the asset-admission gate from the dashboard (screen / propose / discover)#176
eaitbrahim merged 4 commits into
mainfrom
feat/tui-admission-menu

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

The allowlist-admission workflow — the halal asset gate — was CLI-only. This puts it on the operator dashboard: three new overlays, s screen, p propose, d discover, alongside the existing i insights.

Nothing here admits, attests, or trades. keel assets attest stays deliberately CLI-only: it is the one step in this gate that rests on human judgment rather than code, and it must not be a keystroke.

The overlays

  • s screen — the current allowlist's admission verdicts, with the reason for each. OFFLINE, DB reads only.
  • p propose — screens the newest *.json shortlist in config.proposals_dir (new setting, default ~/keel/proposals, ~ expanded at use rather than baked into a Config). OFFLINE. A missing directory, absent shortlist, unreadable file, bad JSON or malformed proposal each render as a calm, actionable explanation — never an error that takes the dashboard down. Invalid candidate entries are reported as INVALID, never dropped.
  • d discover — proposes NEW candidates from the venue's own product list.

Each reuses keel/proposer.py and the existing _screen_product gate verbatim rather than growing a second rendering or admission path.

d is gated harder than f is

Discover is the second deliberate network exception in a dashboard whose contract is read-only and offline. Pressing d makes no call at all — it opens ARMED, rendering an explanation of what running it will do and that it is a live venue call. Only Enter, pressed inside the overlay, makes the one list_products request. The result is then HELD: later polls repaint it without re-fetching, and closing discards it so reopening is armed again. It is bounded by a timeout, because the operator is blocked on it with the screen frozen — a hung venue becomes a retryable discover failed: line, not a dashboard whose only exit is Ctrl-C.

Two tests pin this by driving the real loop: zero list_products calls across d + several polls + close, and exactly one per Enter with no re-fetch on subsequent polls.

The correctness fix this uncovered

DATA_DERIVED_FAILURES claimed to hold every failure class "DOWNSTREAM of having no cached history" but omitted history itself. So a product with zero cached candles was reported as history: 0 daily bars, need 1460 — which reads as "this asset is too young" when the truth is "we have never fetched it". A candidate never fetched was indistinguishable, by its failure list alone, from one that genuinely is too young. Since screening an unfetched asset is the normal first step of admitting one, this was the common case, not the edge case.

The split and its explanation now live once, in screen.py beside the tag set that decides them:

split_failures(facts, result) -> (about_the_asset, about_our_cache)
missing_history_lines(product_id, not_assessable) -> list[str]

assets holdings --screen, assets propose, assets screen and the new TUI overlay all route through them, replacing three independent copies of the same logic. A zero-bar product now says "no local history — run keel fetch --products X first" and names what is unassessable until then, instead of asserting something false about the asset.

The suppression fires at exactly zero bars, so an asset with some history that still falls short is still reported as genuinely too young — pinned by counterpart tests on every surface, so the fix cannot decay into a blanket silencer.

keel assets screen was initially left out of that migration and a review caught it. It is the sibling of the new s overlay — both screen the same product set through the same gate — so leaving only one able to explain an empty cache would have handed an operator two different stories about the same allowlist depending on which surface they looked at.

Also

  • tui_cmd's docstring claimed the TUI "never touches the network except when explicitly asked (f)", while run_live has all along made an automatic ~30s get_accounts balance read — which run_live's own docstring acknowledged. The two contradicted each other before this branch. The docstring now enumerates all three network touches honestly.
  • run_live's help and insights branches each hand-rolled an identical scroll chain. Rather than copy it three more times, both now share one pure _scroll_offset, behaviour unchanged.

Verification

ruff clean, mypy clean, 2062 tests passing (1976 on main, +86).

Beyond running the suite, the two constraints that carry the safety weight were mutation-tested: removing history from the tag set fails 5 tests, and making discover fetch on open or on poll fails 4. Both were confirmed to fail for the right reason before the fix existed.

eaitbrahim and others added 4 commits August 7, 2026 12:18
…verdict

`DATA_DERIVED_FAILURES` claimed to hold every failure class "DOWNSTREAM of having
no cached history", but omitted `history` itself. So a product with zero cached
candles was reported as `history: 0 daily bars, need 1460` -- which reads as "this
asset is too young" when the truth is "we have never fetched it". A candidate that
was never fetched was indistinguishable, by its failure list alone, from one that
genuinely IS too young.

Adds `history` to the tag set, and moves the split + its explanation out of the two
call sites that each kept their own copy (`assets holdings --screen` and
`assets propose`) into `screen.py`, beside the tag set that decides it:

  split_failures(facts, result) -> (about_the_asset, about_our_cache)
  missing_history_lines(product_id, not_assessable) -> list[str]

The split only ever fires at EXACTLY zero bars, so a genuinely short history stays a
real verdict -- pinned by a counterpart test, so the fix cannot decay into a blanket
silencer.

`keel assets screen`'s output is deliberately untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rkflow

`keel/commands/admission.py` is to the allowlist-admission workflow what
`keel/commands/insights.py` is to the insights overlay: a curses-free, network-free
report layer the TUI will merely style.

- `build_screen_report`/`render_screen_report` screen the configured allowlist through
  an INJECTED `screen_fn` (`keel.cli._screen_product` in production), so no candidate
  source can drift onto a laxer path -- and route the zero-bars case through
  `screen.split_failures`/`missing_history_lines` rather than reimplementing it.
- `build_propose_view`/`render_propose_view` locate the newest `*.json` shortlist and
  reuse `keel/proposer.py`'s `parse_proposal`/`build_proposal_report`/
  `render_proposal_report` verbatim. NEVER raises: a missing directory, absent
  shortlist, unreadable file, bad JSON or malformed proposal each become a calm,
  actionable status -- a dashboard must not be killed by a stray file on disk.
- `build_discover_report`/`render_discover_report` are PURE over already-fetched venue
  products. Taking the product list as an argument, rather than building a broker, is
  what lets the caller gate the one network-touching action behind an explicit keypress.

Adds `Config.proposals_dir` (default `~/keel/proposals`, `~` expanded at use, not at
parse) so the shortlist location is configuration rather than a user-specific absolute
path hardcoded in library code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oard

Makes the halal asset-admission gate reachable from the operator dashboard instead of
CLI-only. Each overlay styles `keel/commands/admission.py`'s already-built report --
the same discipline `i` insights keeps toward `keel/commands/insights.py`.

- `s` screen   -- the allowlist's admission verdicts. OFFLINE, DB reads only.
- `p` propose  -- screens the newest `*.json` in `config.proposals_dir`. OFFLINE.
- `d` discover -- proposes NEW candidates from the venue's product list.

`d` is the second deliberate network exception after `f`, and is gated harder than `f`
is: opening the overlay makes NO call and renders an ARMED, not-yet-run explanation of
what it is about to do. Only Enter, pressed inside the overlay, makes the one
`list_products` call. The result is then HELD -- later polls repaint it without
re-fetching -- and closing discards it, so reopening is armed again.

None of the three attests, admits or trades. `attest` stays CLI-only: it is the human
judgment the whole gate rests on, and must not be a keystroke.

Also fixes a docstring that was already untrue: `tui_cmd` claimed the TUI "never
touches the network except when explicitly asked (`f`)", while `run_live` has all along
made an automatic ~30s `get_accounts` balance read -- which `run_live`'s own docstring
acknowledged, so the two contradicted each other. It now enumerates all three touches.

`run_live`'s help and insights branches each hand-rolled an identical scroll chain;
rather than copy it three more times, both now share one pure `_scroll_offset` helper,
with their behaviour unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lings do

Review of the new TUI overlays surfaced that `keel assets screen` was the last caller
still printing `✗ history: 0 daily bars, need 1460` for a product nobody had ever
fetched -- the "too young" misreading the rest of this branch removes.

It is the SIBLING of the new `s` screen overlay: both screen
`_default_sim_products(config)` through `_screen_product`. Leaving only one able to
explain an empty cache would hand an operator two different stories about the same
allowlist depending on which surface they happened to look at -- the drift
`_screen_product` exists to prevent, applied to the reporting rather than the verdict.

Also bounds the discover overlay's `list_products` call with `_DISCOVER_TIMEOUT_SEC`.
The operator is blocked on that one with the screen frozen behind a "contacting venue"
frame, so a hung venue must become a retryable `discover failed:` line rather than a
dashboard whose only exit is Ctrl-C. (`f` fetch stays deliberately unbounded -- it
legitimately runs for minutes, and is documented as freezing the dashboard.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eaitbrahim
eaitbrahim merged commit 51a2a95 into main Aug 7, 2026
1 check passed
@eaitbrahim
eaitbrahim deleted the feat/tui-admission-menu branch August 7, 2026 17:35
eaitbrahim added a commit that referenced this pull request Aug 7, 2026
…176 made (#177)

An adversarial review of #176 confirmed the network gating and read-only
guarantees, and found ten things to fix. This is all ten.

The two real bugs:

- `build_propose_view` guarded `source.read_text()` with `except OSError`,
  but `UnicodeDecodeError` subclasses **ValueError**. A UTF-16LE+BOM
  shortlist -- valid JSON, and what a scout run on Windows writes -- escaped
  both handlers and broke the function's own "Never raises. Every failure
  mode is FAIL-SOFT" contract: the TUI overlay repainted `propose read
  failed: 'utf-8' codec can't decode byte 0xff...` every poll forever,
  naming no file and no next step, and `keel assets propose --from` exited
  on a raw traceback. Both now take the existing `unreadable` fail-soft
  path, which names the file. The two docstrings that claimed an exception
  "can only come from `open_state()`, never from the shortlist read itself"
  said something that was false then and is still not the whole truth now
  (`build_propose_view` screens every parsed candidate, so a locked DB
  surfaces there too) -- both corrected.

- `assets propose --json` emitted `sc.result.failures` raw, so at zero
  cached bars the payload carried `history: 0 daily bars < 1460 required`
  unflagged while every human surface suppressed that exact line. Same
  report, two surfaces, two different answers to "is this asset too young?".
  `--json` now applies the same `split_failures` the renderers do, and says
  so explicitly: `failures` / `not_assessable` / `missing_history`.

The single-admission-path property (design constraint 4) was convention-only
exactly where it is wired: replacing `_screen_product` with an always-ADMIT
stub in `_do_screen_report` OR `_do_propose_view` left all 2062 tests green,
because the overlay tests only asserted that a title paints and Esc closes.
Two `run_live` tests now seed an unattested asset with ample history and
liquidity and assert the overlay paints REJECT plus `attestation: MISSING` --
a verdict only the real gate produces. Both stubs now die.

Honesty fixes to comments that asserted more than the code can know:

- `split_failures` claimed a shallow-but-non-empty cache means the asset
  "really is too young". `MarketFacts` carries no first-bar timestamp, so
  it cannot tell that from `keel fetch --years 2`, an aborted fetch, or a
  venue not serving the full window. Reworded to say so, and to tell the
  operator to check the fetch window first.
- `missing_history_lines` said a zero-bar asset "is not too young" -- same
  overclaim, opposite direction. It now refuses to rule either way.
- "the SECOND deliberate network exception" appeared in the module
  docstring, the help screen's Safety notes and the operator-facing ARMED
  overlay, forgetting the ~30s live-balance refresh that has been firing
  since v3. It is the THIRD of exactly three, as `tui_cmd`'s docstring
  already said. The help's "Live balance" section now also says the refresh
  is itself a live venue call.
- `run_live` said "Seven modes"; there are six.

Coverage for things that were correct but untested, each verified to die
under a mutation: `config.proposals_dir` resolution and `~` expansion (the
default is `~/keel/proposals`, inside the live deployment root, so a silent
regression means reading a right-looking wrong place);
`_DISCOVER_TIMEOUT_SEC`'s wiring, pinned per-call so collapsing it into the
balance timeout is caught; `missing_history_lines`' semantic sentence and
its promise never to restate a suppressed failure verbatim.

`test_run_live_discover_closing_discards_the_held_result` is renamed to
`..._reopening_after_a_run_is_armed_not_stale`. Deleting the close-branch
clear alone leaves it green -- verified -- because the normal-mode `d`
branch clears too, and nothing observable from outside `run_live` can
separate them, since `mode` only becomes `discover` via that branch. The
name now matches what it pins.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim added a commit that referenced this pull request Aug 7, 2026
Patch: no schema change, no rail change, nothing that alters what the agent
trades or when. Ships #176, #177 and #178.

The reason to cut this rather than wait is #176's fix to `DATA_DERIVED_FAILURES`,
which is a reporting bug live on the deployment right now: `history` was missing
from the tag set that marks a failure as downstream of an empty cache, so
`keel assets propose` and `keel assets holdings --screen` printed
`x history: 0 daily bars < 1460 required` directly beneath their own note saying
the verdict was about missing data rather than about the asset. An asset nobody
had ever fetched was reported as too young to trade.

Also in: the TUI can now reach the admission gate (`screen`/`propose`/`discover`,
read-only, `discover` network-gated behind an explicit keypress like `f`); the
non-UTF-8 shortlist hole that broke `build_propose_view`'s documented never-raises
contract; and the header now shows the patch segment, so 0.5.1 and 0.5.3 no longer
render identically as `v0.5`.

uv.lock relocked in the SAME commit, per 0.5.0-0.5.2. Verified with
`uv sync --frozen`, which accepted the lock and rewrote nothing.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant