diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 42a90480..f4391517 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -204,9 +204,9 @@ opens the database and reaches the venue. If the deployment runs on a schedule (LaunchAgents, cron), a new build takes effect on the next cycle with nothing to restart — each cycle is a fresh process. A **long-running** process is the -exception: a `keel tui` left open keeps the build it started with until you quit and relaunch it. +exception: a `keel serve` left running keeps the build it started with until you stop and restart it. -### Self-update: `keel update` and the console's update view +### Self-update: `keel update` The four commands above — plus the per-database `keel migrate --db` step the updater also runs (the four commands don't include it; it runs for every `keel*.db` with the new build, @@ -272,18 +272,16 @@ run: the wheels would land in a venv that is not this deployment's), or an insta is not the wheels. From a checkout, this section's four commands by hand remain the procedure. -**The relaunch split.** On a verified success the **TUI relaunches itself** — it replaces its -own process with the new build's `keel` entry (`os.execv`, the terminal restored first, the -original invocation's arguments carried over VERBATIM after argv[0] — wrappers that exec -`keel --config X --db Y tui` relaunch with exactly those flags in that order; only a wrapper -invoked with no arguments at all relaunches as plain `keel tui`), because a console left -running would keep the replaced -binary it started with. A relaunch that cannot exec at all renders the manual `keel tui` start -and holds it for an Enter retry that re-installs nothing. The **CLI prints the command instead** -and does NOT relaunch anything: -`keel update` ends by telling you to run `keel tui` (or your deployment wrapper). A wrapper -invoked directly (`./keel-live tui`) relaunches through the venv's `keel` entry with the same -flags. +**Nothing relaunches itself, and there used to be one thing that did.** On a verified success +`keel update` prints what to restart and stops there. Until #541 the TUI replaced its own process +with the new build's `keel` entry (`os.execv`, the terminal restored first), because a curses +front-end left running keeps the build it started with and there was no other way to pick up a new +one without the operator noticing. That code went with the dashboard, and its fallback had already +become wrong: with no arguments to carry it rebuilt `keel tui`, a command that no longer exists. + +A long-running `keel serve` has the same property -- it keeps the build it started with -- and +needs no execv to fix it: stop it and start it again, and the browser tab reconnects to whatever is +listening. **The manual fallback is unchanged.** The four commands at the top of this section still work and remain the documented procedure when uv is absent, the API is rate-limiting, or you simply @@ -324,8 +322,7 @@ database — do not delete them while keel is running, and prefer `keel update`' SQLite's own online-backup API) over copying the `.db` file by hand. Conversion happens on the next connection and needs nothing from you. -**Which one am I looking at.** On any dashboard (`keel status`, `keel insights`, `keel tui`, -`keel serve`) the +**Which one am I looking at.** On any dashboard (`keel status`, `keel insights`, `keel serve`) the `equity_state_mode` line names the account the equity, high-water mark and drawdown figures describe, and `paper_cash_usdc` is printed in paper mode only. On the command line it is the `--config`/`--db` pair — and `--db` is the one that bites, because `keel.db` is its default, so a @@ -336,10 +333,9 @@ account. Live commands always carry both: keel --config config.live-sandbox.yaml --db keel-live.db status ``` -**The same view in a browser: `keel serve`.** `keel tui` needs a terminal, and there are two -places it cannot go — Windows, where CPython ships no `curses`, and a macOS app launched from -Finder, which has no controlling terminal at all. `keel serve` renders the same reports over -loopback HTTP instead: +**The same view in a browser: `keel serve`.** This is the only interactive surface keel has; the +curses dashboard it replaced was deleted at #541, for reasons the console section below records. +`keel serve` renders the same reports over loopback HTTP: ```bash keel --config config.live-sandbox.yaml --db keel-live.db serve @@ -684,84 +680,42 @@ Rewards item: account settings no rail can see, re-checked after any account cha - **Trademark posture** — unchanged and stated where it lives: the README's standing disclaimer covers Alpaca alongside every other venue, and nothing here duplicates it. -## The TUI console - -`keel tui` (or any wrapper, e.g. `./keel-live tui`) opens the **operator console**: the dashboard is -still the landing screen, and `m` opens a menu tree over it — Profile, Trading, Rules, Compliance, -Data, Research, Account, Help — covering every operational read and write the CLI knows (the -setup-only writes are deliberately absent: `rules seed` is bootstrap, and schema migration rides -along every database open rather than being a menu action). The console -is **thin by construction**: each entry dispatches to the same `keel/commands/*` service layer the -CLI commands call, and an architectural test (`tests/commands/test_console_thinness.py`) pins that -the TUI layer contains no business logic — no sizing, screening, gating or reporting math, no -`Decimal` arithmetic beyond display, and no broker construction outside the service seams. If a -feature is missing, the fix lands in the service layer and both front-ends get it. - -**Profile switching and the live guard.** The Profile menu lists every deployment as its config+db -**pair** — the same pairs the table above pins — and switching rebinds both halves everywhere, in -one action: every screen, banner and read answers about the new deployment on the next paint. -Selecting **LIVE** asks an explicit y/N at the terminal first; declining keeps the binding exactly -where it was, and no key path can rebind around that confirm (the one guarded entry point is -pinned by test). The switch rebinds the **console only** — a `keel agent` process keeps the pair -its own command line gave it, so pointing the console at live never changes what a running agent -trades. Binding a deployment directly through the CLI's `--config`/`--db` flags remains the -wrappers' documented path. - -**The session banner.** Every screen's header names the active deployment (LIVE styled -unmistakably) and the market session state with the venue clock — OPEN/CLOSED with the recorded -next open/close, `24/7` for always-open venues, and **CLOCK UNAVAILABLE** rendered fail-loud when -the recorded clock is absent or stale, exactly as `fetch --check` treats it. The banner reads the -recorded session state; there is no TUI-side calendar. - -**The typed contracts: seven of the CLI's own, two the console adds.** Seven actions run the -CLI's own typed prompt in-console, word for word (curses suspends around it so the prompt -renders at the terminal): `resume`, `resume-entries`, `record-flow`, `reset-hwm`, -`withdrawals attest --enabled`, `autonomy on`, and `update` (the self-update run — the same -gate `keel update` demands; see "Self-update" under "Deploying a new version") — each the same -`_require_interactive_confirmation` gate the CLI command runs, demanding a typed `yes` and -failing closed off a TTY. Two more typed prompts are **ceremony the console adds on top of -an ungated CLI action** — deliberately *stricter* than the CLI, not identical to it: asset -`attest` makes you type the **asset code** back (the CLI's `keel assets attest` is not -gated — an attestation only ever admits to a list rail 1 still enforces per-trade), and the -retry flow's `rules promote --force` demands a typed `yes` quoting the CLI's own force -warning (the CLI's `--force` is a bare flag). Both are built on the same shared -typed-confirmation gate as the CLI's six. Every typed prompt **cannot be pre-filled, piped -or bypassed**; a wrong phrase or a decline writes nothing. `kill` is the deliberate -exception: **one key, no confirmation**, its own CLI contract — engaging the halt is the -safe direction — and the console adds no ceremony to it. The whole ceremony map (every -state-mutating console action → typed-phrase / confirm-step / ARMED+Enter / -ungated-by-design, each with its refusal proof) is pinned as a table-driven suite, -`tests/commands/test_console_ceremony.py`, so a newly added mutating action without a -classified ceremony row fails the tests. - -**ARMED and blocking surfaces.** The runs that do real work — one agent cycle, one monitor poll, -fetch and its check/repair variants, one simulate — open **ARMED**: nothing runs until Enter, -which is the confirm step. While a run executes the screen freezes (it can take minutes, exactly -like the CLI) and the result is held on screen afterwards. **Ctrl-C exits the console -gracefully, discards held results, and the in-flight run does not complete** — as every frozen -screen states; the interrupt propagates out of the run itself (the loop's failure handlers -catch `Exception` only), which is what restores the terminal cleanly. The one entry that can -place orders, the agent cycle, goes through `agent.run_once` with the CLI's own -order-confirmation gate — there is no TUI-originated order path. - -**Venues and help.** The Profile menu's **Venues** entry browses every installed adapter and its -declared capabilities — the same payload `keel brokers list` prints, one service, both -front-ends — with the selected adapter highlighted; no key presence is read or implied, and no -secret is ever shown. `?` on any screen opens that screen's own "what am I looking at" help, and -the Help menu holds the glossary (one source, `docs/glossary.md`, the fiqh terms anchored to -`docs/fiqh-basis.md`), every screen's rows consolidated, and the per-rule-parameter help rendered -from the rule classes themselves. - -**Safety design notes.** Re-entering any sub-menu resets its cursor to the top — a remembered -row is a loaded one (leave Trading with the cursor on kill and a replayed Enter would engage the -halt with no ceremony). The Account menu's pnl (the FIFO report over imported transactions, with -an honest empty state until `keel db import` has loaded any) and versions (the deploy check) -entries are read-only views; the branch's **one** write path is the update entry (issue #415) — -an ARMED view whose run demands the CLI's own typed gate, and which on a verified success -relaunches the console on the new build ("Self-update" under "Deploying a new version" is its -procedure). The console runs no loop of its own and schedules -nothing: it is a front-end over the same services, and closing it never stops a deployment's own -scheduled cycles. +## The operator console, in a browser + +`keel serve` opens keel's **operator console**: a local web page showing Status, Setup, Activity, +Insights, Rules, Venues and Gates, over the same `keel/commands/*` service layer the CLI commands +call. An architectural test (`tests/commands/test_console_thinness.py`) pins that thinness -- the +front-end renders and dispatches, and every behaviour comes from the services. + +```bash +keel --config config.live-sandbox.yaml --db keel-live.db serve +``` + +It binds loopback and prints a URL carrying a one-time token for that run. The token is never +written to disk, so stopping the server invalidates it. + +**`keel tui` was the console until #541, and it is gone.** It needed a terminal, and there were two +places it could not go: Windows, where CPython ships no `curses`, and a macOS app launched from +Finder, which has no controlling terminal at all -- both of them platforms a desktop release +targets. The menu tree it carried (Profile, Trading, Rules, Compliance, Data, Research, Account, +Help) went with it, along with roughly 24,000 lines of code and tests. + +**On a headless host, forward the port rather than reaching for a terminal UI:** + +```bash +ssh -L 8765:127.0.0.1:8765 your-host +``` + +The browser at the reading end gets the full interface, encrypted by SSH, and `http://127.0.0.1` +is still a secure context there -- which is what makes the installable app work. `keel status` +covers the rest from a plain shell. + +**What the browser cannot do, and that is deliberate.** Every capability-increasing action -- +arming autonomy, releasing the kill-switch, clearing a consecutive-loss halt, re-seeding the +drawdown high-water mark, declaring a deposit or withdrawal, attesting withdrawal capability, and +replacing the binary -- is a CLI command behind a typed confirmation at an interactive terminal. +`keel capabilities` lists all seven with the gate covering each. The server implements no verb +that would reach one, so this is a property of the server rather than of what the page draws. ## How much money moves diff --git a/keel/capabilities.py b/keel/capabilities.py index 1407f0c7..0d07bc8f 100644 --- a/keel/capabilities.py +++ b/keel/capabilities.py @@ -14,10 +14,24 @@ `tests/test_capabilities.py`, which fails in BOTH directions -- an undeclared gate call site fails, and a declared entry whose gate has disappeared fails. -**Why one entry per call site and not per action.** `autonomy on` appears twice, because the CLI -and the TUI each gate it in their own front-end, and an auditor asking "can the TUI arm autonomy -without a terminal?" needs to see the TUI's own row. Rows that mirror a CLI action carry -`mirrors`, so the duplication reads as deliberate rather than as an inventory that double-counts. +**One entry per call site, and since #541 there is one call site per action.** `autonomy on` used +to appear twice -- the CLI and the TUI each gated it in their own front-end -- and four of the +eleven rows were a TUI or console form of a CLI action, carrying `mirrors` so the duplication read +as deliberate. #541 deleted the TUI and the console layer that was reachable only from inside it, +and those four rows went with their call sites. `mirrors` stays on the dataclass: it describes a +shape this registry must be able to express the moment a second front-end gates anything again. + +**What that leaves is worth stating plainly: every capability increase in this build is a CLI +command run by a person at a terminal.** The browser can perform none of them. That is not a +property of what the client draws -- "a client that hides a button is not a gate" -- it is a +property of the server, which implements no verb that would reach one. + +**And one bypass is deliberately absent, as it always was.** `keel rules promote --force` skips +the backtest and every gate check, and it is not here, because this registry is an inventory of +GATE CALL SITES and that flag has none: `keel/commands/rules.py` records the reasoning -- "the +CLI's `--force` is a flag the operator already typed at a terminal". The console's retry flow DID +run a typed gate before calling with `force=True`, which is why a force-promote row existed at +all; it described the console's ceremony, not the CLI's flag, and it went with the console. **The gate vocabulary is a tuple of one today.** `TTY` is the whole model: a human at a terminal, evidenced by `sys.stdin.isatty()`, with no env-var or flag seam because any such seam would be @@ -161,48 +175,10 @@ def key(self) -> tuple[str, str]: "other one at once, which is why it is gated identically" ), ), - Capability( - module="keel.commands.tui", - function="_confirm_arm_autonomy", - surface="tui", - invocation="the dashboard's autonomy action", - increases="the same as `keel autonomy on`, reached from the live dashboard", - mirrors=("keel.commands.autonomy", "autonomy_on_gate"), - ), - Capability( - module="keel.commands.trading_console", - function="_clis_typed_gate", - surface="console", - invocation="the Trading menu's halt-release actions", - increases=( - "the same halt releases the CLI offers, reached from the console -- the wording and " - "the gate are imported from their one home so the two front-ends cannot drift into " - "two ceremonies for one bypass" - ), - mirrors=("keel.cli", "resume"), - ), - Capability( - module="keel.commands.compliance_console", - function="clis_typed_withdrawals_gate", - surface="console", - invocation="the Compliance menu's withdrawal attestation", - increases="the same as `keel withdrawals attest --enabled`, reached from the console", - mirrors=("keel.commands.withdrawals", "withdrawals_attest"), - ), - Capability( - module="keel.commands.strategy_console", - function="clis_typed_promote_force_gate", - surface="console", - invocation="the Strategy menu's force-promote", - increases=( - "a rule is promoted BYPASSING the backtest and promotion gate -- the console's form " - "of `keel rules promote --force`" - ), - mirrors=None, - ), ) + def gate_named(name: str) -> Gate: for gate in GATES: if gate.name == name: diff --git a/keel/cli.py b/keel/cli.py index 3f546627..d6a0e14f 100644 --- a/keel/cli.py +++ b/keel/cli.py @@ -194,7 +194,6 @@ ) from keel.commands.trading import record_flow as record_declared_flow from keel.commands.trials import trials_group -from keel.commands.tui import tui_cmd from keel.commands.update import update_cmd from keel.commands.versions import versions_cmd from keel.commands.withdrawals import withdrawals_group @@ -1326,20 +1325,23 @@ def simulate( cli.add_command(doctor_cmd) -# -- tui (live, full-screen operator dashboard, with a help menu and a few gated actions) -------- - -# `keel status` was built as the substrate for this: `tui_cmd` is a curses view over the same -# `gather_status` report, defined in `keel.commands.tui` and registered here. -cli.add_command(tui_cmd) - - -# -- serve (the same read surface in a browser, for machines and users a TUI cannot reach) ------- - -# `curses` does not exist in CPython on Windows and a macOS app launched from Finder has no -# controlling terminal at all, so the TUI is unreachable on both of the platforms a desktop -# release targets. `keel serve` renders the same reports over loopback HTTP instead: read-only by -# construction (GET and HEAD are the only verbs it implements), and pinned by the same thinness -# test as the console layer. Defined in `keel.commands.serve` over `keel.web`. +# -- serve (the interactive surface -- there is one, and it is a browser) ------------------------ + +# **`keel tui` was here, and #541 deleted it.** `curses` does not exist in CPython on Windows and +# a macOS app launched from Finder has no controlling terminal at all, so the TUI was unreachable +# on both of the platforms a desktop release targets -- and `windows-curses`, which papered over +# the first, is unmaintained. The console layer went with it: it was reachable only from inside +# the dashboard, so keeping it would have shipped fourteen thousand lines nothing could open. +# +# `keel serve` renders the same reports over loopback HTTP: `keel status` was built as the +# substrate for both, and `gather_status` is still the one report behind them. Defined in +# `keel.commands.serve` over `keel.web`. +# +# What did NOT move is every capability-increasing action. All seven that remain are CLI commands +# behind `_require_interactive_confirmation`'s typed gate, and the browser can perform none of +# them -- see `keel/capabilities.py`, which shrank from eleven surfaces to seven when the +# dashboard's autonomy action and the console's three gated actions went. Each of those four +# mirrored a CLI command that is still here. cli.add_command(serve_cmd) diff --git a/keel/commands/account_console.py b/keel/commands/account_console.py deleted file mode 100644 index 5de178c3..00000000 --- a/keel/commands/account_console.py +++ /dev/null @@ -1,487 +0,0 @@ -"""The Account menu (issue #392 C6; PRD §3's Account branch: pnl + versions; #415 adds -the update entry). - -The console tree's LAST placeholder turned real (C6) as its READ-ONLY area -- and it -stayed that way until the self-update slice (#415) added the branch's ONE write path: -the `update` entry, an ARMED view whose run is TYPED (`keel update`'s own gate -wording, one gate, both front-ends -- its ceremony row). Everything renders through -the C1 services the CLI itself calls, never a re-implementation: - -* **pnl** -- `keel.commands.pnl.build_pnl_report` + `render_pnl_report` (the exact - `keel pnl` report, FIFO and all, unchanged) over the ACTIVE deployment's imported - transactions. An empty transactions table renders its honest empty state naming the - import path (`keel db import`, the Data menu's form) -- never a confident - `total realized P&L: 0` over an account whose history was simply never loaded. -* **versions** -- the ONE shared renderer `keel.commands.versions.render_versions_lines` - (the same lines `keel versions` prints, stderr half included), so the deploy check - cannot drift between terminal and console. The environment scan (`build_info` + - `check_install`, an importlib.metadata walk) runs ONCE per entry and the rows are - HELD -- the Venues browser's contract, never per poll. -* **update** -- `keel.commands.update`, the self-update service: the entry-time check - (`update_check`: one public-API read + the plan) runs ONCE and the plan is HELD, - the versions view's contract; Enter is NOT enough -- the run happens at the - TERMINAL through the suspend dance, behind the CLI's own typed gate, and a - verified success RELAUNCHES the console (`os.execv` the new build's keel entry, - terminal already restored). The subprocess/HTTP/execv orchestration is ALL the - service's; this module renders and asks. - -All the pure builders are directly unit-testable without curses, mirroring the -`build_*` split of the other console modules; the live loop owns only the I/O. -""" - -from __future__ import annotations - -import textwrap -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -from keel.commands import update -from keel.commands.pnl import build_pnl_report, render_pnl_report -from keel.commands.tui import CTRL_C_DISCLOSURE, ScreenLine, _blank -from keel.commands.versions import build_info, check_install, render_versions_lines - -#: The width every console line must fit (`_paint` clips at the window width; 80-column -#: terminals are this dashboard's stated target) -- the same budget the other console -#: modules keep, applied by wrapping rather than clipping. -_WIDTH = 78 - - -def _wrap(text: str, *, indent: str = " ", width: int = _WIDTH) -> list[str]: - """Wrap `text` on spaces to the 80-column budget, continuation lines carrying `indent`. - PURE -- the same rule every console module keeps.""" - return textwrap.wrap(text, width=width, initial_indent=indent, subsequent_indent=indent) or [ - indent - ] - - -# -- the sub-menu model (PRD §3's Account branch) -------------------------------------------------- - - -@dataclass(frozen=True) -class AccountEntry: - """One entry of the Account sub-menu. `kind` is the closed dispatch vocabulary the - other console modules keep: `"view"` renders a read-only report, and the #415 - `update` entry is `"armed"` -- an ARMED view whose Enter dispatches the run at the - TERMINAL, behind the CLI's own typed gate (the branch's one write path, and its - ceremony row in the audit table).""" - - ordinal: int - label: str - description: str - kind: str # "view" | "armed" - target: str # the view name - - -#: PRD §3's Account branch in tree order, plus the #415 update entry. The descriptions -#: are O8's plain-English "what will this do" in miniature, and they say honestly what -#: each view reads -- the update's names the typed gate and the replacement. -ACCOUNT_MENU: tuple[AccountEntry, ...] = ( - AccountEntry( - ordinal=1, - label="pnl", - description=( - "realized + unrealized FIFO P&L from the imported transactions (read-only; " - "the exact `keel pnl` report)" - ), - kind="view", - target="pnl", - ), - AccountEntry( - ordinal=2, - label="versions", - description=( - "every keel distribution's version, not just keel-trader's -- the deploy " - "check that can fail (read-only)" - ), - kind="view", - target="versions", - ), - AccountEntry( - ordinal=3, - label="update", - description=( - "check for a newer release and (after a TYPED confirmation) deploy it into " - "this launch folder -- backups first, verify, then the console relaunches " - "itself on the new build" - ), - kind="armed", - target="update", - ), -) - - -def account_entry(ordinal: int) -> AccountEntry | None: - """The entry selected by its displayed ordinal, or `None` -- the one-lookup rule - every console menu keeps, so the rendered ordinals and the shortcut keys cannot - drift.""" - for entry in ACCOUNT_MENU: - if entry.ordinal == ordinal: - return entry - return None - - -#: This module's screens' contextual help (O8) -- the rows the `?` overlay renders, -#: keyed by the live loop's mode names. Plain `(subject, description)` pairs so the text -#: stays HERE with the module that owns the screens; `keel.commands.help_console` is the -#: registry and renderer. -CONTEXT_HELP: dict[str, tuple[tuple[str, str], ...]] = { - "account": ( - ( - "the Account branch", - "the console's account area: the FIFO P&L report and the whole-install " - "version check are read-only views; the update entry is the branch's ONE " - "write path, and it is typed-gated", - ), - ( - "pnl", - "the EXACT `keel pnl` report (realized FIFO totals plus open positions), " - "rendered from the same service over the ACTIVE deployment's imported " - "transactions; nothing is recomputed locally", - ), - ( - "versions", - "the same lines `keel versions` prints, from the one shared renderer -- a " - "disagreement between keel distributions renders loud here just as it fails " - "the CLI's exit code", - ), - ( - "update", - "check for a newer release and deploy it into this launch folder -- the " - "same service `keel update` runs, behind the same TYPED gate; a refused " - "gate writes nothing and never relaunches", - ), - ), - "account-pnl": ( - ( - "the report", - "`keel pnl`'s own output, verbatim, over this deployment's imported " - "transactions; rebuilt each poll from the database like every offline view", - ), - ( - "no marks supplied", - "the console renders the overall report without marks, exactly `keel pnl` " - "with no --mark: unrealized P&L needs a price you supply, and none is " - "inferred here", - ), - ( - "the empty state", - "no imported transactions means exactly that: the report names the import " - "path (`keel db import`, the Data menu's form) rather than printing a " - "confident zero", - ), - ), - "account-versions": ( - ( - "the deploy check", - "every keel distribution installed in this environment, compared -- the same " - "lines `keel versions` prints from the one shared renderer; the scan runs " - "once per entry, and the rows are held across repaints", - ), - ( - "loud disagreement", - "a partial install or a dev-only venue renders its error lines in the alert " - "style -- the console's equivalent of the command's non-zero exit, because a " - "view cannot exit", - ), - ), - "account-update": ( - ( - "the ARMED check", - "the release check ran ONCE on entry (one public read of the GitHub " - "releases API, no auth) and the plan is held -- repaints re-check nothing; " - "the view names current vs latest, the production wheels, the " - "Release/ dir, the .bak-before-* backups and the RUNNING venv", - ), - ( - "Enter is not enough", - "the run happens at the TERMINAL, behind `keel update`'s own TYPED gate " - "(the wording names the version, the launch folder and that the running " - "binary is replaced); a wrong phrase, a decline or no TTY writes nothing", - ), - ( - "the relaunch", - "a VERIFIED success replaces this process with the new build's keel entry " - "(os.execv, terminal restored first) -- a failure renders its honest state " - "and the manual recovery instead, and the backups are never deleted", - ), - ), -} - - -def build_account_menu_lines(*, cursor: int = 0) -> list[ScreenLine]: - """The Account sub-menu screen: every entry with its description wrapped to the - 80-column budget, exactly one cursor-marked row. PURE.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- account", "heading"), - _blank(), - ] - cursor = max(0, min(cursor, len(ACCOUNT_MENU) - 1)) - for index, entry in enumerate(ACCOUNT_MENU): - marker = ">" if index == cursor else " " - head = f"{marker} {entry.ordinal:>2} {entry.label}" - lines.append(ScreenLine(head, "heading" if index == cursor else "normal")) - for wrapped in _wrap(f"{entry.description}.", indent=" "): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append(ScreenLine("up/k down/j move · Enter/Space select · 1-3 jump", "muted")) - lines.append(ScreenLine("q/Esc/m to the console menu", "muted")) - return lines - - -# -- pnl: the service's own report, verbatim ------------------------------------------------------- - - -def build_pnl_lines(transactions: list[dict[str, Any]]) -> list[ScreenLine]: - """The pnl view: `render_pnl_report(build_pnl_report(...))` VERBATIM -- the exact - `keel pnl` output, the overall report with no marks (the CLI with no --asset and no - --mark) -- or, when the deployment holds no imported transactions, the honest empty - state naming the import path instead of a confident zero. PURE over the transaction - rows the caller read from the ACTIVE deployment.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- account / pnl", "heading"), - _blank(), - ] - if not transactions: - lines.append(ScreenLine("no imported transactions -- nothing to report yet.", "normal")) - for wrapped in _wrap( - "this report reads the Coinbase CSV exports `keel db import` loads (the " - "Data menu's db import entry); until then there is no cost-basis history to " - "compute from, and a zero printed here would be a claim, not a reading.", - indent="", - ): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append(ScreenLine("q/Esc/m back to the Account menu", "muted")) - return lines - report = build_pnl_report(transactions, None, {}) - for line in render_pnl_report(report): - for wrapped in _wrap(line, indent=""): - lines.append(ScreenLine(wrapped, "normal")) - lines.append(_blank()) - for wrapped in _wrap( - "the overall report with no marks supplied, exactly `keel pnl` with no --mark -- " - "unrealized P&L needs a price you supply, and none is inferred here.", - indent="", - ): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append(ScreenLine("q/Esc/m back to the Account menu", "muted")) - return lines - - -# -- versions: one renderer, two front-ends -------------------------------------------------------- - - -def versions_rows() -> list[tuple[str, bool]]: - """Read the deploy check ONCE: the build identity and the installed-distribution - report, rendered through the shared `render_versions_lines`. The importlib.metadata - scan this triggers is the whole reason the live loop holds the rows from entry - rather than rebuilding per poll (the Venues browser's contract).""" - info = build_info() - return render_versions_lines(info, check_install(source=info.source)) - - -def build_versions_lines(rows: list[tuple[str, bool]]) -> list[ScreenLine]: - """The versions view: the shared renderer's exact `(text, to_stderr)` pairs, the - stderr half (the not-reproducible warning, the disagreement errors) styled loud -- - a view cannot exit non-zero, so loudness is the console's equivalent. PURE over the - held rows.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- account / versions", "heading"), - _blank(), - ] - for text, to_stderr in rows: - if not text: - lines.append(_blank()) - continue - style = "alert" if to_stderr and text.startswith("error:") else ( - "warn" if to_stderr else "normal" - ) - for wrapped in _wrap(text, indent=""): - lines.append(ScreenLine(wrapped, style)) - lines.append(_blank()) - for wrapped in _wrap( - "the same lines `keel versions` prints, from the one shared renderer -- a " - "disagreement fails that command's exit code and renders loud here.", - indent="", - ): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append(ScreenLine("q/Esc/m back to the Account menu", "muted")) - return lines - - -# -- update: the self-update view (issue #415) ----------------------------------------------------- - - -def update_check( - *, - fetch: Callable[[str], bytes] | None = None, - launch_dir: Any = None, - venv_python: Any = None, - package_file: Any = None, -) -> update.UpdatePlan: - """The entry-time read: ONE public-API release check plus the plan -- the versions - view's contract (read once, hold), never per poll. A network/API failure raises - `update.UpdateError`, which the live loop holds as the view's honest error state - (Enter retries); it is never rendered as a confident 'up to date'.""" - return update.plan_update( - update.latest_release(fetch=fetch), - launch_dir=launch_dir, - venv_python=venv_python, - package_file=package_file, - ) - - -def build_update_lines(plan: update.UpdatePlan) -> list[ScreenLine]: - """The update view: the SHARED plan renderer's exact lines (the same report `keel - update --check` prints -- one renderer, two front-ends), wrapped to the 80-column - budget. An offered plan renders ARMED with the typed-gate disclosure; a refusal - renders its reasons and offers nothing to run; up-to-date renders calm. PURE over - the held plan.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- account / update", "heading"), - _blank(), - ] - for line in update.render_plan_lines(plan): - if not line.strip(): - lines.append(_blank()) - continue - for wrapped in _wrap(line, indent=""): - lines.append(ScreenLine(wrapped, "normal")) - lines.append(_blank()) - if plan.offered: - lines.append(ScreenLine("ARMED -- nothing has run yet.", "normal")) - for wrapped in _wrap( - "Enter opens the TYPED confirmation at the terminal (`keel update`'s own " - "wording: it names the version, the launch folder and that the running " - "binary is REPLACED); only the typed word `yes` proceeds, and the run " - "blocks this screen exactly like the CLI, with its lines held here when " - "it ends.", - indent="", - ): - lines.append(ScreenLine(wrapped, "normal")) - for wrapped in _wrap( - "a verified success RELAUNCHES the console on the new build (the process " - "is replaced); a failure renders its honest state and the manual recovery.", - indent="", - ): - lines.append(ScreenLine(wrapped, "muted")) - else: - for wrapped in _wrap( - "nothing will run -- Enter re-checks the release, q/Esc/m returns.", - indent="", - ): - lines.append(ScreenLine(wrapped, "muted")) - for wrapped in _wrap(CTRL_C_DISCLOSURE, indent=""): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append(ScreenLine("q/Esc/m back to the Account menu", "muted")) - return lines - - -def build_update_error_lines(error: str) -> list[ScreenLine]: - """The check's honest failure state: the error verbatim (wrapped), with Enter as - the retry and no run offered -- a failed check is never a confident 'up to - date'. PURE.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- account / update", "heading"), - _blank(), - ScreenLine("the release check failed:", "alert"), - ] - for wrapped in _wrap(error, indent=""): - lines.append(ScreenLine(wrapped, "warn")) - lines.append(_blank()) - for wrapped in _wrap( - "Enter re-checks the release (one public read of the releases API); the " - "manual procedure in docs/operator-runbook.md ('Deploying a new version') " - "needs no API call at all.", - indent="", - ): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append(ScreenLine("q/Esc/m back to the Account menu", "muted")) - return lines - - -def build_update_result_lines( - result: update.UpdateResult, - progress: list[str] | tuple[str, ...], - *, - relaunch_pending: bool = False, -) -> list[ScreenLine]: - """The held run result: the streamed step lines VERBATIM above (wrapped, never - clipped -- how far it got is the detail), then the shared summary/recovery - renderer's lines, failure text loud. `relaunch_pending` -- a verified success - whose execv failed, held for an Enter retry -- switches the footer from re-run to - retry-the-relaunch. PURE over the held values.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- account / update results", "heading"), - _blank(), - ] - if progress: - for line in progress: - if not line.strip(): - lines.append(_blank()) - continue - for wrapped in _wrap(line, indent=""): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - for line in update.render_result_lines(result): - style = "alert" if not result.ok else "ok" - for wrapped in _wrap(line, indent=""): - lines.append(ScreenLine(wrapped, style)) - lines.append(_blank()) - for wrapped in _wrap(CTRL_C_DISCLOSURE, indent=""): - lines.append(ScreenLine(wrapped, "muted")) - footer = ( - "Enter retries the relaunch (re-installs nothing) · q/Esc/m back to the menu" - if relaunch_pending - else "Enter re-runs · q/Esc/m back to the Account menu" - ) - lines.append(ScreenLine(footer, "muted")) - return lines - - -def run_update_at_terminal( - plan: update.UpdatePlan, - *, - progress: list[str], - gate_fn: Callable[[update.UpdatePlan], bool] | None = None, - relaunch_fn: Callable[[], object] | None = None, - run_fn: Callable[..., update.UpdateResult] | None = None, - on_relaunch_failure: Callable[[BaseException], object] | None = None, -) -> update.UpdateResult: - """THE update run, at the terminal: the CLI's OWN typed gate rides the service's - `confirm_gate` seam (both shipped front-ends gate the run; nothing keel ships calls - the service ungated), the service's streamed lines collect into `progress`, and -- - only on a verified success -- the relaunch closure runs (execv, terminal already - restored by the suspend dance). A relaunch that RAISES (execv refused) is rendered - into `progress` with the manual `keel tui` start and reported through - `on_relaunch_failure` -- the update itself is done and verified, so its result is - returned held-ok, never lost to the exception. `gate_fn`/`run_fn` are injectable - so the loop's tests can drive the contract without any of it.""" - gate = gate_fn if gate_fn is not None else update.typed_update_gate - service = run_fn if run_fn is not None else update.run_update - result = service(plan, echo=progress.append, confirm_gate=lambda: gate(plan)) - if result.ok: - progress.append( - f"updated to {plan.target_version} and verified -- relaunching the console " - "on the new build." - ) - if relaunch_fn is not None: - try: - relaunch_fn() - except Exception as exc: # execv refused: the update itself is DONE and verified - progress.append(f"RELAUNCH FAILED: {exc}") - progress.append( - "the new build IS installed and verified -- run `keel tui` by hand " - "(or your deployment wrapper). Enter retries the relaunch; it " - "re-installs nothing." - ) - if on_relaunch_failure is not None: - on_relaunch_failure(exc) - return result - progress.append("relaunch did not replace the process -- run `keel tui` by hand.") - return result diff --git a/keel/commands/compliance_console.py b/keel/commands/compliance_console.py deleted file mode 100644 index a07b7fd0..00000000 --- a/keel/commands/compliance_console.py +++ /dev/null @@ -1,1285 +0,0 @@ -"""The Compliance menu -- the console's third slice (issue #389 C3; PRD O6/O10 and §3's tree). - -Everything here is DISPATCH, never behavior (PRD O2, the same discipline `console.py` keeps): -every view renders a C1 service's own report through its own renderer, every form collects -fields at the terminal and calls the SAME service/repository function the CLI command calls, -and the two browsers (scout results, Shariah in force) read through service reads added for -the purpose (`admission.list_shortlists`, `assets.gather_attestations_in_force`) -- never a -TUI-side re-implementation. No sizing, screening, gating or reporting math lives here. - -Three surfaces, all pure (or injected-I/O) and unit-testable without curses: - -* **The sub-menu model** -- `COMPLIANCE_MENU`, PRD §3's Compliance branch. `kind` is a closed - vocabulary: `"view"` (a service report overlay; holdings/discover are network-gated and open - ARMED), `"form"` (a record-write), `"scout"` (the proposals browser). `typed` marks the two - entries the PRD marks "(typed)": `attest` and `withdrawals attest`. -* **The forms** -- `run_form` is the loop's single dispatch seam; the `run_*` functions beneath - it collect fields through an injected `prompt_fn` and dispatch to the repository call the CLI - makes (`upsert_asset_attestation`, `upsert_instrument_attestation`, `upsert_screen_exception`, - `delete_screen_exception`, `set_state`) or to the extracted subscription services - (`subscription.apply_subscription_attest`/`apply_subscription_set` -- one implementation, two - front-ends since C3). Each returns the CLI's own confirmation line, so every write shows what - it did. -* **The typed contract (O3)** -- `withdrawals attest --enabled` keeps the CLI's OWN typed gate, - verbatim (`clis_typed_withdrawals_gate` wraps `_require_interactive_confirmation` with the - CLI's exact action wording, fails closed); `attest` (typed per the PRD tree) ends with - `typed_asset_confirmation`: the operator types the ASSET CODE back -- never pre-filled, never - piped, and a wrong phrase means not a single row is written. The TUI adds no gate the CLI has - (`attest-instrument`, exempt/unexempt, subscription writes match their CLI ungated shapes). - -The scout-results handler (O6) is proposer-never-decider exactly like the skill that writes the -shortlists: the browser lists, renders and OFFERS the attest step; it never auto-attests, and -the attest step is the same typed form the Compliance menu's own entry runs. - -The "Shariah in force" browser (O10) renders what the engine enforces for the ACTIVE profile -from records alone -- attestations in force, exemptions, rail 17's live state -- plus the -fiqh-derived constraints, each a VERBATIM quote from `docs/fiqh-basis.md` with a citation that -resolves to a real section heading of that document. Nothing on that screen is a TUI-authored -fiqh summary: the vocabulary section quotes the document too, and a term the document does not -state (gharar) is rendered as not-stated-there rather than defined -- the document's own -honesty rule, inherited. The two standing honesty lines are pinned to the document's wording -and to a FIXED footer of the view (`shariah_honesty_lines` + `pinned_frame`), painted outside -the scroll so they stay on screen at every scroll offset -- not merely present at the body's -end, one viewport below the fold. -""" - -from __future__ import annotations - -import time -from collections.abc import Callable -from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from keel.commands.admission import ( - ProposeView, - ScoutFile, - list_shortlists, - render_propose_view, - render_screen_report, -) -from keel.commands.assets import ( - VENUE, - AttestationsInForce, - render_discover, - render_holdings, -) -from keel.commands.subscription import ( - apply_subscription_attest, - apply_subscription_set, -) -from keel.commands.tui import ( - ScreenLine, - _admission_line_style, - _blank, - _message_style, - _visible_slice, -) -from keel.commands.withdrawals import WITHDRAWALS_ATTEST_ACTION, WITHDRAWALS_ATTEST_DETAIL -from keel.compliance.screen import KNOWN_BACKINGS, KNOWN_WRAPPERS, WAIVABLE_CRITERIA -from keel.execution.executor import WITHDRAWAL_ATTESTATION_TTL_SEC - -if TYPE_CHECKING: - pass - -#: One terminal prompt: injected so every form is unit-testable with a scripted fake, and so -#: the live loop can run the whole form through the curses suspend/restore dance. -PromptFn = Callable[[str], str] - - -class FormInputError(ValueError): - """A form field that cannot be accepted (an unknown backing, a blank rationale). The - `run_*` functions render this as the form's `Error: ...` result line -- the same shape - the CLI's own option validation takes, never an exception past the form.""" - - -# -- the sub-menu model (PRD §3's Compliance branch) ---------------------------------------------- - - -@dataclass(frozen=True) -class ComplianceEntry: - """One entry of the Compliance sub-menu. `kind` is the closed dispatch vocabulary: - `"view"` renders a service report (the network-touching views open ARMED, gated behind - Enter like the discover overlay), `"form"` runs a record-write at the terminal, and - `"scout"` opens the proposals browser. `typed` marks the entries whose write carries a - typed confirmation (O3 / the PRD tree's own "(typed)" annotations).""" - - ordinal: int - label: str - description: str - kind: str # "view" | "form" | "scout" - target: str # the view kind, the form name, or "" for the scout browser - typed: bool = False - - -#: PRD §3's Compliance branch, in tree order. The descriptions are O8's plain-English -#: "what will this do" in miniature, and they name the dispatch honestly: what each entry -#: reads or writes, and where the ceremony is. -COMPLIANCE_MENU: tuple[ComplianceEntry, ...] = ( - ComplianceEntry( - ordinal=1, - label="screen", - description="the allowlist's admission verdicts (offline; the assets screen gate)", - kind="view", - target="screen", - ), - ComplianceEntry( - ordinal=2, - label="propose", - description="the newest shortlist, screened (offline; the assets propose gate)", - kind="view", - target="propose", - ), - ComplianceEntry( - ordinal=3, - label="attest", - description="record an asset classification -- typed: type the asset code to confirm", - kind="form", - target="attest", - typed=True, - ), - ComplianceEntry( - ordinal=4, - label="attest-instrument", - description="record what CONTRACT a venue listing is (spot admits; the rest refuse)", - kind="form", - target="attest-instrument", - ), - ComplianceEntry( - ordinal=5, - label="exempt", - description="waive one DATA criterion for one asset, with a documented rationale", - kind="form", - target="exempt", - ), - ComplianceEntry( - ordinal=6, - label="unexempt", - description="revoke a documented waiver (a de-risking action, always allowed)", - kind="form", - target="unexempt", - ), - ComplianceEntry( - ordinal=7, - label="holdings", - description="what you hold, as allowlist CANDIDATES (one live balance read)", - kind="view", - target="holdings", - ), - ComplianceEntry( - ordinal=8, - label="discover", - description="propose candidates from venue metadata (one live product read)", - kind="view", - target="discover", - ), - ComplianceEntry( - ordinal=9, - label="Scout results", - description="browse the scout's shortlists and admit through the real flow", - kind="scout", - target="", - ), - ComplianceEntry( - ordinal=10, - label="Shariah in force", - description="what the engine enforces now, from records (read-only)", - kind="view", - target="shariah", - ), - ComplianceEntry( - ordinal=11, - label="subscription show", - description="every venue's subscription, with the cap actually in force", - kind="view", - target="subscription", - ), - ComplianceEntry( - ordinal=12, - label="subscription attest", - description="assert a venue's tier (rail 14's allowance)", - kind="form", - target="subscription-attest", - ), - ComplianceEntry( - ordinal=13, - label="subscription set", - description="hand-set a raw allowance, naming no tier (prefer attest)", - kind="form", - target="subscription-set", - ), - ComplianceEntry( - ordinal=14, - label="withdrawals attest", - description="rail 17's qabd input -- typed 'yes' when enabling", - kind="form", - target="withdrawals-attest", - typed=True, - ), - ComplianceEntry( - ordinal=15, - label="purification", - description="non-compliant income owed to charity (report-only)", - kind="view", - target="purification", - ), -) - - -def compliance_entry(ordinal: int) -> ComplianceEntry | None: - """The entry selected by its displayed ordinal, or `None` -- the same one-lookup rule - `console.menu_entry` keeps, so the rendered ordinals and the shortcut keys cannot drift.""" - for entry in COMPLIANCE_MENU: - if entry.ordinal == ordinal: - return entry - return None - - -#: This module's screens' contextual help (O8, issue #394 C7) -- the rows the `?` -#: overlay renders, keyed by the live loop's mode names. Plain `(subject, description)` -#: pairs so the text stays HERE with the module that owns the screens; -#: `keel.commands.help_console` is the registry and renderer. The typed actions' rows -#: state the O3 contract explicitly: the prompt cannot be pre-filled. -CONTEXT_HELP: dict[str, tuple[tuple[str, str], ...]] = { - "compliance": ( - ( - "the screen and propose views", - "read-only admission verdicts -- what the allowlist's assets passed or " - "failed, and the newest shortlist screened the same way; both offline", - ), - ( - "the attest / attest-instrument / exempt forms", - "record-write forms run at the TERMINAL (curses suspended): attest records " - "a human classification, attest-instrument names a listing's contract, " - "exempt waives one DATA criterion with a documented rationale", - ), - ( - "attest and withdrawals attest are TYPED", - "their confirmation asks you to TYPE the answer (the asset code, or 'yes') " - "at the terminal -- the prompt cannot be pre-filled, piped or bypassed; " - "backing out changes nothing", - ), - ( - "holdings and discover", - "the two live reads: holdings screens what you actually hold as candidates, " - "discover proposes from the venue's own product list. Both open ARMED -- " - "nothing touches the network until Enter", - ), - ( - "Scout results / Shariah in force", - "the scout browser drives the real propose -> screen -> attest flow; the " - "shariah view renders what the engine enforces now, from records alone", - ), - ), - "compliance-view": ( - ( - "what this view is", - "a read-only rendering of a compliance service report -- verdicts, records " - "or the venue's answer, depending on the entry you opened it from", - ), - ( - "ARMED views (holdings, discover)", - "the network kinds open with nothing run: Enter makes the ONE live read, " - "and the result is then held until the view closes -- a poll can never fire " - "a venue call", - ), - ( - "q / Esc / m", - "back to the Compliance menu", - ), - ), - "scout-list": ( - ( - "the shortlists", - "every proposal file the keel-asset-scout wrote to the configured " - "proposals directory, newest first; an absent directory is a calm empty " - "state, never an error", - ), - ( - "Enter", - "screen the selected shortlist through the real admission services -- " - "read-only; the human attest step is offered, never auto-run", - ), - ), - "scout-view": ( - ( - "the screened shortlist", - "each candidate with its admission verdict, rendered from the same screen " - "service the CLI runs -- the proposer proposes, it never decides", - ), - ( - "a attest (TYPED)", - "attesting a selected candidate opens the CLI's own typed gate at the " - "terminal: you type the asset code yourself -- the prompt cannot be " - "pre-filled -- and declining changes nothing", - ), - ), -} - - -#: The width every console line must fit: `_paint` clips at the window width and -#: 80-column terminals are this dashboard's stated target -- a clipped entry description -#: tail would be the "what will this do" half of the row (O8), so the menu WRAPS instead. -_MENU_WIDTH = 80 - - -def _entry_rows(entry: ComplianceEntry, cursor: bool) -> list[ScreenLine]: - """One menu entry within the 80-column budget: the ordinal+label row (carrying the - `[typed]` marker), with the description on the same row when it fits and on its own - indented rows when it does not -- the profile menu's guarded-note style, never a - clipped tail. PURE.""" - marker = ">" if cursor else " " - head = f"{marker} {entry.ordinal:>2} {entry.label}" - if entry.typed: - head += " [typed]" - style = "heading" if cursor else "normal" - if len(head) + 1 + len(entry.description) <= _MENU_WIDTH: - return [ScreenLine(f"{head} {entry.description}", style)] - return [ - ScreenLine(head, style), - *( - ScreenLine(wrapped, "muted") - for wrapped in _wrap(entry.description, width=_MENU_WIDTH - 2, indent=" ") - ), - ] - - -def build_compliance_menu_lines( - *, cursor: int = 0, message: str | None = None -) -> list[ScreenLine]: - """The Compliance sub-menu screen: every PRD §3 entry with its description (wrapped to - the 80-column budget, never clipped), the typed entries marked, exactly one - cursor-marked row, and the last action's confirmation line (`message`) -- every write - shows what it did, on the screen it was invoked from. PURE.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- compliance", "heading"), - _blank(), - ] - cursor = max(0, min(cursor, len(COMPLIANCE_MENU) - 1)) - for index, entry in enumerate(COMPLIANCE_MENU): - lines.extend(_entry_rows(entry, cursor=index == cursor)) - lines.append(_blank()) - lines.append( - ScreenLine( - "up/k down/j move · Enter/Space select · 1-9 jump", "muted", - ) - ) - lines.append(ScreenLine("q/Esc/m to the Compliance menu", "muted")) - if message is not None: - lines.append(_blank()) - # The toast's STYLE follows the message's own semantics (a failure is an alert, - # a cancellation a warning) -- `_message_style`, the rule every other console - # toast keeps; a hardcoded "ok" made a green "failed" line. - lines.append(ScreenLine(message, _message_style(message))) - return lines - - -# -- the forms ------------------------------------------------------------------------------------ - - -@dataclass(frozen=True) -class AttestFields: - """The `keel assets attest` fields, exactly the CLI's own options.""" - - asset: str - sector: str - backing: str - pays_yield: bool - source: str - attested_by: str - - -@dataclass(frozen=True) -class InstrumentAttestFields: - """The `keel assets attest-instrument` fields, exactly the CLI's own options.""" - - venue: str - product: str - wrapper: str - source: str - attested_by: str - - -@dataclass(frozen=True) -class ExemptFields: - """The `keel assets exempt` fields, exactly the CLI's own options.""" - - asset: str - criterion: str - rationale: str - granted_by: str - - -def _ask(prompt_fn: PromptFn, question: str) -> str: - return prompt_fn(question).strip() - - -def typed_asset_confirmation(asset: str, prompt_fn: PromptFn) -> bool: - """The attest form's typed gate (the PRD tree marks attest "(typed)"): the operator - types the ASSET CODE back, naming the thing being recorded. Nothing is pre-filled and - no other phrase accepts -- the question SHOWS the code, the answer must BE it, exactly: - case-sensitive, with no whitespace tolerance beyond the trailing newline the prompt - itself appends. The CLI's own typed gate (`_require_interactive_confirmation`) accepts - exactly `yes` -- not `YES`, not ` yes ` -- and this gate is exactly as strict.""" - answer = prompt_fn( - f'Type "{asset}" to record this attestation (anything else cancels)' - ).rstrip("\n") - return answer == asset - - -def collect_attest(prompt_fn: PromptFn, *, asset: str | None = None) -> AttestFields | None: - """Collect the attest fields through `prompt_fn`. `asset` pre-seeds the asset code - (the scout flow's chosen candidate) -- the judgment fields (sector, backing, source, - attestor) are ALWAYS asked: the scout's shariah hypothesis is UNVERIFIED by design and - can never pre-fill the human's classification. `None` = cancelled (empty first answer). - Raises `FormInputError` for a field the CLI's own Choice validation would refuse.""" - if asset is None: - asset = _ask(prompt_fn, "asset code (e.g. BTC) -- empty cancels") - if not asset: - return None - asset = asset.upper() - sector = _ask(prompt_fn, "sector -- the core business line / purpose of the token") - if not sector: - raise FormInputError("sector must be a non-empty description") - backing = _ask( - prompt_fn, f"backing -- one of: {', '.join(sorted(KNOWN_BACKINGS))} (ayn/dayn/native)" - ).lower() - if backing not in KNOWN_BACKINGS: - raise FormInputError( - f"backing must be one of: {', '.join(sorted(KNOWN_BACKINGS))}; got {backing!r}" - ) - pays_yield = _ask(prompt_fn, "does BARE holding pay a yield, without staking/lending? (y/N)") - pays_yield_bool = pays_yield.lower().startswith("y") - source = _ask(prompt_fn, "source -- where this was established: URL, standard, ruling") - if not source: - raise FormInputError("an unsourced claim is not evidence -- source is required") - attested_by = _ask(prompt_fn, "attested-by -- who established it") - if not attested_by: - raise FormInputError("attested-by is required (the audit trail names who said it)") - return AttestFields( - asset=asset, - sector=sector, - backing=backing, - pays_yield=pays_yield_bool, - source=source, - attested_by=attested_by, - ) - - -def apply_attest(repo: Any, fields: AttestFields, now_ts: int) -> str: - """THE write, the same repository call `keel assets attest` makes with the same - argument names -- and the same confirmation line.""" - repo.upsert_asset_attestation( - asset=fields.asset, - sector=fields.sector, - backing=fields.backing, - pays_yield=fields.pays_yield, - source=fields.source, - attested_by=fields.attested_by, - attested_at=now_ts, - ) - return ( - f"attested {fields.asset}: sector={fields.sector} backing={fields.backing} " - f"pays_yield={fields.pays_yield}" - ) - - -def run_attest_form( - repo: Any, prompt_fn: PromptFn, now_ts: int, *, asset: str | None = None -) -> str: - """Collect -> typed-confirm -> write. The typed gate is BETWEEN the fields and the - repository: a wrong phrase returns the cancellation line and writes nothing.""" - try: - fields = collect_attest(prompt_fn, asset=asset) - except FormInputError as exc: - return f"Error: {exc}" - if fields is None: - return "attest cancelled -- nothing recorded" - if not typed_asset_confirmation(fields.asset, prompt_fn): - return ( - f"attest cancelled -- the typed confirmation did not name {fields.asset}; " - "nothing recorded" - ) - return apply_attest(repo, fields, now_ts) - - -def collect_instrument_attest(prompt_fn: PromptFn) -> InstrumentAttestFields | None: - """Collect the attest-instrument fields through `prompt_fn`. `None` = cancelled (empty - product). Raises `FormInputError` for a field the CLI's own Choice validation would - refuse -- the same convention `collect_attest` keeps: the COLLECT step raises, the - `run_*` wrapper renders (one error shape, not two).""" - venue = _ask(prompt_fn, f"venue (empty = {VENUE}, the default)") - if not venue: - venue = VENUE - product = _ask(prompt_fn, "venue product id (e.g. BTC-USD) -- empty cancels") - if not product: - return None - product = product.upper() # matches the uppercase ids the screen looks up by - wrapper = _ask( - prompt_fn, f"wrapper -- one of: {', '.join(sorted(KNOWN_WRAPPERS))} (only spot admits)" - ).lower() - if wrapper not in KNOWN_WRAPPERS: - raise FormInputError( - f"wrapper must be one of: {', '.join(sorted(KNOWN_WRAPPERS))}; got {wrapper!r}" - ) - source = _ask(prompt_fn, "source -- the venue's contract spec, its API docs, a filing") - if not source: - raise FormInputError("an unsourced claim is not evidence -- source is required") - attested_by = _ask(prompt_fn, "attested-by -- who established it") - if not attested_by: - raise FormInputError("attested-by is required") - return InstrumentAttestFields( - venue=venue, - product=product, - wrapper=wrapper, - source=source, - attested_by=attested_by, - ) - - -def run_instrument_attest_form(repo: Any, prompt_fn: PromptFn, now_ts: int) -> str: - """`keel assets attest-instrument` as a form. Not typed (the CLI's own gate is none); - the wrapper vocabulary is enforced exactly as the CLI's Choice enforces it, as a - `FormInputError` from the collect step rendered here -- `run_attest_form`'s own - convention, never a second inline error-string shape.""" - try: - fields = collect_instrument_attest(prompt_fn) - except FormInputError as exc: - return f"Error: {exc}" - if fields is None: - return "attest-instrument cancelled -- nothing recorded" - repo.upsert_instrument_attestation( - venue=fields.venue, - product_id=fields.product, - wrapper=fields.wrapper, - source=fields.source, - attested_by=fields.attested_by, - attested_at=now_ts, - ) - return f"attested {fields.product} on {fields.venue}: wrapper={fields.wrapper}" - - -def run_exempt_form(repo: Any, prompt_fn: PromptFn, now_ts: int) -> str: - """`keel assets exempt` as a form: criterion restricted to `WAIVABLE_CRITERIA` (the - CLI's own Choice restriction), a blank rationale refused (the CLI's own guard).""" - asset = _ask(prompt_fn, "asset code (e.g. PAXG) -- empty cancels") - if not asset: - return "exempt cancelled -- nothing recorded" - asset = asset.upper() - criterion = _ask( - prompt_fn, f"criterion to waive -- one of: {', '.join(sorted(WAIVABLE_CRITERIA))}" - ).lower() - if criterion not in WAIVABLE_CRITERIA: - return ( - f"Error: criterion must be one of: {', '.join(sorted(WAIVABLE_CRITERIA))} -- a " - "DATA/market criterion, never a shariah one" - ) - rationale = prompt_fn( - "rationale -- why this waiver is granted (documentation, not a formality)" - ) - if not rationale.strip(): - return "Error: rationale must be a non-empty documented reason" - granted_by = _ask(prompt_fn, "granted-by -- who granted it") - if not granted_by: - return "Error: granted-by is required" - repo.upsert_screen_exception( - asset=asset, - criterion=criterion, - rationale=rationale.strip(), - granted_by=granted_by, - granted_at=now_ts, - ) - return f"recorded exception: {asset} waives '{criterion}' criterion (by {granted_by})" - - -def run_unexempt_form(repo: Any, prompt_fn: PromptFn) -> str: - """`keel assets unexempt` as a form -- a de-risking action, always allowed, and the - repository's own rowcount tells a real revoke from a no-op (never success either way).""" - asset = _ask(prompt_fn, "asset code -- empty cancels") - if not asset: - return "unexempt cancelled -- nothing changed" - asset = asset.upper() - criterion = _ask( - prompt_fn, f"criterion to revoke -- one of: {', '.join(sorted(WAIVABLE_CRITERIA))}" - ).lower() - if criterion not in WAIVABLE_CRITERIA: - return f"Error: criterion must be one of: {', '.join(sorted(WAIVABLE_CRITERIA))}" - removed = repo.delete_screen_exception(asset, criterion) - if removed: - return f"revoked exception: {asset} no longer waives '{criterion}' criterion" - return f"no such exception: {asset} has no '{criterion}' waiver" - - -def run_subscription_attest_form( - repo: Any, config: Any, prompt_fn: PromptFn, now_ts: int -) -> str: - """Collect `subscription attest`'s fields and dispatch to the extracted service - (`subscription.apply_subscription_attest`) -- tier resolution, venue binding and - pacing carry-over live THERE, once, shared with the CLI.""" - venue = _ask(prompt_fn, "venue -- empty means this config's bound venue") - tier_name = _ask(prompt_fn, "tier name from config.yaml's tiers -- empty cancels") - if not tier_name: - return "subscription attest cancelled -- nothing recorded" - pacing = _ask( - prompt_fn, "pacing (opportunistic/even_daily) -- empty keeps the venue's current value" - ).lower() - if pacing and pacing not in ("opportunistic", "even_daily"): - return "Error: pacing must be opportunistic or even_daily" - try: - return apply_subscription_attest( - repo, - config, - venue=venue or None, - tier_name=tier_name, - pacing=pacing or None, - now_ts=now_ts, - ) - except ValueError as exc: - return f"Error: {exc}" - - -def run_subscription_set_form( - repo: Any, config: Any, prompt_fn: PromptFn, now_ts: int -) -> str: - """Collect `subscription set`'s fields and dispatch to the extracted service.""" - venue = _ask(prompt_fn, "venue -- empty means this config's bound venue") - free_volume_raw = _ask( - prompt_fn, "raw fee-free monthly volume in USD (e.g. 500) -- empty cancels" - ) - if not free_volume_raw: - return "subscription set cancelled -- nothing recorded" - pacing = _ask( - prompt_fn, "pacing (opportunistic/even_daily) -- empty keeps the venue's current value" - ).lower() - if pacing and pacing not in ("opportunistic", "even_daily"): - return "Error: pacing must be opportunistic or even_daily" - try: - return apply_subscription_set( - repo, - config, - venue=venue or None, - free_volume_raw=free_volume_raw, - pacing=pacing or None, - now_ts=now_ts, - ) - except ValueError as exc: - return f"Error: {exc}" - - -def clis_typed_withdrawals_gate() -> bool: - """The CLI's OWN typed gate for `withdrawals attest --enabled`, called verbatim: - `_require_interactive_confirmation` with the command's exact action/detail wording -- - imported from `withdrawals.py`, their ONE home, so the console and the CLI can never - drift into two wordings for the same gate (pinned by test against that home). - The console wraps it in the curses suspend/restore dance so the prompt renders - in-console; the gate itself is untouched -- never pre-filled, never piped. Fails - CLOSED: a wrong phrase, a Ctrl-C, any exception answers False and the halt stays.""" - from keel.commands._common import _require_interactive_confirmation - - try: - _require_interactive_confirmation( - WITHDRAWALS_ATTEST_ACTION, WITHDRAWALS_ATTEST_DETAIL - ) - return True - except Exception: - return False - - -def run_withdrawals_form( - repo: Any, - prompt_fn: PromptFn, - now_ts: int, - *, - confirm_enabled_fn: Callable[[], bool] | None = None, -) -> str: - """`keel withdrawals attest` as a form, with the CLI's own asymmetry: `--suspended` - only ever REDUCES capability and is ungated; `--enabled` RELEASES a rail-17 halt and - demands the typed gate (`clis_typed_withdrawals_gate` unless a test injects its own). - A declined gate means not a single state row is written.""" - if confirm_enabled_fn is None: - confirm_enabled_fn = clis_typed_withdrawals_gate - answer = _ask( - prompt_fn, - "attest withdrawals as enabled or suspended? (enabled/suspended) -- empty cancels", - ).lower() - if answer not in ("enabled", "suspended"): - return "withdrawals attest cancelled -- nothing recorded" - enabled = answer == "enabled" - if enabled and not confirm_enabled_fn(): - return "withdrawals attest cancelled -- typed confirmation not given; nothing recorded" - repo.set_state("withdrawals_enabled", bool(enabled)) - repo.set_state("withdrawals_attested_at", now_ts) - ttl_days = WITHDRAWAL_ATTESTATION_TTL_SEC // 86400 - state = "ENABLED" if enabled else "SUSPENDED" - line = f"withdrawals attested {state}; expires in {ttl_days} days" - if not enabled: - line += " -- new ENTRIES are now halted (rail 17). Exits are deliberately unaffected." - return line - - -#: The loop's single form-dispatch seam: form name -> runner(repo, config, prompt_fn, now_ts). -#: Every member is directly unit-tested above/below; `run_form` adds no behavior of its own. -FORM_RUNNERS: dict[str, Callable[[Any, Any, PromptFn, int], str]] = { - "attest": lambda repo, config, prompt_fn, now_ts: run_attest_form(repo, prompt_fn, now_ts), - "attest-instrument": lambda repo, config, prompt_fn, now_ts: run_instrument_attest_form( - repo, prompt_fn, now_ts - ), - "exempt": lambda repo, config, prompt_fn, now_ts: run_exempt_form(repo, prompt_fn, now_ts), - "unexempt": lambda repo, config, prompt_fn, now_ts: run_unexempt_form(repo, prompt_fn), - "subscription-attest": run_subscription_attest_form, - "subscription-set": run_subscription_set_form, - "withdrawals-attest": lambda repo, config, prompt_fn, now_ts: run_withdrawals_form( - repo, prompt_fn, now_ts - ), -} - - -def run_form( - name: str, - repo: Any, - config: Any, - prompt_fn: PromptFn, - now_ts: int, - *, - confirm_enabled_fn: Callable[[], bool] | None = None, -) -> str: - """Dispatch a Compliance form by name -- what the loop calls, and what the tests spy. - An unknown name is a programming error (the menu's targets and this table are pinned - together by test), so it raises rather than rendering a calm nothing.""" - if name == "withdrawals-attest" and confirm_enabled_fn is not None: - return run_withdrawals_form( - repo, prompt_fn, now_ts, confirm_enabled_fn=confirm_enabled_fn - ) - runner = FORM_RUNNERS[name] - return runner(repo, config, prompt_fn, now_ts) - - -# -- the scout-results browser (O6) ---------------------------------------------------------------- - - -def scout_listing(config: Any) -> tuple[tuple[ScoutFile, ...], Path]: - """The proposals the scout wrote, from CONFIG's `proposals_dir` (default - `~/keel/proposals` -- the documented config key, never a TUI-side path guess), via - the service read `admission.list_shortlists`. Returns the files newest-first and the - directory it read (rendered on the empty state, so "no proposals" is checkable).""" - directory = Path(config.proposals_dir).expanduser() - return list_shortlists(directory), directory - - -def build_scout_list_lines( - files: tuple[ScoutFile, ...], directory: Path, *, cursor: int = 0 -) -> list[ScreenLine]: - """The Scout results browser's file list: every shortlist newest-first with its date, - exactly one cursor row, and an empty state that names the directory it read AND the - filename convention -- an operator whose file is called `candidates.json` is one - rename away and must be able to discover that from this screen. The directory-bearing - lines WRAP to the 80-column budget (`_paint` clips at the window width): a path's tail - is exactly the part that identifies it. PURE.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- compliance / Scout results", "heading"), - ] - for wrapped in _wrap(f"shortlists in {directory} (config proposals_dir), newest first", - indent=""): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - if not files: - for wrapped in _wrap(f"no proposals -- {directory} holds no *shortlist.json file yet.", - indent=""): - lines.append(ScreenLine(wrapped, "normal")) - for wrapped in _wrap( - "the keel-asset-scout writes there (operator-local); a shortlist's name must " - 'end in "shortlist.json" and hold {"candidates": [...]}.', - indent="", - ): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append( - ScreenLine("Press q, Esc or m to return to the Compliance menu.", "muted") - ) - return lines - cursor = max(0, min(cursor, len(files) - 1)) - for index, scout_file in enumerate(files): - marker = ">" if index == cursor else " " - day = time.strftime("%Y-%m-%d %H:%M", time.localtime(scout_file.mtime_ts)) - text = ( - f"{marker} {scout_file.path.name} · written {day} · {scout_file.size_bytes} bytes" - ) - lines.append(ScreenLine(text, "heading" if index == cursor else "normal")) - lines.append(_blank()) - for wrapped in _wrap( - "Enter opens a shortlist · q/Esc/m back -- the flow proposes and screens; it " - "never auto-attests", - indent="", - ): - lines.append(ScreenLine(wrapped, "muted")) - return lines - - -def build_scout_file_lines( - view: ProposeView, *, cursor: int = 0 -) -> tuple[list[ScreenLine], int, int]: - """The selected shortlist, rendered through the propose services' own renderer - (`render_propose_view` -- parse + `build_proposal_report` through THE gate), with a - cursor over the CANDIDATE rows (the `ADMIT`/`REJECT` verdict lines map 1:1 to - `view.report.screened`) so `a` can offer the typed attest step for the chosen asset. - - Returns (lines, the cursor row's line index, the candidate count) -- the count is what - lets the loop clamp the cursor and refuse `a` when there is nothing selectable. PURE.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- compliance / Scout results", "heading"), - ] - verdict_rows: list[int] = [] - for text in render_propose_view(view): - if not text: - lines.append(_blank()) - continue - stripped = text.lstrip() - if view.status == "ok" and stripped.startswith(("ADMIT", "REJECT")): - verdict_rows.append(len(lines)) - lines.append(ScreenLine(text, _admission_line_style(text))) - cursor_line = len(lines) # default: no row marked (no candidates / non-ok view) - if verdict_rows: - cursor = max(0, min(cursor, len(verdict_rows) - 1)) - target = verdict_rows[cursor] - marked = lines[target] - lines[target] = ScreenLine(f"> {marked.text}", "heading") - cursor_line = target - lines.append(_blank()) - lines.append( - ScreenLine( - "up/k down/j move · a attest the selected candidate (TYPED -- never auto-run) · " - "q/Esc back to the list", - "muted", - ) - ) - candidates = ( - len(view.report.screened) if view.status == "ok" and view.report is not None else 0 - ) - return lines, cursor_line, candidates - - -# -- the "Shariah in force" browser (O10) ---------------------------------------------------------- - - -@dataclass(frozen=True) -class FiqhConstraint: - """One fiqh-derived constraint the rails encode, SOURCED from `docs/fiqh-basis.md`'s - own structure: `quote` is a verbatim passage of that document (whitespace-normalized - when rendered/wrapped), and `citation` is one of its EXACT section headings -- so the - browser can never drift into a TUI-authored fiqh summary (pinned by test against the - document itself).""" - - key: str - quote: str - citation: str - - -#: The fiqh-derived constraints O10 names, each keyed to the fiqh basis's own section. -#: The no-leverage/no-interest posture rides the screen's attested axes; spot-only is the -#: charter (rails 18/19); qabd is rail 17; purification is §65.9 -- exactly the document's -#: own mapping, quoted from it. -FIQH_CONSTRAINTS: tuple[FiqhConstraint, ...] = ( - FiqhConstraint( - key="attested-vs-computed", - quote=( - "market facts are computed, Shariah classifications are **ATTESTED, never " - "inferred**" - ), - citation="## What is attested versus what is computed", - ), - FiqhConstraint( - key="rail-1-allowlist", - quote=( - "Per-trade and un-overridable: every intent, DCA included, must be for an " - "allowlisted asset." - ), - citation="### Rail 1 — allowlist enforcement (`keel/execution/guards.py`)", - ), - FiqhConstraint( - key="rail-17-qabd", - quote=( - "An asset we cannot withdraw is an asset we may not validly POSSESS — so " - "acquiring more of it is the thing to stop." - ), - citation="### Rail 17 — withdrawal capability, `qabd` §65.4", - ), - FiqhConstraint( - key="rails-18-19-spot-charter", - quote="Spot-only is this agent's CHARTER, not an operator preference", - citation="### Rails 18/19 — settlement currency and spot-instrument shape", - ), - FiqhConstraint( - key="purification", - quote=( - "interest/reward credits are segregated from realised P&L and the equity base, " - "reported as owed to charity, never recognised as profit" - ), - citation="### Purification (§65.9) and idle-balance rewards (§56.3)", - ), -) - - -@dataclass(frozen=True) -class VocabTerm: - """One vocabulary term of the shariah screen, anchored to `docs/fiqh-basis.md`: - `stated=True` means `definition` is a verbatim passage of the document; a term the - document does NOT state renders `stated=False` and says so, rather than being defined - here -- the document's own "not stated" honesty rule, inherited (never a second, - drifting glossary; the full single-source glossary is C7's).""" - - term: str - definition: str - citation: str - stated: bool = True - - -VOCABULARY: tuple[VocabTerm, ...] = ( - VocabTerm( - term="attestation", - definition=( - # [review #406] the full contiguous passage, so "records them" carries its - # antecedent -- the sentence alone dangled ("them" what?). The glossary's - # entry is pinned equal to this, whitespace-normalized. - "market facts are computed, Shariah classifications are **ATTESTED, never " - "inferred**. Whether a token's core purpose is a haram sector (§28.4), " - "whether it is asset-backed `'ayn` or a claim `dayn` (§65.5/§67.2), and " - "whether it pays a riba-like yield are questions of fact-plus-scholarship " - "about the world. No module in this repository derives them from candles, " - "and none pretends to. A human records them, with a source and a name, via " - "`keel assets attest`." - ), - citation="## What is attested versus what is computed", - ), - VocabTerm( - term="qabd", - definition="possession is the ability to dispose, not physical custody", - citation="### Rail 17 — withdrawal capability, `qabd` §65.4", - ), - VocabTerm( - term="riba", - definition=( - "Coinbase pays USDC rewards on idle balances, that interest is riba, and it " - "accrues with no order placed" - ), - citation="### Purification (§65.9) and idle-balance rewards (§56.3)", - ), - VocabTerm( - term="maisir", - definition=( - "what makes speculation *maisir* is non-ownership, non-delivery, " - "difference-settlement" - ), - citation="### Rails 18/19 — settlement currency and spot-instrument shape", - ), - VocabTerm( - term="exemption", - definition=( - "a documented exception (`keel assets exempt`) may waive only ONE criterion " - "today: `history`" - ), - citation="### The curation screen (`keel/compliance/screen.py`)", - ), - VocabTerm( - term="purification", - definition=( - "interest/reward credits are segregated from realised P&L and the equity base, " - "reported as owed to charity, never recognised as profit" - ), - citation="### Purification (§65.9) and idle-balance rewards (§56.3)", - ), - VocabTerm( - term="gharar", - definition=( - "not stated in docs/fiqh-basis.md -- the knowledge-base sources it indexes " - "(docs/superpowers/references/trading-knowledge-base/) are the place to read it" - ), - citation="## How to read the citations", - stated=False, - ), -) - -#: The standing honesty lines, sourced from the fiqh basis's own wording (the boundary -#: sentence and the review-status section's opening clause -- both pinned two-sided by -#: test against the document, the house pattern from `tests/test_fiqh_basis.py`). -NOT_A_FATWA_ENGINE_LINE = ( - "keel is not a fatwa engine. It is an enforcement engine for a ruling you supply." -) -NO_SCHOLARLY_REVIEW_LINE = "No scholarly review of keel's fiqh basis has occurred." - - -def _wrap(text: str, width: int = 78, indent: str = " ") -> list[str]: - """Wrap `text` on spaces to `width`, every continuation line carrying `indent` -- the - shariah screen's quotes, definitions and honesty lines are sentences of the fiqh - basis, not screen furniture, and `_paint` clips at the window width (80-column - terminals are this dashboard's stated target): a clipped citation tail would be the - part an operator scrolled for. PURE.""" - words = text.split() - if not words: - return [indent] - lines: list[str] = [] - current = words[0] - for word in words[1:]: - candidate = f"{current} {word}" - if len(indent) + len(candidate) > width: - lines.append(f"{indent}{current}") - current = word - else: - current = candidate - lines.append(f"{indent}{current}") - return lines - - -def _date(ts: Any) -> str: - """Local-time `YYYY-MM-DD` for an attestation/exception stamp -- the recorded DATE, - not a raw int (and not the time: a ruling's provenance is its day).""" - return time.strftime("%Y-%m-%d", time.localtime(int(ts))) - - -def build_shariah_lines( - inventory: AttestationsInForce, - *, - withdrawals_enabled: bool | None, - now_ts: int, -) -> list[ScreenLine]: - """The "Shariah in force" browser's CONTENT (the view overlay adds title/footer): - what the engine enforces for the ACTIVE profile, rendered from records alone. - - * the attestations in force over the active allowlist -- asset rows (source, ruling, - recorded date) and the instrument statements behind each product, via the service - read `gather_attestations_in_force`; unattested allowlisted assets are NAMED as the - fail-closed gap they are; - * the documented exemptions in effect; - * rail 17's live state (`withdrawals_enabled`: the same read the rail makes, with - `None` rendered UNKNOWN/stale -- fail-closed is a fact worth showing); - * the fiqh-derived constraints, each a verbatim `docs/fiqh-basis.md` quote with its - section citation, and the vocabulary anchored the same way; - * the two standing honesty lines -- as a PINNED FOOTER (`shariah_honesty_lines`, - painted outside the scroll by `pinned_frame`), always visible at every scroll - offset rather than riding the body's tail a viewport below the fold. - - READ-ONLY: pure over its inputs; nothing here re-derives, and no state changes.""" - del now_ts # the dates rendered are the RECORDS' own stamps, not "now" - # The header carries the ACTIVE allowlist however long it is (paper-hourly runs 19 - # assets -- one unwrapped line would clip past the 80-column budget), so it wraps - # through the same `_wrap` the quotes and honesty lines use. - lines: list[ScreenLine] = [] - for wrapped in _wrap( - f"active allowlist: {', '.join(inventory.allowlist)} · quote {inventory.quote}", - indent="", - ): - lines.append(ScreenLine(wrapped, "muted")) - lines.append( - ScreenLine("read-only -- what the engine enforces now, rendered from records", "muted") - ) - lines.append(_blank()) - lines.append(ScreenLine("attestations in force", "heading")) - if not inventory.asset_rows and not inventory.instrument_rows: - lines.append(ScreenLine(" none recorded for the active allowlist", "normal")) - for row in inventory.asset_rows: - lines.append( - ScreenLine( - f" {row['asset']:<8} sector={row['sector']} backing={row['backing']} " - f"pays_yield={bool(row['pays_yield'])}", - "normal", - ) - ) - lines.append( - ScreenLine( - f" source: {row['source']} (by {row['attested_by']}, " - f"{_date(row['attested_at'])})", - "muted", - ) - ) - for row in inventory.instrument_rows: - lines.append( - ScreenLine( - f" {row['product_id']} on {row['venue']}: wrapper={row['wrapper']}", - "normal", - ) - ) - lines.append( - ScreenLine( - f" source: {row['source']} (by {row['attested_by']}, " - f"{_date(row['attested_at'])})", - "muted", - ) - ) - for asset in inventory.unattested: - lines.append( - ScreenLine( - f" {asset:<8} no attestation -- the screen fails closed " - "(unknown is a rejection)", - "warn", - ) - ) - - lines.append(_blank()) - lines.append(ScreenLine("documented exemptions in effect", "heading")) - if not inventory.exceptions: - lines.append(ScreenLine(" none", "muted")) - for row in inventory.exceptions: - lines.append( - ScreenLine( - f" {row['asset']:<8} waives '{row['criterion']}' (by {row['granted_by']}, " - f"{_date(row['granted_at'])})", - "normal", - ) - ) - for wrapped in _wrap(row["rationale"], indent=" "): - lines.append(ScreenLine(wrapped, "muted")) - - lines.append(_blank()) - if withdrawals_enabled is None: - rail17 = ScreenLine( - "rail 17 (qabd) right now: UNKNOWN -- no fresh attestation; rail 17 blocks " - "new entries", - "warn", - ) - elif withdrawals_enabled: - rail17 = ScreenLine("rail 17 (qabd) right now: ENABLED (attested)", "ok") - else: - rail17 = ScreenLine( - "rail 17 (qabd) right now: SUSPENDED -- rail 17 blocks new entries", "warn" - ) - lines.append(rail17) - - lines.append(_blank()) - lines.append( - ScreenLine( - "fiqh-derived constraints the rails encode (quoted from docs/fiqh-basis.md)", - "heading", - ) - ) - for constraint in FIQH_CONSTRAINTS: - for wrapped in _wrap(f"{constraint.key}: {constraint.quote}"): - lines.append(ScreenLine(wrapped, "normal")) - lines.append(ScreenLine(f" -- {constraint.citation}", "muted")) - - lines.append(_blank()) - lines.append(ScreenLine("vocabulary (anchored to docs/fiqh-basis.md)", "heading")) - for term in VOCABULARY: - for wrapped in _wrap(f"{term.term}: {term.definition}"): - lines.append(ScreenLine(wrapped, "normal")) - - # The two standing honesty lines are NOT part of this body: they render as a pinned - # footer (`shariah_honesty_lines` + `pinned_frame`) so no scroll offset can hide them. - return lines - - -def shariah_honesty_lines() -> list[ScreenLine]: - """The shariah view's FIXED footer: the two standing honesty lines, wrapped to the - 80-column budget, in the alert style -- painted OUTSIDE the scroll (see - `pinned_frame`), so they are on screen at EVERY scroll offset. O10's "always visible" - made structural: as the body's tail they were one viewport below the fold on any real - allowlist, and an operator who never scrolled never saw them. PURE.""" - lines: list[ScreenLine] = [] - for wrapped in _wrap(NOT_A_FATWA_ENGINE_LINE): - lines.append(ScreenLine(wrapped, "alert")) - for wrapped in _wrap(f"{NO_SCHOLARLY_REVIEW_LINE} (docs/fiqh-basis.md, review status)"): - lines.append(ScreenLine(wrapped, "alert")) - return lines - - -def pinned_frame( - body: list[ScreenLine], - footer: list[ScreenLine], - *, - offset: int, - height: int, -) -> list[ScreenLine]: - """A scrolled `body` under a FIXED `footer`: the footer's rows are reserved off the - window FIRST, then the body is sliced through `_visible_slice` into what remains -- - so whatever `offset` the body is scrolled to, the frame that gets painted ENDS with - the footer's lines. PURE, and total: a footer taller than `height` still renders - (clipped by `_paint`, never raising here).""" - window = max(height - len(footer), 0) - return [*_visible_slice(body, offset, window), *footer] - - -# -- the view overlay ------------------------------------------------------------------------------ - - -_VIEW_TITLES: dict[str, str] = { - "screen": "screen", - "propose": "propose", - "holdings": "holdings", - "discover": "discover", - "shariah": "Shariah in force", - "subscription": "subscription", - "purification": "purification", -} - -#: The two views whose payload is one live venue read -- they open ARMED, gated behind an -#: explicit Enter exactly like the dashboard's discover overlay, and hold their result. -_NETWORK_VIEWS = ("holdings", "discover") - - -def build_compliance_view_lines( - kind: str, payload: Any, *, error: str | None = None -) -> list[ScreenLine]: - """One Compliance report overlay, PURE over its payload: the service's own renderer's - lines, styled by the admission overlays' own style function, under a title that names - the entry, with the ARMED story for the network-gated views (`payload is None`) and a - fail-soft alert for a read that failed. The caller (the loop) gathers the payload -- - this function never touches a repo, a broker or the network.""" - lines: list[ScreenLine] = [ - ScreenLine(f"keel console -- compliance / {_VIEW_TITLES.get(kind, kind)}", "heading"), - _blank(), - ] - if error is not None: - # Honest about what happens next: nothing retries on its own here -- the network - # views HOLD this error until Enter re-runs the read (and the offline views - # rebuild on the next poll, which Enter also forces) -- so the line names the - # retry key instead of claiming a "retrying..." that never happens. - lines.append( - ScreenLine(f"{kind} read failed: {error} -- press Enter to retry", "alert") - ) - elif payload is None: - # The network-gated views' ARMED state: opening them made NO call. What Enter - # does is named, in the same words the discover overlay uses for the same story. - if kind == "holdings": - call = "get_accounts -- the same balance read the dashboard's live-balance line makes" - else: - call = "list_products -- the same product metadata read `keel assets discover` makes" - lines.append(ScreenLine("ARMED -- no network call has been made yet.", "normal")) - lines.append(_blank()) - lines.append( - ScreenLine(f"Pressing Enter makes ONE live call to the venue ({call}).", "normal") - ) - lines.append( - ScreenLine( - "It never fires from opening this screen, and the result is held until " - "Enter is pressed again or the screen closes.", - "muted", - ) - ) - if kind == "holdings": - lines.append( - ScreenLine( - "Holdings are CANDIDATES, not admissions -- nothing here is admitted.", - "muted", - ) - ) - elif kind == "screen": - for text in render_screen_report(payload): - lines.append(ScreenLine(text, _admission_line_style(text)) if text else _blank()) - elif kind == "propose": - for text in render_propose_view(payload): - lines.append(ScreenLine(text, _admission_line_style(text)) if text else _blank()) - elif kind == "holdings": - for text in render_holdings(payload): - lines.append(ScreenLine(text, _admission_line_style(text)) if text else _blank()) - elif kind == "discover": - for text in render_discover(payload): - lines.append(ScreenLine(text, _admission_line_style(text)) if text else _blank()) - elif isinstance(payload, list) and payload and isinstance(payload[0], ScreenLine): - # Pre-styled content (the shariah browser's own builder). - lines.extend(payload) - else: - # The report-shape payloads (purification, subscription) arrive as the service's - # own already-rendered `list[str]` lines. - for text in payload: - lines.append(ScreenLine(text, _admission_line_style(text)) if text else _blank()) - lines.append(_blank()) - lines.append(ScreenLine("Press q or Esc to return to the Compliance menu.", "muted")) - return lines diff --git a/keel/commands/console.py b/keel/commands/console.py deleted file mode 100644 index 72f71171..00000000 --- a/keel/commands/console.py +++ /dev/null @@ -1,809 +0,0 @@ -"""The console shell around the dashboard -- menu navigation, the deployment profiles, and -the session banner (issue #388 C2; PRD O4, O9 and §5 C2). - -The PRD's ask for this slice was structure, not features: the TUI grows a menu/sub-menu -navigation whose entries are the PRD §3 tree, with everything beyond Dashboard/Profile/Help -a named placeholder ("lands in C3/C4/C5") owned by a later slice. C3 (issue #389) landed -Compliance (`keel.commands.compliance_console`), C4 (issue #390) the strategy console and -the research readers, C5 (issue #391) the Trading and Data menus, and C6 (issue #392) the -Account menu (`keel.commands.account_console`) -- the tree is whole, no entry is a -placeholder anymore, and the placeholder MECHANISM stays for a future slice's entry. The -dashboard stays the landing screen, untouched -- the shell lands AROUND it (`run_live` -gains menu modes; the pre-existing modes and every pure builder are unchanged). - -Three pure surfaces lived here at C2; C7 (issue #394) added the Venues browser and -the module's contextual-help rows. All directly unit-testable without curses (the -`build_*`/`discover_*`/`switch_*` functions take their inputs as values), mirroring -`keel/commands/tui.py`'s split between a pure screen model and the thin I/O loop: - -* **The deployment convention (O4)** -- the four known config+db pairs, discovered from the - tracked config files present in the deployment directory. A switch rebinds the WHOLE pair - through the same `_load_cfg`/`_open_repo` loaders every CLI command uses (`ConsoleBinding`), - and the LIVE pair is guarded by an explicit confirm step. -* **The menu model (PRD §3)** -- `CONSOLE_MENU`'s nine entries and the builders that render - them, their placeholder notices, and the Profile menu. -* **The session banner (O9)** -- `build_banner_lines`, composed ONLY from the recorded - session state (`keel.agent.latest_recorded_session`) and the venue adapter's own - `session_bound` declaration: 24/7 for always-open venues, OPEN/CLOSED with the recorded - next open/close for session-bound ones, and CLOCK UNAVAILABLE fail-loud when the record - is absent or stale. No broker, no network, no TUI-side calendar -- the recording IS the - source, exactly as `fetch --check`/`status` read it. -* **The Venues browser (O7, C7)** -- `build_venues_lines`, a THIN rendering of the - `keel.commands.brokers` service payload (the same one `keel brokers list` prints) - with the SELECTED adapter highlighted and the active deployment's binding named: - capability display only, never key-presence inference. -* **Contextual help (O8, C7)** -- `CONTEXT_HELP`, this module's screens' rows for the - `?` overlay; `keel.commands.help_console` is the registry and renderer. - -No secrets anywhere in here: the banner and the profile menu render file NAMES and venue -declarations only, never config contents. -""" - -from __future__ import annotations - -import textwrap -import time -from collections.abc import Callable -from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING, Any - -import click - -from keel import agent -from keel.commands import brokers -from keel.commands._common import _load_cfg, _open_repo -from keel.commands.tui import ScreenLine, _blank, _market_session_style - -if TYPE_CHECKING: - from keel.config import Config - from keel.data.repository import Repository - - -# -- the deployment convention (O4) --------------------------------------------------------------- - - -@dataclass(frozen=True) -class DeploymentProfile: - """One deployment: a config file and its database, travelling as a PAIR (the wrappers' - rule -- the whole reason `keel-live`/`keel-paperhourly`/`keel-equities` exist is that - `--db` defaults to keel.db, and a config opened against the wrong ledger answers about - the wrong account). `requires_confirmation` is LIVE's guard (O3): pointing the console - at real money demands an explicit confirm step.""" - - key: str - label: str - config_path: str - db_path: str - requires_confirmation: bool = False - - -#: THE FOUR KNOWN DEPLOYMENTS, by convention and not by a registry file: each pair is what -#: the deployment's own wrapper pins (`keel-paper`, `keel-live`, `keel-paperhourly`, -#: `keel-equities`) and what `docs/operator-runbook.md`'s deployment table states -- -# -#: paper-forward config.paperforward.yaml + keel.db (the --db default) -#: live config.live-sandbox.yaml + keel-live.db (guarded) -#: paper-hourly config.paper-hourly.yaml + keel-paperhourly.db -#: paper-equities config.paper-equities.yaml + keel-equities.db -# -#: The tracked config files ARE the registry: `discover_profiles` lists the pairs whose -#: config exists in the deployment directory (the working directory, the same place the -#: CLI resolves its relative `--config`/`--db` paths), so a checkout without a live config -#: offers no live entry, and a new deployment becomes console-visible by shipping its -#: config + wrapper -- no second list to keep alive. The database half of a pair is not -#: existence-checked: `_open_repo` creates and migrates it on first open, exactly as the -#: CLI does for a fresh deployment. -KNOWN_PROFILES: tuple[DeploymentProfile, ...] = ( - DeploymentProfile( - key="paper-forward", - label="paper-forward", - config_path="config.paperforward.yaml", - db_path="keel.db", - ), - DeploymentProfile( - key="live", - label="LIVE", - config_path="config.live-sandbox.yaml", - db_path="keel-live.db", - requires_confirmation=True, - ), - DeploymentProfile( - key="paper-hourly", - label="paper-hourly", - config_path="config.paper-hourly.yaml", - db_path="keel-paperhourly.db", - ), - DeploymentProfile( - key="paper-equities", - label="paper-equities", - config_path="config.paper-equities.yaml", - db_path="keel-equities.db", - ), -) - - -def discover_profiles(base_dir: Any = None) -> list[DeploymentProfile]: - """The known deployments whose config file exists in `base_dir` (the working directory - when omitted), in `KNOWN_PROFILES`' stable order. PURE aside from the `stat` reads -- - no config is parsed here, only named.""" - root = Path.cwd() if base_dir is None else Path(base_dir) - return [profile for profile in KNOWN_PROFILES if (root / profile.config_path).is_file()] - - -def active_profile( - config_path: str, db_path: str, profiles: list[DeploymentProfile] | None = None -) -> DeploymentProfile | None: - """The deployment the console is currently bound to, by EXACT pair -- both halves, - because a deployment is the pair, not the config. `None` for anything else (a raw - `keel tui` on the `config.yaml` default, or a config opened against another - deployment's db): the banner then names the raw pair and says it recognizes nothing, - rather than guessing a label an operator would trust.""" - known = KNOWN_PROFILES if profiles is None else tuple(profiles) - for profile in known: - if profile.config_path == config_path and profile.db_path == db_path: - return profile - return None - - -class ConsoleBinding: - """The console's active deployment binding -- the config/db pair every screen reads. - - `open_state` IS the CLI path, not a parallel one: it writes the bound pair into the - command's `ctx.obj` and calls the same `_load_cfg`/`_open_repo` every CLI command - uses, so a switched console reads exactly what `keel --config X --db Y status` would. - `rebind` swaps the pair in one assignment; the next `open_state()` (every poll, every - screen) reflects it, which is the whole O4 acceptance -- profile switching visibly - rebinds config/db everywhere in one action. - - Everything STATIC about the bound pair is resolved once per binding and cached here, - not re-derived per poll: the venue's session-boundness (`session_bound`), whose - resolution is an adapter-registry walk (`venue_session_bound` -> `load_broker` -> - `discover_brokers()`, an `importlib.metadata` scan) the banner would otherwise repeat - on every render of every screen, a few times a minute for as long as the console runs - -- on top of the deliberate per-poll `open_state()` the banner's recorded-session read - already needs. `rebind` invalidates the cache, because a different pair means a - different config and possibly a different venue; a config file edited IN PLACE - mid-session is not picked up until the operator re-selects the profile (or restarts), - the same freshness a switched console already grants every other static read. - """ - - def __init__( - self, ctx: click.Context, *, config_path: str, db_path: str - ) -> None: - self._ctx = ctx - self._config_path = config_path - self._db_path = db_path - self._session_bound: bool | None = None - - @property - def config_path(self) -> str: - return self._config_path - - @property - def db_path(self) -> str: - return self._db_path - - @property - def pair(self) -> tuple[str, str]: - """The bound (config_path, db_path) -- the value `active_profile` resolves.""" - return (self._config_path, self._db_path) - - def session_bound(self, config: Config) -> bool: - """The bound deployment's venue session-boundness, resolved ONCE per binding (see - the class docstring) through the same conservative `venue_session_bound` read -- - an unresolvable venue still answers `True`, so the banner fails loud rather than - assuming a 24/7 it cannot know.""" - if self._session_bound is None: - self._session_bound = venue_session_bound(config) - return self._session_bound - - def rebind(self, profile: DeploymentProfile) -> None: - """Swap the binding to `profile`'s WHOLE pair. No validation here -- `switch_ - profile` is the guarded entry point; this is the one-line mutation beneath it. - Also drops the cached session-boundness: the new pair's venue may differ.""" - self._config_path = profile.config_path - self._db_path = profile.db_path - self._session_bound = None - - def open_state(self) -> tuple[Repository, Config]: - """Open the bound deployment through the CLI's own loaders (`_common._open_repo`/ - `_load_cfg`), the exact seam `keel tui`'s `open_state` closures always used.""" - self._ctx.obj["config_path"] = self._config_path - self._ctx.obj["db_path"] = self._db_path - return _open_repo(self._ctx), _load_cfg(self._ctx) - - -def switch_profile( - binding: ConsoleBinding, - profile: DeploymentProfile, - *, - confirm_fn: Callable[[], bool], - profiles: list[DeploymentProfile] | None = None, -) -> str: - """Apply a profile switch to `binding`, returning the operator-facing toast text. - - The pair rule enforced, not assumed: `profile` must be one of the discovered known - pairs -- a config from one deployment on another's database (the wrappers' footgun) is - REFUSED with a `ValueError` rather than bound. Switching to the already-active pair is - a calm no-op. The LIVE pair asks `confirm_fn()` first and stays untouched on a decline - -- an explicit confirm step (a y/N at the terminal in the live loop), deliberately NOT - O3's typed contract: typed confirmation is for destructive actions, and pointing the - console at live data changes what you are LOOKING at, not what the engine does. - """ - known = discover_profiles() if profiles is None else profiles - if profile not in known: - raise ValueError( - f"refusing to bind {profile.config_path} + {profile.db_path}: not one of the " - "known config+db pairs (a deployment is the PAIR, per the wrappers)" - ) - if binding.pair == (profile.config_path, profile.db_path): - return f"profile: {profile.label} already active" - if profile.requires_confirmation and not confirm_fn(): - return "profile unchanged -- live confirmation not given" - binding.rebind(profile) - return f"profile -> {profile.label} ({profile.config_path} + {profile.db_path})" - - -# -- the menu model (PRD §3) ----------------------------------------------------------------------- - - -@dataclass(frozen=True) -class MenuEntry: - """One top-level entry of the PRD §3 tree. `lands_in` names the console slice that owns - the entry's behavior: `None` means the entry works TODAY; anything else is a - placeholder that renders a 'lands in Cx' notice, so no menu item is ever a dead click - and no future slice has to restructure the tree. - - `action` is what selecting the entry does in the shell: `"dashboard"`/`"profile"`/ - `"help"` are this slice's three live destinations, `"compliance"` is C3's (issue #389) - -- the Compliance sub-menu, `keel.commands.compliance_console` -- `"strategy"` and - `"research"` are C4's (issue #390), `"trading"` and `"data"` are C5's (issue #391) -- - `keel.commands.trading_console` and `keel.commands.data_console` -- `"account"` is - C6's (issue #392, `keel.commands.account_console`), and `"placeholder"` everything a - FUTURE slice has not landed yet: a closed vocabulary the live loop dispatches on, - rather than string-matching labels. Since C6 no current entry uses it -- the - mechanism stays for the next slice, unused today.""" - - ordinal: int - label: str - description: str - lands_in: str | None = None - action: str = "placeholder" - - @property - def available(self) -> bool: - return self.lands_in is None - - -#: The PRD §3 tree's top level, in tree order. The slice owners are the PRD §5 phasing's -#: own assignments: Compliance landed with C3 (issue #389); Rules and Research with C4 -#: (issue #390) -- the strategy console (`keel.commands.strategy_console`) and the -#: evidence readers (`keel.commands.research_console`); Trading and Data with C5 -#: (issue #391) -- `keel.commands.trading_console` and `keel.commands.data_console`; -#: Account with C6 (issue #392) -- `keel.commands.account_console`, the tree's last -#: placeholder turned real. No entry points at a placeholder anymore: the MECHANISM -#: (`lands_in`, the placeholder mode) stays for a future slice's entry, unused today. -CONSOLE_MENU: tuple[MenuEntry, ...] = ( - MenuEntry( - ordinal=1, - label="Dashboard", - description="the live view -- rails, session, positions, freshness, activity", - action="dashboard", - ), - MenuEntry( - ordinal=2, - label="Profile", - description="switch deployment (config+db pair); LIVE asks first", - action="profile", - ), - MenuEntry( - ordinal=3, - label="Trading", - description="agent cycle, monitor, autonomy, record-flow, reset-hwm, kill/resume", - action="trading", - ), - MenuEntry( - ordinal=4, - label="Rules", - description="the strategy console -- ledger, simulate, add, retry", - action="strategy", - ), - MenuEntry( - ordinal=5, - label="Compliance", - description="screen, propose, attest, exemptions, subscription, purification", - action="compliance", - ), - MenuEntry( - ordinal=6, - label="Data", - description="fetch, fetch --check, repair gaps, freshness, db import", - action="data", - ), - MenuEntry( - ordinal=7, - label="Research", - description="experiments, research docs, promotion reports, the trials ledger", - action="research", - ), - MenuEntry( - ordinal=8, - label="Account", - description="pnl, versions, update (the typed self-update)", - action="account", - ), - MenuEntry( - ordinal=9, - label="Help", - description="keys, the glossary, and the safety notes", - action="help", - ), -) - - -def menu_entry(ordinal: int) -> MenuEntry | None: - """The entry selected by its displayed ordinal (1-9), or `None` -- the shortcut keys - and the rendered ordinals resolve through this ONE lookup, so they cannot drift.""" - for entry in CONSOLE_MENU: - if entry.ordinal == ordinal: - return entry - return None - - -def build_menu_lines( - active: DeploymentProfile | None, - *, - cursor: int = 0, - profiles: list[DeploymentProfile] | None = None, -) -> list[ScreenLine]: - """The console menu screen: every PRD §3 entry on one list, placeholders carrying their - 'lands in Cx' notice inline, exactly one cursor-marked row. PURE.""" - known = KNOWN_PROFILES if profiles is None else tuple(profiles) - lines: list[ScreenLine] = [ScreenLine("keel console -- menu", "heading")] - if active is not None: - lines.append( - ScreenLine( - f"active: {active.label} ({active.config_path} + {active.db_path})", "muted" - ) - ) - else: - lines.append(ScreenLine("active: no known deployment (see the header)", "muted")) - lines.append(_blank()) - cursor = max(0, min(cursor, len(CONSOLE_MENU) - 1)) - for index, entry in enumerate(CONSOLE_MENU): - marker = ">" if index == cursor else " " - if entry.available: - text = f"{marker} {entry.ordinal} {entry.label:<12} {entry.description}" - style = "heading" if index == cursor else "normal" - else: - text = f"{marker} {entry.ordinal} {entry.label:<12} lands in {entry.lands_in}" - style = "heading" if index == cursor else "muted" - lines.append(ScreenLine(text, style)) - lines.append(_blank()) - lines.append( - ScreenLine( - "up/k down/j move · Enter/Space select · 1-9 jump · q/Esc/m back to the dashboard", - "muted", - ) - ) - # `known` is the discovered profile list; the count is surfaced so an operator who is - # missing a deployment sees why (its config file is not in this directory). - lines.append( - ScreenLine( - f"{len(known)} deployment profile(s) discovered -- the Profile entry switches them", - "muted", - ) - ) - return lines - - -def build_placeholder_lines(entry: MenuEntry) -> list[ScreenLine]: - """The screen a future slice's entry lands on: a notice naming the owning slice and - saying what the shell is -- navigation only, nothing invokable from it. PURE.""" - lines = [ - ScreenLine(f"keel console -- {entry.label}", "heading"), - _blank(), - ScreenLine(f"{entry.label} lands in slice {entry.lands_in}.", "normal"), - _blank(), - ScreenLine( - f"What it will hold: {entry.description}.", "normal" - ), - _blank(), - ScreenLine( - "The console shell is navigation only for now -- this entry renders, it does " - "not act. Nothing here invokes a service, places an order, or changes state.", - "muted", - ), - _blank(), - ScreenLine("Press q or Esc to return to the menu.", "muted"), - ] - return lines - - -def build_profile_menu_lines( - profiles: list[DeploymentProfile], - *, - cursor: int = 0, - binding_pair: tuple[str, str] | None = None, -) -> list[ScreenLine]: - """The Profile menu: every discovered deployment with its config+db pair visible (the - O4 rule -- the active pair is VISIBLE before any action), the active one marked, the - LIVE row styled as the guarded one -- plus the PRD §3 tree's Venues entry under the - deployments (O7), reached with the cursor like any row. PURE.""" - active = ( - active_profile(binding_pair[0], binding_pair[1], profiles) if binding_pair else None - ) - lines: list[ScreenLine] = [ - ScreenLine("keel console -- profile", "heading"), - ScreenLine( - "each deployment is a config+db PAIR -- switching rebinds both, everywhere", - "muted", - ), - _blank(), - ] - # The Venues entry rides BELOW the deployments and is part of the SAME cursor range: - # index len(profiles) is the Venues row, and `profile_menu_venues_at` is the one - # place that fact is stated, so the live loop's dispatch and this render cannot - # disagree about which row opens the browser. - venues_at = len(profiles) - cursor = max(0, min(cursor, venues_at)) - for index, profile in enumerate(profiles): - marker = ">" if index == cursor else " " - suffix = ( - " [active]" - if active is not None and active.key == profile.key - else "" - ) - text = f"{marker} {profile.label} · {profile.config_path} + {profile.db_path}{suffix}" - if profile.requires_confirmation: - # The guard note WRAPS to its own row under the guarded pair rather than riding - # the pair's line: the pair alone is already ~50 columns, and appending the note - # ran the row ~27 past the 80-column budget `_paint` clips at -- so the tail - # ("...asks for confirmation") was exactly the part that vanished. Same alert - # style on both rows, so the guard reads as one marked entry. - lines.append(ScreenLine(text, "alert")) - lines.append( - ScreenLine( - f" (guarded: selecting {profile.label} asks for confirmation)", - "alert", - ) - ) - else: - lines.append(ScreenLine(text, "heading" if index == cursor else "normal")) - lines.append(_blank()) - lines.append( - ScreenLine( - f"{'>' if cursor == venues_at else ' '} Venues -- every installed adapter and " - "its capabilities", - "heading" if cursor == venues_at else "normal", - ) - ) - lines.append( - ScreenLine(" (the O7 browser; the selected adapter is highlighted)", "muted") - ) - lines.append(_blank()) - lines.append( - ScreenLine( - "up/k down/j move · Enter/Space switch (Venues: open) · q/Esc/p/m to the menu", - "muted", - ) - ) - return lines - - -def profile_menu_venues_at(profiles: list[DeploymentProfile]) -> int: - """The cursor index of the Profile menu's Venues row -- the ONE statement of where - the browser entry sits, so the live loop's "is the cursor on Venues?" test and - the rendered list cannot drift.""" - return len(profiles) - - -# -- the Venues browser (O7) ---------------------------------------------------------------------- - - -def _wrap(text: str, *, indent: str = " ", width: int = 78) -> list[str]: - """Wrap to the 80-column budget `_paint` clips at -- the same rule every console - module keeps. PURE.""" - return textwrap.wrap(text, width=width, initial_indent=indent, subsequent_indent=indent) or [ - indent - ] - - -def build_venues_lines( - infos: list[brokers.BrokerInfo], - *, - selected_venue: str | None, - profile: DeploymentProfile | None = None, - binding_pair: tuple[str, str] | None = None, - endpoint: str | None = None, - data_feed: str | None = None, -) -> list[ScreenLine]: - """The Venues browser (O7): every installed adapter with its declared - capabilities, rendered from the SAME service payload `keel brokers list` prints - (`brokers.list_installed_brokers` -- the equality is pinned by test), the - SELECTED adapter (the active deployment's `broker: name:`) highlighted, and the - active deployment's binding named at the top. PURE, and THIN: this renders the - service's rows; every fact on it comes from the adapters' own declarations -- - capability display, never key-presence inference.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- profile / venues", "heading"), - ] - for wrapped in _wrap( - "every installed adapter and its declared capabilities (keel brokers list " - "renders the same payload)", - indent="", - ): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - # The active deployment's binding, VISIBLE before anything else (O4's rule, applied - # to the venue half of the pair): which deployment is bound, which venue its config - # selects, and -- where the venue declares the knobs -- which endpoint and data feed. - if profile is not None: - bound = f"{profile.label} ({profile.config_path} + {profile.db_path})" - elif binding_pair is not None: - bound = f"{binding_pair[0]} + {binding_pair[1]}" - else: - bound = "no known deployment" - lines.append(ScreenLine(f"bound deployment: {bound}", "normal")) - binding_bits = [f"venue {selected_venue or '?'}"] - if endpoint is not None: - binding_bits.append(f"endpoint {endpoint}") - if data_feed is not None: - binding_bits.append(f"data feed {data_feed}") - for wrapped in _wrap("binding: " + " · ".join(binding_bits)): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - for info in infos: - if info.error is not None: - # A raising adapter renders its honest error block (the service's own shared - # wording) -- never a fabricated capability row (#406 review). - for row in brokers.adapter_error_block(info): - lines.append(ScreenLine(row, "warn")) - continue - selected = selected_venue is not None and info.name == selected_venue - version = info.package_version or "unknown version" - head = f"{info.name} ({version}) · {info.venue}" - if selected: - head += " [selected]" - lines.append(ScreenLine(head, "heading" if selected else "normal")) - # The capability facts and order kinds render through the SERVICE's own shared - # wording (`brokers.capability_facts`) -- the one-phrase rule that pins the CLI - # and this browser to identical information. - for wrapped in _wrap(brokers.capability_facts(info)): - lines.append(ScreenLine(wrapped, "muted")) - for wrapped in _wrap(f"order kinds: {', '.join(info.supported_orders)}"): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - for wrapped in _wrap(brokers.NO_KEY_INFERENCE_LINE, indent=""): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append( - ScreenLine( - "up/k down/j scroll · PgUp/PgDn/Home/End · q/Esc/v/m back to the profile menu", - "muted", - ) - ) - return lines - - -# -- contextual help (O8, issue #394 C7) ---------------------------------------------------------- -# -#: This module's screens' contextual help -- the "what am I looking at" / "what will -#: this do" rows the `?` overlay renders -- keyed by the live loop's mode names. Plain -#: `(subject, description)` pairs so the text stays HERE, with the module that owns the -#: screen; `keel.commands.help_console` is the registry and renderer (it imports THIS -#: module lazily, so this dict imports nothing from it). -CONTEXT_HELP: dict[str, tuple[tuple[str, str], ...]] = { - "menu": ( - ( - "the console menu", - "the operator console's tree over the dashboard: every entry is an area " - "(Trading, Rules, Compliance, ...) or a destination (Dashboard, Profile, Help)", - ), - ( - "the cursor and the ordinals", - "up/k and down/j move the marker, Enter or Space selects, 1-9 jump straight " - "to the numbered entry", - ), - ( - "q / Esc / m", - "back to the dashboard -- m steps back one level out of any console screen", - ), - ), - "profile": ( - ( - "the deployments", - "every config+db PAIR the wrappers pin -- a deployment is the PAIR, and " - "switching rebinds both halves everywhere, in one action", - ), - ( - "LIVE (guarded)", - "selecting the live pair asks an explicit y/N at the terminal first; this " - "guard is a VIEW switch, not a typed gate -- it changes what the console " - "answers about, not what the engine does", - ), - ( - "Venues", - "the installed-adapter browser (O7): every venue's declared capabilities, " - "the selected one highlighted; Enter opens it", - ), - ), - "venues": ( - ( - "the rows", - "every installed adapter and its DECLARED capabilities -- the same one " - "service payload `keel brokers list` prints; both front-ends render it", - ), - ( - "[selected]", - "the active deployment's venue: the `broker:` name its config selects", - ), - ( - "bound deployment", - "the active config+db pair, and -- where the venue declares the knobs -- " - "which endpoint (paper/live) and data feed it is bound to", - ), - ( - "capability display only", - "wired-for-deployment says a shipped config selects this venue; it does " - "NOT say any key is present. No key presence is read or implied, and no " - "secret is ever shown", - ), - ), - "placeholder": ( - ( - "a future slice's entry", - "the console tree is stable: this entry renders a 'lands in Cx' notice " - "naming the slice that owns it -- navigation only, nothing is invokable", - ), - ), -} - - -# -- the session banner (O9) ----------------------------------------------------------------------- - - -def _short_dt(ts: int) -> str: - """Local-time `YYYY-MM-DD HH:MM` -- `_human_dt` minus the seconds, for the banner's - line-two stamps. The banner is two rows on a screen `_paint` clips at the window width - (80-column terminals are this dashboard's stated target), and line two can carry TWO - schedule stamps beside their labels: at second precision that row ran ~11 columns past - the budget and the SECOND timestamp -- the part an operator scrolled for -- was exactly - what clipped. Seconds are not load-bearing for a next open/close; the date and minute - are. PURE, and total on any int (same `time.localtime` contract as `_human_dt`).""" - return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) - - -def build_banner_lines( - profile: DeploymentProfile | None, - session_bound: bool, - recorded: agent.RecordedSession | None, - now_ts: int, - *, - binding_pair: tuple[str, str] | None = None, -) -> list[ScreenLine]: - """The two-line header every console screen carries: the active deployment, then the - market session + clock. PURE -- a function of the binding, the venue's declared - session-boundness and the RECORDED session, nothing else. - - Line one is the profile: a known paper deployment in the dashboard's heading style, - LIVE in its alert style (unmistakable -- the same weight a live autonomy line gets), - and an unrecognized pair named honestly (the raw paths, and a warn) rather than - guessed at. - - Line two is the session, in B1's own severity vocabulary (`_market_session_style`: - open ok, closed muted -- an expected weekend, never a warning -- unreadable warn): - - * a 24/7 venue says so explicitly, muted; - * a session-bound venue renders its recorded state, with the recorded NEXT OPEN/CLOSE - beside it when the recording carries them; - * absent or stale (outside the recorded interval's trust window) renders CLOCK - UNAVAILABLE, fail-loud -- exactly how `fetch --check` treats a record that no longer - vouches for anything. No TUI-side calendar ever fills a gap. - - Every line fits the 80-column budget `_paint` clips at, worst case first: the two - fail-loud CLOCK UNAVAILABLE variants lead with the headline (the reason and, for a - stale record, the when-recorded stamp follow it), and the schedule stamps render - through `_short_dt` (local time, minute precision) so an OPEN row can carry BOTH the - recorded next close and next open without either clipping -- the same local-time - contract `_human_dt` keeps for the dashboard's absolute points in time. - """ - # Line one: the active deployment. File NAMES only -- a config path and a db path carry - # no secrets; the config's CONTENTS are never rendered here. - if profile is None: - raw = f"{binding_pair[0]} + {binding_pair[1]}" if binding_pair else "the raw pair" - first = ScreenLine(f"console: {raw} (unrecognized deployment)", "warn") - elif profile.requires_confirmation: - first = ScreenLine( - f"console: {profile.label} (REAL MONEY) · {profile.config_path} + " - f"{profile.db_path}", - "alert", - ) - else: - first = ScreenLine( - f"console: {profile.label} · {profile.config_path} + {profile.db_path}", - "heading", - ) - - # Line two: the recorded session + clock. - if not session_bound: - second = ScreenLine("market: 24/7 (always open)", "muted") - return [first, second] - - if recorded is None: - second = ScreenLine( - "market: CLOCK UNAVAILABLE -- no recorded clock yet (the agent cycle records it)", - "warn", - ) - return [first, second] - if not recorded.fresh: - recorded_at = ( - _short_dt(recorded.recorded_ts) if recorded.recorded_ts is not None else "unknown" - ) - second = ScreenLine( - f"market: CLOCK UNAVAILABLE -- the record is stale (recorded {recorded_at})", - "warn", - ) - return [first, second] - - times: list[str] = [] - if recorded.state == "open": - if recorded.next_close_ts is not None: - times.append(f"closes {_short_dt(recorded.next_close_ts)}") - if recorded.next_open_ts is not None: - times.append(f"opens {_short_dt(recorded.next_open_ts)}") - text = "market: OPEN (venue clock)" - elif recorded.state == "closed": - if recorded.next_open_ts is not None: - times.append(f"opens {_short_dt(recorded.next_open_ts)}") - text = "market: CLOSED (venue clock) -- cycles skip" - else: - text = "market: CLOCK UNAVAILABLE (fail-closed) -- cycles skip until the clock answers" - if times: - text = f"{text} · {' · '.join(times)}" - second = ScreenLine(text, _market_session_style(recorded.state)) - return [first, second] - - -def venue_session_bound(config: Config) -> bool: - """Whether the active profile's venue is session-bound, read from the ADAPTER's own - capabilities declaration -- broker-free and offline: the registry loads the adapter - CLASS and constructs it without a transport (every first-party adapter's - `capabilities()` is a constant), so this is a capability DISPLAY, not a broker handle. - Until the C7 `brokers` service lands, this one-boolean read is the whole surface the - banner needs from that future service. - - The resolution walks the adapter registry (`load_broker` -> `discover_brokers()`, an - `importlib.metadata` scan), so callers re-rendering per poll go through - `ConsoleBinding.session_bound` -- the once-per-binding cache -- rather than calling - this directly on every banner build. - - A venue that cannot be resolved answers `True` (session-bound) -- the conservative - direction: an unknown venue renders CLOCK UNAVAILABLE until it records, never an - assumed 24/7 that would quietly hide a closed market.""" - try: - from keel_broker_api.registry import load_broker - - adapter_cls = load_broker(config.broker.name) - return bool(adapter_cls().capabilities().session_bound) - except Exception: - return True - - -def console_banner_lines( - binding: ConsoleBinding, repo: Repository, config: Config, now_ts: int -) -> list[ScreenLine]: - """The banner as one read: the binding's deployment + the recorded session + the - venue's session-boundness (cached per binding -- see `ConsoleBinding`), over the - repo/config the caller already holds. This is the TUI loop's single entry point; the - composition (and every rendering decision) lives in `build_banner_lines`.""" - profile = active_profile(binding.config_path, binding.db_path) - recorded = agent.latest_recorded_session(repo, config, now_ts) - return build_banner_lines( - profile, - binding.session_bound(config), - recorded, - now_ts, - binding_pair=binding.pair, - ) diff --git a/keel/commands/data_console.py b/keel/commands/data_console.py deleted file mode 100644 index be0104d1..00000000 --- a/keel/commands/data_console.py +++ /dev/null @@ -1,512 +0,0 @@ -"""The Data menu (issue #391 C5; PRD §3's Data branch). - -Everything here is DISPATCH, never behavior (PRD O2): `fetch`, `fetch --check` and -`repair gaps` all run `keel.commands.fetch.run_fetch` -- the SAME flow `keel fetch` runs, -with the CLI's own defaults (the allowlist's products, the config's granularities, 5y, -the default tolerance) and a LAZY broker factory, so `--check` and the all-current skip -still never construct one. The freshness overview is the same service's OFFLINE -read-only sweep (a `check=True` run never opens a network connection), rebuilt per poll; -`db import` is the CLI's own import service (`keel.data.csv_import.import_dir`) behind a -path form, with the CLI's own DIR_PATH validation surfaced verbatim. - -The ARMED story is the simulate/discover pattern: opening a fetch screen shows the PLAN -(the products x granularities x window the ACTIVE profile's config resolves to, and the -db it warms) and makes NO call; Enter is the confirm step, the run blocks the loop -exactly like the CLI (the screen says so), and the progress lines the CLI would have -streamed are collected and HELD -- rendered as the results, above any error, so a failed -run never hides how far it got. - -All the pure builders here are directly unit-testable without curses, mirroring the -`build_*`/`run_*` split of the other console modules. -""" - -from __future__ import annotations - -import textwrap -from collections.abc import Callable -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any - -from keel.commands.fetch import FetchResult, run_fetch -from keel.commands.tui import CTRL_C_DISCLOSURE, ScreenLine, _blank, _message_style -from keel.types import Granularity - -if TYPE_CHECKING: - from keel.config import Config - from keel.data.repository import Repository - -#: One terminal prompt: injected so the import form is unit-testable with a scripted -#: fake, and so the live loop can run it through the curses suspend/restore dance. -PromptFn = Callable[[str], str] - -#: The width every console line must fit (`_paint` clips at the window width) -- the -#: same budget every console module keeps, applied by wrapping rather than clipping. -_WIDTH = 78 - - -def _wrap(text: str, *, indent: str = " ", width: int = _WIDTH) -> list[str]: - """Wrap `text` on spaces to the 80-column budget, continuation lines carrying - `indent`. PURE -- the same rule every console module keeps.""" - return textwrap.wrap(text, width=width, initial_indent=indent, subsequent_indent=indent) or [ - indent - ] - - -def _verbatim(line: str, *, width: int = _WIDTH) -> list[str]: - """Render one of the service's OWN lines: VERBATIM (alignment spaces included -- a - freshness row's columns are part of what the CLI prints) when it fits the budget, - wrapped when it does not -- never clipped, never re-indented while it fits.""" - if len(line) <= width: - return [line] - return textwrap.wrap(line, width=width) or [""] - - -# -- the sub-menu model (PRD §3's Data branch) ----------------------------------------------------- - - -@dataclass(frozen=True) -class DataEntry: - """One entry of the Data sub-menu. `kind` is the closed dispatch vocabulary: - `"armed"` opens an ARMED view (Enter is the confirm step -- fetch, fetch --check, - repair gaps), `"view"` renders a read-only report (the freshness overview, rebuilt - per poll), and `"form"` runs a service at the terminal (db import's path form).""" - - ordinal: int - label: str - description: str - kind: str # "armed" | "view" | "form" - target: str - - -#: PRD §3's Data branch in tree order. The descriptions are O8's plain-English "what -#: will this do" in miniature, naming the dispatch honestly. -DATA_MENU: tuple[DataEntry, ...] = ( - DataEntry( - ordinal=1, - label="fetch", - description=( - "warm the candle cache for every allowlisted product (Enter confirms first: " - "it shows the plan, then fetches -- money-safe, data only)" - ), - kind="armed", - target="fetch", - ), - DataEntry( - ordinal=2, - label="fetch --check", - description=( - "the scheduler's dry-run: report freshness and exit -- NEVER touches the " - "network; the exit verdict renders (Enter runs it)" - ), - kind="armed", - target="fetch-check", - ), - DataEntry( - ordinal=3, - label="repair gaps", - description=( - "re-request interior holes window by window (Enter confirms first: the " - "venue is re-asked for each gap; per-series outcomes render)" - ), - kind="armed", - target="repair-gaps", - ), - DataEntry( - ordinal=4, - label="freshness overview", - description=( - "the current assessment of every series -- read-only, offline, from the " - "same sweep `fetch --check` runs" - ), - kind="view", - target="freshness", - ), - DataEntry( - ordinal=5, - label="db import", - description=( - "import Coinbase transaction-history CSV exports into this deployment's db " - "(read-only w.r.t. the exchange)" - ), - kind="form", - target="db-import", - ), -) - - -def data_entry(ordinal: int) -> DataEntry | None: - """The entry selected by its displayed ordinal, or `None` -- the one-lookup rule - every console menu keeps, so the rendered ordinals and the shortcut keys cannot - drift.""" - for entry in DATA_MENU: - if entry.ordinal == ordinal: - return entry - return None - - -#: This module's screens' contextual help (O8, issue #394 C7) -- the rows the `?` -#: overlay renders, keyed by the live loop's mode names. Plain `(subject, description)` -#: pairs so the text stays HERE with the module that owns the screens; -#: `keel.commands.help_console` is the registry and renderer. -CONTEXT_HELP: dict[str, tuple[tuple[str, str], ...]] = { - "data": ( - ( - "fetch and its variants", - "warm the candle cache from the ACTIVE profile's config -- the fetch and " - "repair-gaps variants open ARMED with the plan (products x granularities x " - "window) shown first, and Enter runs the SAME flow `keel fetch` runs, " - "blocking the loop until it ends", - ), - ( - "fetch --check", - "the scheduler's dry-run: a freshness verdict with NO network connection -- " - "the same sweep `keel fetch --check` runs, with its exit verdict rendered", - ), - ( - "freshness overview", - "every series' current freshness, read-only and offline, rebuilt per poll", - ), - ( - "db import", - "import Coinbase transaction-history CSV exports into this deployment's db " - "through the CLI's own importer -- read-only with respect to the exchange", - ), - ), - "data-fetch": ( - ( - "the ARMED view", - "the fetch plan (or the check's verdict, or the repair's per-series ask) " - "renders BEFORE anything runs; Enter dispatches to `run_fetch` itself, the " - "progress lines the CLI would stream are held and rendered, and Ctrl-C " - "exits the whole console (disclosed on the screen)", - ), - ), - "data-freshness": ( - ( - "the rows", - "one per product and granularity with its age and staleness verdict, from " - "the same offline assessment the CLI prints -- a broker is never " - "constructed for this view", - ), - ), -} - - -def build_data_menu_lines(*, cursor: int = 0, message: str | None = None) -> list[ScreenLine]: - """The Data sub-menu screen: every entry with its description wrapped to the - 80-column budget, exactly one cursor-marked row, and the last action's confirmation - lines as the toast. PURE.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- data", "heading"), - _blank(), - ] - cursor = max(0, min(cursor, len(DATA_MENU) - 1)) - for index, entry in enumerate(DATA_MENU): - marker = ">" if index == cursor else " " - head = f"{marker} {entry.ordinal:>2} {entry.label}" - lines.append(ScreenLine(head, "heading" if index == cursor else "normal")) - for wrapped in _wrap(f"{entry.description}.", indent=" "): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append(ScreenLine("up/k down/j move · Enter/Space select · 1-5 jump", "muted")) - lines.append(ScreenLine("q/Esc/m to the console menu", "muted")) - if message is not None: - lines.append(_blank()) - for part in message.splitlines(): - lines.append(ScreenLine(part, _message_style(part))) - return lines - - -# -- fetch / fetch --check / repair gaps: the ARMED plan, the run, the held results ---------------- - - -#: `keel fetch --years`'s own default -- the window the console's runs keep. -FETCH_YEARS = 5 - -#: The three run variants, by target: what each one says it does and how it dispatches. -_FETCH_TARGETS = ("fetch", "fetch-check", "repair-gaps") - - -@dataclass(frozen=True) -class FetchPlan: - """What a fetch run WILL do, shown BEFORE any of it runs (the confirm step): the - products x granularities x window the ACTIVE profile's config resolves to, the db it - warms, and which variant (`check` never touches the network; `repair_gaps` - re-requests interior windows).""" - - db_path: str - products: tuple[str, ...] - granularities: tuple[Granularity, ...] - years: int - check: bool = False - repair_gaps: bool = False - - @property - def target(self) -> str: - if self.check: - return "fetch-check" - if self.repair_gaps: - return "repair-gaps" - return "fetch" - - -def fetch_plan(config: Config, db_path: str, target: str) -> FetchPlan: - """The plan for a console fetch run -- `keel fetch`'s own defaults (the allowlist's - products in the settlement currency via the same `_default_sim_products` derivation - every fetch/simulate/monitor surface uses, the config's granularities, `--years 5`) - and the variant's flags. PURE aside from reading the config it is handed.""" - from keel.commands._products import _default_sim_products - - if target not in _FETCH_TARGETS: - raise ValueError(f"unknown fetch target: {target!r}") - return FetchPlan( - db_path=db_path, - products=tuple(_default_sim_products(config)), - granularities=tuple(config.market_data.granularities), - years=FETCH_YEARS, - check=target == "fetch-check", - repair_gaps=target == "repair-gaps", - ) - - -def build_fetch_armed_lines(plan: FetchPlan) -> list[ScreenLine]: - """A fetch view's ARMED state: NOTHING has run, and the screen says exactly what - Enter will do -- the plan (products, granularities, window, db) first, then the - variant's own story (`--check` never touches the network; repair re-requests - windows). PURE.""" - lines: list[ScreenLine] = [ - ScreenLine(f"keel console -- data / {plan.target}", "heading"), - _blank(), - ScreenLine("ARMED -- nothing has run yet.", "normal"), - _blank(), - ScreenLine( - f"Enter runs `keel fetch`'s flow on THIS deployment ({plan.db_path}):", "normal" - ), - ] - products = ", ".join(plan.products) - for wrapped in _wrap(f"products {products}", indent=" "): - lines.append(ScreenLine(wrapped, "normal")) - granularities = ", ".join(g.value for g in plan.granularities) - for wrapped in _wrap( - f"granularities {granularities} · {plan.years}y window (the CLI's own defaults)", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "normal")) - if plan.check: - for wrapped in _wrap( - "this is the --check dry-run: it reports freshness and NEVER opens a network " - "connection -- the plan above is judged, not fetched.", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "ok")) - elif plan.repair_gaps: - for wrapped in _wrap( - "repair re-requests interior gap windows from the venue (money-safe: data " - "only). A window the venue cannot supply is recorded absent at source; each " - "series' outcome renders when the run ends.", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "normal")) - else: - for wrapped in _wrap( - "the warm fetch pulls what is missing up to the window (money-safe: data " - "only, no orders, no rails) and skips the network entirely when every " - "series is already current.", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "normal")) - for wrapped in _wrap( - "the run can take seconds to minutes; the screen freezes while it runs, exactly " - "like the CLI, and the exact lines it would have printed are held here when it " - "ends. Enter again re-runs.", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "muted")) - for wrapped in _wrap(CTRL_C_DISCLOSURE, indent=" "): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append(ScreenLine("Press q/Esc/m to return to the Data menu.", "muted")) - return lines - - -def run_console_fetch( - repo: Repository, - config: Config, - plan: FetchPlan, - *, - now_ts: int, - build_client: Callable[[], Any], - run_fn: Callable[..., FetchResult] = run_fetch, - progress: list[str] | None = None, -) -> FetchResult: - """THE fetch run, dispatched: `run_fetch` itself over the active profile's repo/ - config with the CLI's own defaults and flags, its progress lines collected into - `progress` (the CLI streamed them; the console shows them as the results). The - broker factory stays LAZY -- `--check` and the all-current skip never construct one, - exactly as the CLI's wrapper keeps them. `run_fn` is injectable so the loop's - confirm-gate tests can spy the call without fetching anything.""" - sink = progress.append if progress is not None else (lambda _message: None) - return run_fn( - repo, - config, - build_client, - db_path=plan.db_path, - products=list(plan.products), - years=plan.years, - now_ts=now_ts, - tolerance_bars=_default_tolerance_bars(), - check=plan.check, - refresh=False, - repair_gaps=plan.repair_gaps, - echo=sink, - echo_err=sink, - ) - - -def _default_tolerance_bars() -> int: - """`--tolerance-bars`'s own default, from the freshness module that owns it -- never - a second constant here.""" - from keel.data import freshness as freshness_mod - - return int(freshness_mod.DEFAULT_TOLERANCE_BARS) - - -def build_fetch_result_lines( - target: str, - progress: tuple[str, ...], - *, - error: str | None, - verdict: str | None, -) -> list[ScreenLine]: - """The held fetch results: the progress lines the CLI would have streamed, VERBATIM - (wrapped, never clipped -- a freshness row's tail is the detail), the run's failure - below them when there was one (so the lines that say how far it got stay above), and - the --check verdict as the service's own message. PURE.""" - lines: list[ScreenLine] = [ - ScreenLine(f"keel console -- data / {target} results", "heading"), - _blank(), - ] - if progress: - for line in progress: - if not line.strip(): - lines.append(_blank()) - continue - for rendered in _verbatim(line): - lines.append(ScreenLine(rendered, "muted")) - lines.append(_blank()) - if error is not None: - lines.append(ScreenLine(f"fetch failed: {error}", "alert")) - lines.append(_blank()) - elif verdict is not None: - # `--check`'s verdict, in the service's own words ("N series missing or stale") - # -- loud, because a scheduler would exit non-zero on it. - lines.append(ScreenLine(f"--check verdict: FAIL -- {verdict}", "alert")) - lines.append(_blank()) - for wrapped in _wrap(CTRL_C_DISCLOSURE, indent=""): - lines.append(ScreenLine(wrapped, "muted")) - if error is not None: - lines.append(ScreenLine("Press Enter to retry, or q/Esc to close.", "muted")) - else: - lines.append( - ScreenLine("Enter re-runs · q/Esc/m back to the Data menu", "muted") - ) - return lines - - -def check_verdict_footer(verdict: str | None) -> list[ScreenLine]: - """The --check run's PINNED verdict footer: reserved off the window before the body - is sliced (`compliance_console.pinned_frame`), so no scroll offset can hide what the - scheduler's dry-run concluded. A PASSING run pins nothing -- its verdict already - rides the service's own summary lines. PURE.""" - if verdict is None: - return [] - return [ScreenLine(f"--check verdict: FAIL -- {verdict}", "alert")] - - -# -- the freshness overview: offline, the current assessment --------------------------------------- - - -def freshness_lines( - repo: Repository, - config: Config, - db_path: str, - now_ts: int, - *, - build_client: Callable[[], Any] | None = None, - run_fn: Callable[..., FetchResult] = run_fetch, -) -> tuple[str, ...]: - """The CURRENT assessment, offline: `run_fetch(check=True)`'s own sweep over the - repo -- the exact lines `keel fetch --check` prints, collected rather than echoed. - The check branch never constructs a broker (the factory is lazy inside the service), - and the factory handed in here exists precisely so a test can prove that by raising. - `run_fn` is injectable so the loop's offline proofs can spy without sweeping.""" - collected: list[str] = [] - run_fn( - repo, - config, - build_client or (lambda: (_ for _ in ()).throw(AssertionError("unreachable"))), - db_path=db_path, - products=_default_products(config), - years=FETCH_YEARS, - now_ts=now_ts, - tolerance_bars=_default_tolerance_bars(), - check=True, - echo=collected.append, - echo_err=collected.append, - ) - return tuple(collected) - - -def _default_products(config: Config) -> list[str]: - from keel.commands._products import _default_sim_products - - return _default_sim_products(config) - - -def build_freshness_lines(rows: tuple[str, ...]) -> list[ScreenLine]: - """The freshness overview screen: the sweep's own lines, VERBATIM (the state label, - the counts, the detail -- wrapped, never clipped), under a title that names it. A - fresh read is re-taken each poll by the loop; this builder renders whatever it is - handed. PURE.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- data / freshness overview", "heading"), - _blank(), - ScreenLine("the current assessment, offline -- the same sweep `fetch --check` runs:", - "muted"), - _blank(), - ] - if rows: - for row in rows: - for rendered in _verbatim(row): - lines.append(ScreenLine(rendered, "normal")) - else: - lines.append(ScreenLine("(no series -- an empty allowlist?)", "muted")) - lines.append(_blank()) - lines.append(ScreenLine("q/Esc/m back to the Data menu", "muted")) - return lines - - -# -- db import: the path form ---------------------------------------------------------------------- - - -def run_db_import_form(repo: Repository, prompt_fn: PromptFn) -> str: - """`keel db import` as a form: ask the directory, validate it with the CLI's OWN - DIR_PATH check (`db.validated_import_dir` -- a bad path refuses with the CLI's exact - message, verbatim), then run the CLI's own import service and render its own output - lines. An empty path cancels; a refused path never reaches the import service.""" - import click - - from keel.commands.db import render_import_result, validated_import_dir - from keel.data.csv_import import import_dir - - raw = prompt_fn( - "directory holding the Coinbase *.csv exports (read-only w.r.t. the exchange) -- " - "empty cancels" - ).strip() - if not raw: - return "db import cancelled -- nothing imported" - try: - directory = validated_import_dir(raw) - except click.BadParameter as exc: - return f"Error: {exc.format_message()}" - return "\n".join(render_import_result(import_dir(directory, repo))) diff --git a/keel/commands/help_console.py b/keel/commands/help_console.py deleted file mode 100644 index c30c3d08..00000000 --- a/keel/commands/help_console.py +++ /dev/null @@ -1,563 +0,0 @@ -"""The Help menu -- the O8 help & glossary system (issue #394 C7; PRD O8). - -The PRD §3 Help branch made real: the glossary, the per-screen/per-action catalog, the -rule-parameter help, and the keys/safety notes -- plus the `?` contextual overlay that -renders the CURRENT screen's contribution wherever it is pressed. Three single-source -rules hold the whole system together: - -* **The glossary is ONE hand-written file** -- `docs/glossary.md`. This module renders it - (bounded read at the research corpus's own byte bound, cached per mtime exactly like - the research doc view), the docs link to it, and no other surface defines console - terms. The fiqh terms' definitions are ANCHORED to `docs/fiqh-basis.md` (verbatim - passages with their section citations; where that document is silent the entry says so, - the way C3's shariah screen handles gharar) -- pinned by test, and pinned EQUAL to the - shariah screen's own vocabulary so the two surfaces cannot drift. An installed - deployment has no docs/ checkout: an absent glossary renders a calm empty state that - names the path, never a traceback. -* **Rule parameters are never duplicated.** `build_params_help_lines` renders - `keel.commands.rules.describe_params` -- the rule classes' own docstrings, defaults, - types and choices, by introspection. The glossary's `granularity` entry points here - too; no table in this module restates a parameter. -* **Contextual help text lives with the module that owns the screen.** Each console - module (and the TUI itself, for the pre-console overlays) declares a `CONTEXT_HELP` - mapping of its mode names to plain `(subject, description)` pairs -- one line per - entry plus a short plain-English description. THIS module is only the registry (a - closed mode -> owner mapping, `CONSOLE_MODES`/`contextual_help`) and the renderer; - pressing `?` in a mode renders that mode's contribution, and the Help menu's "screens - & actions" entry consolidates every mode's rows into one auditable catalog -- the C7 - consolidation the PRD promised (the per-screen strings landed with C2-C5 where they - existed; this slice makes them systematic). -""" - -from __future__ import annotations - -import re -import textwrap -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from keel.commands.tui import ScreenLine, _blank - -#: The glossary file: THE home for console term definitions (see the module docstring). -#: Relative, like the CLI's `--config`/`--db` defaults -- resolved against the working -#: directory of the running console, which for a checkout deployment is the repo root. -GLOSSARY_PATH = Path("docs/glossary.md") - -#: The most bytes of the glossary this module will ever read -- the research corpus's own -#: bound (`research_console.MAX_DOC_BYTES`), for the same reason: a screen that repaints -#: per poll must never have its cost grow with whatever a runaway writer put in the file. -MAX_GLOSSARY_BYTES = 1024 * 1024 - -_WIDTH = 78 - - -def _wrap(text: str, *, indent: str = " ", width: int = _WIDTH) -> list[str]: - """Wrap to the 80-column budget `_paint` clips at -- the rule every console screen - keeps. PURE.""" - return textwrap.wrap(text, width=width, initial_indent=indent, subsequent_indent=indent) or [ - indent - ] - - -# -- the glossary ------------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class GlossaryTerm: - """One glossary entry, as parsed from `docs/glossary.md`. `fiqh=True` means the - entry anchors to `docs/fiqh-basis.md`: `citation` is one of that document's EXACT - section headings and `definition` is a verbatim passage of it -- except where - `stated=False`, where fiqh-basis does not state the term and the definition SAYS so - (never a help-authored fiqh summary). Non-fiqh entries carry keel's own vocabulary - with a `source` naming where the concept lives in the code/docs.""" - - term: str - definition: str - source: str - citation: str | None = None - fiqh: bool = False - stated: bool = True - - -_SOURCE_LINE = "Source:" - - -def parse_glossary(text: str) -> list[GlossaryTerm]: - """Parse the glossary file's shape: `## term` headings, each followed by the - definition's lines and a final `Source:` line. PURE -- the parser is the file - format's only description; the fiqh anchoring derives from the source line itself - (a `docs/fiqh-basis.md` source anchors; a quoted section heading is the citation; - "not stated" marks the gharar case).""" - terms: list[GlossaryTerm] = [] - current: dict[str, Any] | None = None - for raw_line in text.splitlines(): - if raw_line.startswith("## ") and not raw_line.startswith("### "): - if current is not None: - terms.append(_finish_term(current)) - current = {"term": raw_line[3:].strip(), "definition_lines": [], "source": ""} - continue - if current is None: - continue # the file's preamble (title, the honesty rules) -- not a term - stripped = raw_line.strip() - if not stripped: - continue - if stripped.startswith(_SOURCE_LINE): - current["source"] = stripped[len(_SOURCE_LINE) :].strip() - continue - current["definition_lines"].append(stripped) - if current is not None: - terms.append(_finish_term(current)) - return terms - - -def _finish_term(current: dict[str, Any]) -> GlossaryTerm: - """Assemble one parsed entry: the definition is its lines joined with spaces (the - file hard-wraps, the term's definition is one passage), the fiqh anchoring derived - from the source line (see `parse_glossary`).""" - source: str = current["source"] - definition = " ".join(current["definition_lines"]) - # The ANCHOR form is `Source: docs/fiqh-basis.md -- "..."` -- the document LEADS the - # line. A keel-vocabulary entry may POINT AT fiqh-basis (the rail entry names the - # rails table) without leading with it: mentioning the doc is not quoting it, and - # only a quote is an anchor. - fiqh = source.startswith("docs/fiqh-basis.md") - quoted = re.search(r'"([^"]+)"', source) - citation = quoted.group(1) if quoted is not None else None - stated = "not stated" not in source.lower() - return GlossaryTerm( - term=current["term"], - definition=definition, - source=source, - citation=citation, - fiqh=fiqh, - stated=stated, - ) - - -def load_glossary(path: Path | None = None) -> list[GlossaryTerm]: - """The glossary's terms, read BOUNDED through the research corpus's own reader - (`read_document_lines`: at most `MAX_GLOSSARY_BYTES`, UTF-8 errors replaced, a calm - one-line notice for an unreadable file -- an installed deployment has no docs/ - checkout, and the help screen renders that notice as its empty state).""" - from keel.commands.research_console import read_document_lines - - target = GLOSSARY_PATH if path is None else path - return parse_glossary("\n".join(read_document_lines(target))) - - -def cached_glossary( - path: Path | None, cache: dict[tuple[str, int], list[GlossaryTerm]] -) -> list[GlossaryTerm]: - """`load_glossary` cached per (path, mtime_ns) -- the research doc view's contract, - applied to the glossary: repaints do not re-read an unchanged file, a changed mtime - refreshes, and the cache is the caller's single-entry dict.""" - from keel.commands.research_console import read_document_lines - - target = GLOSSARY_PATH if path is None else path - try: - mtime_ns = target.stat().st_mtime_ns - except OSError: - mtime_ns = -1 # unstatable: a key that can never re-hit, so it never caches - key = (str(target), mtime_ns) - if key in cache: - return cache[key] - terms = parse_glossary("\n".join(read_document_lines(target))) - cache.clear() # single entry: only the open glossary is worth holding - cache[key] = terms - return terms - - -def build_glossary_lines( - terms: list[GlossaryTerm], *, path: Path | None = None -) -> list[ScreenLine]: - """The glossary view: every term with its definition wrapped to the 80-column budget - and its source named -- fiqh entries cite their `docs/fiqh-basis.md` section, and an - entry the document does not state says so in its own row. PURE; an empty `terms` - (absent file) renders the calm empty state that names the path.""" - shown_path = GLOSSARY_PATH if path is None else path - lines: list[ScreenLine] = [ - ScreenLine("keel console -- help / glossary", "heading"), - ] - for wrapped in _wrap(f"the single source: {shown_path}", indent=""): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - if not terms: - for wrapped in _wrap( - f"no glossary at {shown_path} -- a deployment installed from a wheel has no " - "docs/ checkout; the repo's docs/glossary.md is where the terms are defined.", - indent="", - ): - lines.append(ScreenLine(wrapped, "normal")) - lines.append(_blank()) - lines.append( - ScreenLine( - "up/k down/j scroll · PgUp/PgDn/Home/End · q/Esc/m back to the Help menu", - "muted", - ) - ) - return lines - for term in terms: - lines.append(ScreenLine(term.term, "heading")) - for wrapped in _wrap(term.definition): - lines.append(ScreenLine(wrapped, "normal")) - anchor = f"-- {term.citation}" if term.citation is not None else "" - for wrapped in _wrap(f"Source: {term.source} {anchor}".rstrip()): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append( - ScreenLine( - "up/k down/j scroll · PgUp/PgDn/Home/End · q/Esc/m back to the Help menu", - "muted", - ) - ) - return lines - - -# -- rule-parameter help: describe_params is the source ----------------------------------------- - - -def build_params_kinds_lines(*, cursor: int = 0) -> list[ScreenLine]: - """The parameter-help entry point: every rule kind in `RULE_REGISTRY`, one - cursor-marked row -- selecting one renders `describe_params` for it. PURE.""" - from keel import agent - - kinds = sorted(agent.RULE_REGISTRY) - lines: list[ScreenLine] = [ - ScreenLine("keel console -- help / rule parameters", "heading"), - ] - for wrapped in _wrap( - "every kind's parameters render from the rule classes themselves " - "(rules.describe_params, by introspection) -- never a duplicated table", - indent="", - ): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - cursor = max(0, min(cursor, max(0, len(kinds) - 1))) - for index, kind in enumerate(kinds): - marker = ">" if index == cursor else " " - lines.append( - ScreenLine(f"{marker} {kind}", "heading" if index == cursor else "normal") - ) - lines.append(_blank()) - lines.append( - ScreenLine( - "up/k down/j move · Enter opens · q/Esc/m back to the Help menu", "muted" - ) - ) - return lines - - -def build_params_help_lines(kind: str) -> list[ScreenLine]: - """One rule kind's parameter help, DELEGATED whole to `rules.describe_params` -- the - O8 single source: the doc strings, defaults, types, choices and quoting rules are - the rule classes' own, rendered here; this module owns no parameter text. An unknown - kind is `describe_params`' own refusal, rendered calmly. PURE aside from that one - call.""" - from keel.commands.rules import describe_params - from keel.commands.strategy_console import _default_display - - lines: list[ScreenLine] = [ - ScreenLine(f"keel console -- help / rule parameters: {kind}", "heading"), - ] - for wrapped in _wrap( - "rendered from rules.describe_params -- the class's own docstrings, defaults " - "and types, introspected. The help never restates them.", - indent="", - ): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - try: - params = describe_params(kind) - except ValueError as exc: - for wrapped in _wrap(str(exc), indent=""): - lines.append(ScreenLine(wrapped, "warn")) - lines.append(_blank()) - lines.append( - ScreenLine("q/Esc/m back to the kinds list", "muted") - ) - return lines - for name, help_ in params.items(): - head = f" {name} ({help_.type_name}" - if help_.choices is not None: - head += f", choices {'/'.join(help_.choices)}" - head += ")" - lines.append(ScreenLine(head, "normal")) - for wrapped in _wrap( - f"{help_.doc} [default: {_default_display(help_)}]", indent=" " - ): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append( - ScreenLine( - "up/k down/j scroll · PgUp/PgDn/Home/End · q/Esc/m back to the kinds list", - "muted", - ) - ) - return lines - - -# -- the contextual help registry (O8) --------------------------------------------------------- - - -@dataclass(frozen=True) -class HelpEntry: - """One contextual-help row: the thing on the screen (`subject` -- an entry, a key, a - state) and its one-line plain-English description. The TEXT lives in the owning - module's `CONTEXT_HELP` (plain `(subject, description)` pairs, so no console module - needs to import this one); this dataclass is the registry's rendered shape.""" - - subject: str - description: str - - -#: Every console mode, in owner order -- the closed registry. A mode here without a -#: contribution, or a `run_live` mode not here, is a test failure (the registry and the -#: live loop are pinned to each other), which is what makes `?` total. -CONSOLE_MODES: tuple[str, ...] = ( - # the shell (keel.commands.console) - "menu", - "profile", - "venues", - "placeholder", - # the compliance menu (keel.commands.compliance_console) - "compliance", - "compliance-view", - "scout-list", - "scout-view", - # the strategy console (keel.commands.strategy_console) - "strategy", - "strategy-ledger", - "strategy-rule", - "strategy-simulate", - # the research readers (keel.commands.research_console) - "research", - "research-list", - "research-doc", - "research-trials", - # the trading menu (keel.commands.trading_console) - "trading", - "trading-cycle", - "trading-monitor", - # the data menu (keel.commands.data_console) - "data", - "data-fetch", - "data-freshness", - # the account menu (keel.commands.account_console) - "account", - "account-pnl", - "account-versions", - "account-update", - # the dashboard and its overlays, the help surfaces (keel.commands.tui) - "normal", - "help", - "help-menu", - "help-glossary", - "help-params", - "help-params-kind", - "help-screens", - "context-help", - "insights", - "screen", - "propose", - "discover", - "activity", -) - -#: mode -> the `keel.commands` module NAME that owns the screen (and therefore its -#: `CONTEXT_HELP` rows). Lazy import at read time -- the console modules import this -#: module's import targets' shared base (`tui`) at load, so the registry resolves -#: ownership only when asked (the established cycle-dodge, see `tui.run_live`). -_MODE_OWNERS: dict[str, str] = { - "menu": "console", - "profile": "console", - "venues": "console", - "placeholder": "console", - "compliance": "compliance_console", - "compliance-view": "compliance_console", - "scout-list": "compliance_console", - "scout-view": "compliance_console", - "strategy": "strategy_console", - "strategy-ledger": "strategy_console", - "strategy-rule": "strategy_console", - "strategy-simulate": "strategy_console", - "research": "research_console", - "research-list": "research_console", - "research-doc": "research_console", - "research-trials": "research_console", - "trading": "trading_console", - "trading-cycle": "trading_console", - "trading-monitor": "trading_console", - "data": "data_console", - "data-fetch": "data_console", - "data-freshness": "data_console", - "account": "account_console", - "account-pnl": "account_console", - "account-versions": "account_console", - "account-update": "account_console", -} -# Every other mode (the dashboard's own overlays and the help surfaces) is owned by the -# TUI module itself. - - -def _owning_module(mode: str) -> Any: - """The module that owns `mode`'s screen -- `keel.commands.tui` for the dashboard's - own overlays and the help surfaces, the named console module for the rest.""" - import keel.commands.tui as tui_module - - name = _MODE_OWNERS.get(mode) - if name is None: - return tui_module - import importlib - - return importlib.import_module(f"keel.commands.{name}") - - -def contextual_help(mode: str) -> list[HelpEntry]: - """`mode`'s contextual help -- the "what am I looking at" / "what will this do" rows - the `?` overlay renders, from the module that OWNS the screen (its `CONTEXT_HELP`). - An unregistered mode answers `[]`, which the registry pins never happens for a real - console mode.""" - pairs = _owning_module(mode).CONTEXT_HELP.get(mode, ()) - return [HelpEntry(subject, description) for subject, description in pairs] - - -def build_context_help_lines(mode: str) -> list[ScreenLine]: - """The `?` overlay for `mode`: its title, the mode's contribution (one subject per - row, its description wrapped), and the close note. PURE over the registry.""" - lines: list[ScreenLine] = [ - ScreenLine(f"keel console -- help: {mode}", "heading"), - ScreenLine("what am I looking at (press q, Esc, ? or m to return)", "muted"), - _blank(), - ] - for entry in contextual_help(mode): - lines.append(ScreenLine(entry.subject, "normal")) - for wrapped in _wrap(entry.description, indent=" "): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append( - ScreenLine( - "the Help menu (m -> Help) holds the glossary and every screen's rows", "muted" - ) - ) - return lines - - -def build_screens_catalog_lines() -> list[ScreenLine]: - """The CONSOLIDATED catalog: every console mode's contribution in registry order -- - the C7 audit the PRD's phasing asked for (per-screen strings landed with C2-C5 - where they existed; this view makes the whole set browsable in one place). PURE - over the registry.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- help / screens & actions", "heading"), - ] - for wrapped in _wrap( - "every console screen's own help rows, one block per screen -- the same text " - "the ? overlay renders in place", - indent="", - ): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - for mode in CONSOLE_MODES: - lines.append(ScreenLine(mode, "heading")) - for entry in contextual_help(mode): - lines.append(ScreenLine(f" {entry.subject}", "normal")) - for wrapped in _wrap(entry.description, indent=" "): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append( - ScreenLine( - "up/k down/j scroll · PgUp/PgDn/Home/End · q/Esc/m back to the Help menu", - "muted", - ) - ) - return lines - - -# -- the Help menu (PRD §3's Help branch) ------------------------------------------------------- - - -@dataclass(frozen=True) -class HelpMenuEntry: - """One entry of the Help sub-menu. `kind` is the closed dispatch vocabulary: - `"view"` opens a scrolled help surface (`target` names it).""" - - ordinal: int - label: str - description: str - kind: str # "view" - target: str # "glossary" | "screens" | "params" | "keys" - - -#: PRD §3's Help branch: the glossary, the per-screen/per-action catalog, the parameter -#: help, and the keys/safety notes (the pre-C7 help screen, kept whole). -HELP_MENU: tuple[HelpMenuEntry, ...] = ( - HelpMenuEntry( - ordinal=1, - label="glossary", - description=( - "every console term defined once, the fiqh terms anchored to docs/fiqh-basis.md" - ), - kind="view", - target="glossary", - ), - HelpMenuEntry( - ordinal=2, - label="screens & actions", - description="every console screen's what-am-I-looking-at rows, consolidated", - kind="view", - target="screens", - ), - HelpMenuEntry( - ordinal=3, - label="rule parameters", - description="every rule kind's params, rendered from the classes via describe_params", - kind="view", - target="params", - ), - HelpMenuEntry( - ordinal=4, - label="keys & safety", - description="the keybindings, the network touches, and the typed-action safety notes", - kind="view", - target="keys", - ), -) - - -def help_entry(ordinal: int) -> HelpMenuEntry | None: - """The entry selected by its displayed ordinal (1-4), or `None` -- the one-lookup - rule every console menu keeps.""" - for entry in HELP_MENU: - if entry.ordinal == ordinal: - return entry - return None - - -def build_help_menu_lines(*, cursor: int = 0) -> list[ScreenLine]: - """The Help sub-menu screen: every entry with its description wrapped to the - 80-column budget, exactly one cursor-marked row. PURE.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- help", "heading"), - ScreenLine("? opens the current screen's help wherever you are", "muted"), - _blank(), - ] - cursor = max(0, min(cursor, len(HELP_MENU) - 1)) - for index, entry in enumerate(HELP_MENU): - marker = ">" if index == cursor else " " - lines.append( - ScreenLine( - f"{marker} {entry.ordinal} {entry.label}", - "heading" if index == cursor else "normal", - ) - ) - for wrapped in _wrap(f"{entry.description}.", indent=" "): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append( - ScreenLine( - "up/k down/j move · Enter/Space select · 1-4 jump · q/Esc/m to the menu", - "muted", - ) - ) - return lines diff --git a/keel/commands/insights.py b/keel/commands/insights.py index 41863338..1afc3088 100644 --- a/keel/commands/insights.py +++ b/keel/commands/insights.py @@ -51,7 +51,6 @@ from keel import agent as agent_mod from keel.commands._common import DISCLAIMER, _load_cfg, _open_repo from keel.commands.status import StatusReport, _human_age, gather_status -from keel.commands.tui import _human_dt from keel.config import Config from keel.data.repository import Repository from keel.strategy.paper import track_record @@ -432,6 +431,20 @@ def build_gate_distance( ) +def _human_dt(ts: int) -> str: + """Local-time `YYYY-MM-DD HH:MM:SS` for a unix timestamp. + + Moved here from `keel/commands/tui.py` at #541, which deleted that module; this was the only + use left outside it. **Local time, and deliberately unlabelled**, which is a divergence from + every other surface keel has: `keel/web/payload.py::_gmt` renders UTC and says so, because + keel's day boundaries are UTC everywhere and rendering in local time is what made the activity + feed show a permanently stale "today" (#381). This line is a terminal reading for a person + sitting in front of it, where local time is what they mean; the browser is the surface that + has to be unambiguous, and it is. + """ + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) + + def build_rule_track_record( row: dict[str, Any], stats: Any, default_floor: PromotionConfig ) -> RuleTrackRecord: diff --git a/keel/commands/research_console.py b/keel/commands/research_console.py deleted file mode 100644 index 14b8903d..00000000 --- a/keel/commands/research_console.py +++ /dev/null @@ -1,422 +0,0 @@ -"""The Research menu -- the console's evidence readers (issue #390 C4; PRD O5 and §3's tree). - -Read-only, browsable overlays over the repo's evidence corpus: the `docs/experiments/` and -`docs/research/` documents, the promotion reports (`docs/superpowers/reports/` -- the same -directory `run_simulation` writes into, so a just-run simulation's report is in the list, -newest-first), and the trials ledger (`trials list`'s own rendering plus `verify`'s chain -verdict, both through the `keel.research.ledger` service, read-only). - -Everything is DISPATCH and bounded reading, never behavior: - -* the corpus DIRECTORIES are the engine's own paths, single-sourced -- the experiments - directory is the trials ledger's own parent (`DEFAULT_LEDGER_PATH`), the reports - directory is where `commands.simulate.default_report_path` writes -- never a TUI-side - path table that could drift from the code that writes; -* each document is read through `read_document_lines`, BOUNDED at `MAX_DOC_BYTES` (the - activity feed's own bound, for the same reason: a screen that re-reads per repaint must - never have its cost grow with whatever a runaway writer put in the file) with a loud - truncation note rather than a silent partial view; -* the doc view is cached per (path, mtime) -- the compliance console's scout-browser - lesson: repaints do not re-read an unchanged file, and a changed mtime refreshes; -* the trials view renders `read_trials`/`trial_counts`/`verify_chain` -- the SAME service - calls `keel trials list`/`verify` make, read-only, fail-calm about an absent ledger. -""" - -from __future__ import annotations - -import textwrap -from dataclasses import dataclass -from pathlib import Path - -from keel.commands.simulate import default_report_path -from keel.commands.tui import ScreenLine, _blank, _message_style -from keel.research import ledger as trials_ledger - -#: The research-docs corpus (`docs/research/`). -RESEARCH_DOCS_DIR = Path("docs/research") - - -def corpus_path(target: str) -> Path: - """The corpus directory for a `target` ("experiments"/"research"/"reports"), - single-sourced from the code that writes into it and resolved at CALL time: the - experiments corpus is the trials LEDGER's own directory (`DEFAULT_LEDGER_PATH.parent` - -- read when asked, so a test-isolated or relocated ledger relocates the reader with - it), the reports corpus is where `run_simulation` writes (`default_report_path`), and - the research docs are the repo's own `docs/research/`. Never a TUI-side path table - that could drift from the writer.""" - if target == "experiments": - return trials_ledger.DEFAULT_LEDGER_PATH.parent - if target == "reports": - return default_report_path(0).parent - return RESEARCH_DOCS_DIR - -#: The most bytes of one document this module will ever read (1 MiB -- the activity feed's -#: own bound and the scout browser's, for the same reason: a reader that repaints per poll -#: must never have its cost grow with whatever a runaway writer put in the file). A -#: document past the bound reads its first `MAX_DOC_BYTES` with a loud truncation note, -#: never an unbounded read and never a silent partial view. -MAX_DOC_BYTES = 1024 * 1024 - -_WIDTH = 78 - - -def _wrap(text: str, *, indent: str = " ", width: int = _WIDTH) -> list[str]: - """Wrap to the 80-column budget (the same rule every console screen keeps -- `_paint` - clips at the window width, and a clipped path tail would be exactly the part that - identifies the file). PURE.""" - return textwrap.wrap(text, width=width, initial_indent=indent, subsequent_indent=indent) or [ - indent - ] - - -# -- the sub-menu model (PRD §3's Research branch) ------------------------------------------------- - - -@dataclass(frozen=True) -class ResearchEntry: - """One entry of the Research sub-menu. `kind` is the closed dispatch vocabulary: - `"corpus"` lists a directory of documents, `"trials"` renders the trials-ledger view. - `target` names the corpus for `corpus_path` (call-time resolution -- see it) or the - trials view.""" - - ordinal: int - label: str - description: str - kind: str # "corpus" | "trials" - target: str # "experiments" | "research" | "reports" | "trials" - - -#: PRD §3's Research branch in tree order: experiments, research docs, promotion reports, -#: the trials ledger (list/verify). The directories resolve through `corpus_path` -- the -#: engine's own paths, discovered, never re-declared. -RESEARCH_MENU: tuple[ResearchEntry, ...] = ( - ResearchEntry( - ordinal=1, - label="experiments", - description="the docs/experiments corpus -- what was tried, and measured", - kind="corpus", - target="experiments", - ), - ResearchEntry( - ordinal=2, - label="research docs", - description="the docs/research corpus -- source reviews and feasibility notes", - kind="corpus", - target="research", - ), - ResearchEntry( - ordinal=3, - label="promotion reports", - description=( - "the engine-validation reports simulate writes (a just-run report is here, " - "newest first)" - ), - kind="corpus", - target="reports", - ), - ResearchEntry( - ordinal=4, - label="trials ledger", - description="the hash-chained experiments ledger, with its chain verdict", - kind="trials", - target="trials", - ), -) - - -def research_entry(ordinal: int) -> ResearchEntry | None: - """The entry selected by its displayed ordinal, or `None` -- the one-lookup rule every - console menu keeps, so the rendered ordinals and the shortcut keys cannot drift.""" - for entry in RESEARCH_MENU: - if entry.ordinal == ordinal: - return entry - return None - - -#: This module's screens' contextual help (O8, issue #394 C7) -- the rows the `?` -#: overlay renders, keyed by the live loop's mode names. Plain `(subject, description)` -#: pairs so the text stays HERE with the module that owns the screens; -#: `keel.commands.help_console` is the registry and renderer. -CONTEXT_HELP: dict[str, tuple[tuple[str, str], ...]] = { - "research": ( - ( - "the corpora", - "read-only browsers over the repo's evidence: the experiments corpus (what " - "was tried, and measured), the research docs, and the promotion reports a " - "simulate run writes (newest first)", - ), - ( - "the trials ledger", - "the hash-chained record of every backtest trial, with the chain's own " - "verify verdict rendered under the rows", - ), - ), - "research-list": ( - ( - "the files", - "one row per document, newest first, with its written date and size; the " - "directories are the engine's own paths, discovered -- never a TUI-side list", - ), - ("Enter", "opens the document's own text, verbatim -- its words, not a summary"), - ), - "research-doc": ( - ( - "the document", - "the file's own lines, wrapped to the 80-column budget and read BOUNDED " - "(the first MiB, with a loud truncation note if the file is past it)", - ), - ), - "research-trials": ( - ( - "the rows", - "every trial the ledger records, with the two N accountings; the chain " - "verdict under them is the SAME read-only verify `keel trials verify` runs", - ), - ), -} - - -def build_research_menu_lines(*, cursor: int = 0, message: str | None = None) -> list[ScreenLine]: - """The Research sub-menu screen: every entry with its description wrapped to the - 80-column budget, exactly one cursor-marked row. PURE.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- research", "heading"), - ScreenLine("read-only browsers over the repo's evidence corpus", "muted"), - _blank(), - ] - cursor = max(0, min(cursor, len(RESEARCH_MENU) - 1)) - for index, entry in enumerate(RESEARCH_MENU): - marker = ">" if index == cursor else " " - head = f"{marker} {entry.ordinal:>2} {entry.label}" - lines.append(ScreenLine(head, "heading" if index == cursor else "normal")) - for wrapped in _wrap(f"{entry.description}.", indent=" "): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append(ScreenLine("up/k down/j move · Enter/Space select · 1-4 jump", "muted")) - lines.append(ScreenLine("q/Esc/m to the console menu", "muted")) - if message is not None: - lines.append(_blank()) - for part in message.splitlines(): - lines.append(ScreenLine(part, _message_style(part))) - return lines - - -# -- the corpus readers (O5) ------------------------------------------------------------------ - - -@dataclass(frozen=True) -class DocFile: - """One corpus document with its display facts: WHEN it was written (mtime) and how big - it is -- carried rather than re-`stat`-ed per render, the scout browser's `ScoutFile` - convention.""" - - path: Path - mtime_ts: float - size_bytes: int - - -def list_documents(directory: Path, *suffixes: str) -> tuple[DocFile, ...]: - """Every document under `directory` (filtered by `suffixes` when given, else every - file), NEWEST FIRST by (mtime, name) -- the scout browser's own contract: an absent or - unreadable directory is `()` rather than an exception, a reader must render a calm - empty state, and per-file stat failures cost that row, never the whole list.""" - try: - if not directory.is_dir(): - return () - except OSError: - return () - found: list[DocFile] = [] - for candidate in directory.iterdir(): - if not candidate.is_file(): - continue - if suffixes and candidate.suffix not in suffixes: - continue - try: - stat = candidate.stat() - except OSError: - continue - found.append(DocFile(path=candidate, mtime_ts=stat.st_mtime, size_bytes=stat.st_size)) - found.sort(key=lambda f: (f.mtime_ts, f.path.name), reverse=True) - return tuple(found) - - -def read_document_lines(path: Path, *, max_bytes: int = MAX_DOC_BYTES) -> list[str]: - """The document's own lines, BOUNDED: at most the first `max_bytes` bytes are read, - decoded UTF-8 (errors replaced -- a stray byte must not kill the view), with a loud - truncation note at the head when the file is past the bound. An unreadable file is a - calm one-line notice, never a traceback.""" - try: - with path.open("rb") as handle: - blob = handle.read(max_bytes + 1) - except OSError as exc: - return [f"(unreadable: {exc})"] - lines: list[str] = [] - if len(blob) > max_bytes: - try: - total = path.stat().st_size - except OSError: - total = -1 - total_note = f"{total} bytes" if total >= 0 else "an unknown size" - lines.append( - f"(truncated: the file is {total_note}; showing the first " - f"{max_bytes // 1024} KiB of it)" - ) - blob = blob[:max_bytes] - text = blob.decode("utf-8", errors="replace") - lines.extend(text.splitlines()) - return lines - - -def cached_document_lines( - path: Path, cache: dict[tuple[str, int], list[str]] -) -> list[str]: - """`read_document_lines` for the doc view, cached per (path, mtime_ns): the view - repaints every poll, and re-reading an UNCHANGED document each time is pure waste; a - changed mtime (or an unstatable file -- a key that can never re-hit) refreshes. The - cache is the caller's dict, single purpose: one document is open at a time, and the - caller clears it when another is opened (the `cached_scout_view` lesson, verbatim).""" - try: - mtime_ns = path.stat().st_mtime_ns - except OSError: - mtime_ns = -1 # unstatable: a key that can never be re-hit, so it never caches - key = (str(path), mtime_ns) - if key in cache: - return cache[key] - lines = read_document_lines(path) - cache.clear() # single entry: only the open document is worth holding - cache[key] = lines - return lines - - -def build_doc_list_lines( - title: str, files: tuple[DocFile, ...], directory: Path, *, cursor: int = 0 -) -> list[ScreenLine]: - """A corpus's file list: every document newest-first with its date and size, exactly - one cursor row, and an empty state that NAMES the directory it read. PURE.""" - import time - - lines: list[ScreenLine] = [ - ScreenLine(f"keel console -- research / {title}", "heading"), - ] - for wrapped in _wrap(f"in {directory}, newest first", indent=""): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - if not files: - for wrapped in _wrap(f"no documents -- {directory} is empty or absent.", indent=""): - lines.append(ScreenLine(wrapped, "normal")) - lines.append(_blank()) - lines.append(ScreenLine("Press q, Esc or m to return to the Research menu.", "muted")) - return lines - cursor = max(0, min(cursor, len(files) - 1)) - for index, doc in enumerate(files): - marker = ">" if index == cursor else " " - day = time.strftime("%Y-%m-%d %H:%M", time.localtime(doc.mtime_ts)) - # Wrapped to the budget, not clipped: a long report filename's tail is exactly the - # part that identifies the file (the same rule every console screen keeps). - for wrapped in _wrap( - f"{marker} {doc.path.name} · written {day} · {doc.size_bytes} bytes", indent="" - ): - lines.append( - ScreenLine(wrapped, "heading" if index == cursor else "normal") - ) - lines.append(_blank()) - lines.append( - ScreenLine( - "Enter opens · up/k down/j move · q/Esc/m back to the Research menu", "muted" - ) - ) - return lines - - -def build_doc_lines( - title: str, path: Path, lines_of: list[str] -) -> list[ScreenLine]: - """The chosen document, rendered as text: the corpus's title, the file's name, then - the document's OWN lines verbatim (its words, not a summary of them). PURE.""" - out: list[ScreenLine] = [ - ScreenLine(f"keel console -- research / {title}", "heading"), - ] - for wrapped in _wrap(str(path), indent=""): - out.append(ScreenLine(wrapped, "muted")) - out.append(_blank()) - for line in lines_of: - if not line.strip(): - out.append(_blank()) - continue - # Wrap long lines to the budget rather than clipping: a clipped table row or - # sentence tail is the part an operator scrolled for. - for wrapped in textwrap.wrap(line, width=_WIDTH) or [""]: - out.append(ScreenLine(wrapped, "normal")) - out.append(_blank()) - out.append( - ScreenLine( - "up/k down/j scroll · PgUp/PgDn/Home/End · q/Esc/m back to the list", "muted" - ) - ) - return out - - -# -- the trials reader (list + verify, read-only) --------------------------------------------- - - -def build_trials_lines( - ledger_path: Path | None = None, -) -> list[ScreenLine]: - """The trials-ledger view: `trials list`'s own rendering (one row per trial, the two N - accountings) plus `verify`'s chain verdict -- the SAME service reads the CLI commands - make, read-only. The path defaults to the ledger's OWN constant, read at CALL time (so - a test-isolated or relocated ledger relocates this reader with it). An absent ledger - is a calm empty state that names the path.""" - path = trials_ledger.DEFAULT_LEDGER_PATH if ledger_path is None else ledger_path - lines: list[ScreenLine] = [ - ScreenLine("keel console -- research / trials ledger", "heading"), - ] - for wrapped in _wrap(f"ledger: {path}", indent=""): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - try: - trials = trials_ledger.read_trials(path) - except OSError: - for wrapped in _wrap( - f"no trials on record -- {path} does not exist yet (a trial is " - "recorded by `keel trials record` and by every `keel simulate` run).", - indent="", - ): - lines.append(ScreenLine(wrapped, "normal")) - lines.append(_blank()) - lines.append(ScreenLine("Press q, Esc or m to return to the Research menu.", "muted")) - return lines - if not trials: - for wrapped in _wrap( - f"the ledger at {path} holds no trials yet.", indent="" - ): - lines.append(ScreenLine(wrapped, "normal")) - for index, record in enumerate(trials, start=1): - flag = " [series_missing]" if record.series_missing else "" - lines.append( - ScreenLine( - f"{index:>4} {record.trial_id:<34} {record.rule:<18} " - f"{record.provenance:<9} {record.kind:<16} {record.decision}{flag}", - "normal", - ) - ) - m, n_decisions = trials_ledger.trial_counts(trials) - lines.append(_blank()) - lines.append(ScreenLine(f"M={m} N_decisions={n_decisions}", "normal")) - # The chain verdict, through the SAME read-only verify the CLI runs. - errors = trials_ledger.verify_chain(path) - if not errors: - lines.append(ScreenLine("chain intact", "ok")) - else: - lines.append(ScreenLine(f"CHAIN BROKEN -- {len(errors)} error(s):", "alert")) - for error in errors: - for wrapped in _wrap(error, indent=" "): - lines.append(ScreenLine(wrapped, "alert")) - lines.append(_blank()) - lines.append( - ScreenLine( - "up/k down/j scroll · PgUp/PgDn/Home/End · q/Esc/m back to the Research menu", - "muted", - ) - ) - return lines diff --git a/keel/commands/strategy_console.py b/keel/commands/strategy_console.py deleted file mode 100644 index 5539abc2..00000000 --- a/keel/commands/strategy_console.py +++ /dev/null @@ -1,1168 +0,0 @@ -"""The Rules menu -- the console's strategy console (issue #390 C4; PRD O11 and §3's tree). - -Everything here is DISPATCH, never behavior (PRD O2, the discipline `compliance_console` -keeps): the ledger reads the rules table and the recorded paper track (through -`keel.commands.rules`' extracted services, `strategy.promotion`'s own decision, and the -`insights` service's own gate distance), simulate is `commands.simulate.run_simulation` -with the active profile's config/db, and every form collects fields at the terminal and -calls the same service the CLI command calls. No sizing, gating or reporting math lives here. - -The surfaces, all pure (or injected-I/O) and unit-testable without curses: - -* **The sub-menu model** -- `STRATEGY_MENU`, PRD §3's Rules branch as the O11 strategy - console: the tried-vs-used ledger, simulate + results, add, retry (backtest + promote, - `--force` TYPED), enable/disable/demote, insights. -* **The tried-vs-used ledger (O11.2)** -- one view answering "which strategies are in use, - which were tried, and WHY are the tried ones not used", split by COST. The ENTRY render is - CHEAP and sources only recorded state: every rule row with its lifecycle status, kind, - product and recorded stamps (`promoted_at`/`demoted_at`), plus the `insights` service's - promotion-gate distance for `paper` rules (kind-wide, over the recorded paper orders -- - bounded, never a backtest). It invokes ZERO backtests, pinned by spy: the entry-time - re-backtest this view shipped with measured ~7.5 minutes for ONE rsi_meanrev rule over 5y - of hourly candles (the walk is intrinsically quadratic), ~2.4 hours for a 19-rule hourly - deployment, uncancellable -- so it is gone. Nothing persists a last-backtest result - (`rules backtest`/`promote` write no rows; the schema's `backtests` table has no writer), - so the entry invents no storage either. The full per-rule backtest verdict is an EXPLICIT, - per-rule, Enter-gated re-compute in the rule's detail view ("re-compute this rule's - verdict"), warned before it runs (full-window backtest, minutes on long series), honestly - blocking during the one rule (like simulate/fetch) and Esc-cancellable between rules; the - result is HELD in the ledger's state and invalidated when the ledger is rebuilt from the - rules table on the next entry. G4/PBO is honestly NOT RUN there (no `--pbo-session` is - named), and `can_promote`'s own reason says so. -* **Simulate (O11.1)** -- an ARMED view that shows the TARGET REPORT PATH before anything - runs (the confirm step) and pins that exact path INTO the run (`out_path`), so a run - crossing UTC midnight cannot write a different filename than the one confirmed; - `run_simulation` on Enter (the CLI's own defaults: 5y, $500/month, the allowlist's - products, history fetched when the cache does not cover the window), and a results screen - rendering the service's own verdict/report verbatim under a pinned verdict+path footer. - The run blocks the loop exactly like `f` fetch does -- the CLI's own UX, mirrored - honestly -- with the progress lines the CLI would have streamed collected and shown at - the head of the results (and kept above the error line when the run fails). -* **The forms (O11.3/O11.4)** -- add (per-field help from `rules.describe_params`, the O8 - single source, offering only the params the kind PERSISTS; lands as `candidate` exactly - as the CLI does, with the SERVICE's own validation messages rendered), retry (re-backtest - always; promote only on an explicit y/N; `--force` behind - `clis_typed_promote_force_gate` -- console-ADDED ceremony over the CLI's bare `--force` - flag, built on the shared typed-confirmation gate and quoting the CLI's own force - warning; exact phrase, never pre-filled, failing closed), and enable as the documented - restore path. -""" - -from __future__ import annotations - -import json -import textwrap -from collections.abc import Callable -from dataclasses import dataclass -from decimal import Decimal -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from keel import agent -from keel.commands.rules import ( - ParamHelp, - RulesRefused, - RulesUsageError, - add_rule_row, - apply_rule_demote, - apply_rule_disable, - apply_rule_enable, - attempt_promotion, - backtest_resolved, - describe_params, - resolve_rule_backtest, - run_rule_backtest, -) -from keel.commands.simulate import ( - SimulationOutcome, - default_report_path, - run_simulation, -) -from keel.commands.tui import ScreenLine, _blank, _message_style -from keel.types import Granularity - -if TYPE_CHECKING: - from keel.config import Config - from keel.data.repository import Repository - -#: One terminal prompt: injected so every form is unit-testable with a scripted fake, and so -#: the live loop can run the whole form through the curses suspend/restore dance. -PromptFn = Callable[[str], str] - -#: The width every console line must fit (`_paint` clips at the window width; 80-column -#: terminals are this dashboard's stated target) -- the same budget `compliance_console` -#: keeps, applied by wrapping rather than clipping. -_WIDTH = 78 - - -def _wrap(text: str, *, indent: str = " ", width: int = _WIDTH) -> list[str]: - """Wrap `text` on spaces to the 80-column budget, continuation lines carrying `indent`. - PURE -- the same rule `compliance_console._wrap` keeps, over `textwrap` so a doc quote - or a reason sentence can never lose its tail to `_paint`'s clip.""" - return textwrap.wrap(text, width=width, initial_indent=indent, subsequent_indent=indent) or [ - indent - ] - - -# -- the sub-menu model (PRD §3's Rules branch, as the O11 strategy console) ---------------------- - - -@dataclass(frozen=True) -class StrategyEntry: - """One entry of the Rules sub-menu. `kind` is the closed dispatch vocabulary: `"view"` - renders a browsable report (the ledger), `"armed"` opens an ARMED compute view (simulate - -- Enter is the confirm step), `"form"` runs a rules service at the terminal, and - `"insights"` opens the existing insights overlay (returning here on close).""" - - ordinal: int - label: str - description: str - kind: str # "view" | "armed" | "form" | "insights" - target: str - - -#: PRD §3's Rules branch in tree order, worded as the O11 loop: ledger, simulate, add, -#: retry, and the lifecycle actions beneath them. The descriptions are O8's plain-English -#: "what will this do" in miniature, naming the dispatch honestly. -STRATEGY_MENU: tuple[StrategyEntry, ...] = ( - StrategyEntry( - ordinal=1, - label="tried-vs-used ledger", - description=( - "every rule with its lifecycle status and recorded context (read-only; no " - "backtest runs here -- each rule's verdict is an explicit per-rule re-compute)" - ), - kind="view", - target="ledger", - ), - StrategyEntry( - ordinal=2, - label="simulate + results", - description=( - "run the deterministic replay on this deployment (Enter confirms; it fetches " - "and writes the report) and read the verdict" - ), - kind="armed", - target="simulate", - ), - StrategyEntry( - ordinal=3, - label="add a strategy", - description=( - "the rules add flow: kind, product, params -- help at each field; lands as " - "candidate exactly as the CLI does" - ), - kind="form", - target="add", - ), - StrategyEntry( - ordinal=4, - label="retry a strategy", - description=( - "re-run the backtest, then re-attempt promote (confirm; --force is typed) " - "through the same services the CLI calls" - ), - kind="form", - target="retry", - ), - StrategyEntry( - ordinal=5, - label="enable (restore)", - description="the documented restore path for a disabled rule -- back at candidate", - kind="form", - target="enable", - ), - StrategyEntry( - ordinal=6, - label="disable", - description="take a rule out of the lifecycle (terminal; enable restores it)", - kind="form", - target="disable", - ), - StrategyEntry( - ordinal=7, - label="demote", - description="step a rule back one stage (live->paper->candidate)", - kind="form", - target="demote", - ), - StrategyEntry( - ordinal=8, - label="insights", - description="the promotion-gate distance and journal view (read-only)", - kind="insights", - target="", - ), -) - - -def strategy_entry(ordinal: int) -> StrategyEntry | None: - """The entry selected by its displayed ordinal, or `None` -- the one-lookup rule every - console menu keeps, so the rendered ordinals and the shortcut keys cannot drift.""" - for entry in STRATEGY_MENU: - if entry.ordinal == ordinal: - return entry - return None - - -#: This module's screens' contextual help (O8, issue #394 C7) -- the rows the `?` -#: overlay renders, keyed by the live loop's mode names. Plain `(subject, description)` -#: pairs so the text stays HERE with the module that owns the screens; -#: `keel.commands.help_console` is the registry and renderer. -CONTEXT_HELP: dict[str, tuple[tuple[str, str], ...]] = { - "strategy": ( - ( - "the tried-vs-used ledger", - "every rule row with its lifecycle status AND the machine's recorded reason " - "it sits there -- the failing promotion-gate floor, the disabled context, " - "the demotion; read-only, and zero backtests run to render it", - ), - ( - "simulate + results", - "the deterministic replay on the ACTIVE deployment: Enter is the confirm " - "step, the run fetches and writes exactly as `keel simulate` does, and the " - "verdict (GO-LIVE / TRAIN-MORE), gates and the DCA benchmark render after", - ), - ( - "add a strategy", - "the `rules add` flow at the terminal: kind, product, params -- each param " - "field's help renders from the rule class itself (describe_params), and the " - "row lands as candidate exactly as the CLI's add does", - ), - ( - "retry, and the lifecycle actions", - "retry re-runs the backtest and re-attempts promote through the same " - "services the CLI calls: promote's confirm is a y/N, and `--force` stays " - "TYPED -- you type the phrase yourself at the terminal, and the prompt " - "cannot be pre-filled. enable is the documented restore path for a disabled " - "rule; disable and demote step rules back through the lifecycle", - ), - ( - "insights", - "the per-rule promotion-gate distance and the trade journal, read-only", - ), - ), - "strategy-ledger": ( - ( - "the rows", - "one per rule: lifecycle status, the recorded reason it sits there, and " - "the params it runs -- built cheaply from recorded state only", - ), - ( - "Enter", - "opens the rule's detail (params with their per-field help, the paper gate's " - "distance, and the explicit re-compute -- which is the ONE place the " - "strategy console runs a backtest)", - ), - ), - "strategy-rule": ( - ( - "the rule's params", - "every parameter with its value and the help text the rule CLASS carries " - "(describe_params by introspection) -- one source, the classes themselves", - ), - ( - "re-compute (ARMED)", - "Enter runs the FULL-WINDOW backtest over the cached candles and judges it " - "through the promotion gate -- real work that can take minutes; Esc or q " - "returns without running anything", - ), - ), - "strategy-simulate": ( - ( - "the ARMED view", - "the plan (products, window, the deployment's db) renders first; Enter is " - "the confirm step, and the run blocks the loop exactly like a fetch", - ), - ( - "the verdict", - "GO-LIVE or TRAIN-MORE with the gates' own numbers, the DCA benchmark " - "comparison and the tier matrix; the written report is browsable from the " - "Research menu", - ), - ), -} - - -def build_strategy_menu_lines(*, cursor: int = 0, message: str | None = None) -> list[ScreenLine]: - """The Rules sub-menu screen: every entry with its description wrapped to the 80-column - budget, exactly one cursor-marked row, and the last action's confirmation lines as the - toast. PURE.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- rules", "heading"), - _blank(), - ] - cursor = max(0, min(cursor, len(STRATEGY_MENU) - 1)) - for index, entry in enumerate(STRATEGY_MENU): - marker = ">" if index == cursor else " " - head = f"{marker} {entry.ordinal:>2} {entry.label}" - style = "heading" if index == cursor else "normal" - lines.append(ScreenLine(head, style)) - for wrapped in _wrap(f"{entry.description}.", indent=" "): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append(ScreenLine("up/k down/j move · Enter/Space select · 1-8 jump", "muted")) - lines.append(ScreenLine("q/Esc/m to the console menu", "muted")) - if message is not None: - lines.append(_blank()) - for part in message.splitlines(): - lines.append(ScreenLine(part, _message_style(part))) - return lines - - -# -- the tried-vs-used ledger (O11.2) ---------------------------------------------------------- - - -#: The lifecycle groups, in the ledger's in-use-first order -- `STATUS` itself is the -#: engine's own vocabulary; the gloss after each dash names what the runbook means by it. -_LEDGER_GROUPS: tuple[tuple[str, str], ...] = ( - ("live", "live -- IN USE (trading)"), - ("paper", "paper -- proving (forward, no orders to the venue)"), - ("candidate", "candidate -- TRIED, NOT USED"), - ("disabled", "disabled -- OUT (terminal)"), -) - - -@dataclass(frozen=True) -class LedgerRule: - """One rules-table row as the ledger sees it AT ENTRY: the RECORDED lifecycle facts - only -- status, kind, product, the row's own stamps, its params -- plus (paper rows) - the insights service's kind-wide gate distance over the recorded paper trades. - Building one runs NO backtest; the backtest verdict is `RuleVerdict`, computed only by - the explicit per-rule re-compute (`compute_rule_verdict`).""" - - rule_id: int - kind: str - status: str - product_id: str | None - promoted_at: int | None - demoted_at: int | None - paper_gate_lines: tuple[str, ...] - params: dict[str, Any] - - -@dataclass(frozen=True) -class RuleVerdict: - """One rule's EXPLICITLY-computed backtest verdict (the Enter-gated re-compute): the - fee-honest backtest summary (`stats_line`) and the promotion gate's own reasons -- - exactly what `rules backtest` measures and `rules promote`'s gate answers -- or the - honest absence ("no backtest on record") / honest failure (a backtest that raised). - Held in the ledger's state per rule, never recomputed by a repaint.""" - - stats_line: str | None - reason_lines: tuple[str, ...] - - -def _paper_gate_lines(repo: Repository, config: Config, row: dict[str, Any]) -> list[str]: - """The insights service's kind-wide paper-gate distance for a `paper` row -- its own - reading of the recorded paper track record (by kind, exactly as `keel insights summary` - reads it), with the kind-wide semantics disclosed ON the rendered line. Recorded - orders only: no backtest is involved, so this is cheap enough for the entry render.""" - from keel.commands.insights import build_rule_track_record - from keel.strategy import promotion as promotion_mod - from keel.strategy.paper import track_record - - promo_cfg = promotion_mod.PromotionConfig( - min_trades=config.promotion.min_trades, - min_expectancy=config.promotion.min_expectancy, - min_rr=config.promotion.min_rr, - min_win_rate=float(config.promotion.min_win_rate), - ) - record = build_rule_track_record(row, track_record(repo, row["kind"]), promo_cfg) - lines: list[str] = [] - if record.gate is not None: - verdict = "PASSING" if record.gate.passing else "blocked" - lines.append( - f"paper gate ({record.gate.promotion_class} floor): {verdict} -- " - f"trades_remaining={record.gate.trades_remaining} " - f"(n>={record.gate.min_trades}, win_rate>={record.gate.min_win_rate}, " - f"rr>={record.gate.min_rr}, expectancy>{record.gate.min_expectancy})" - ) - lines.extend(record.gate.blocking_reasons) - else: - # `render_summary`'s own wording for a row whose kind the registry no longer - # knows -- the insights service's honest unavailable, not a TUI line. - lines.append("paper gate: unavailable (rule kind not recognized -- stale row?)") - lines.append( - "distance is kind-wide (insights' own semantics): the floor reading pools every " - "paper trade this KIND recorded, on any product -- not this row's trades alone" - ) - return lines - - -def _ledger_rule(repo: Repository, config: Config, row: dict[str, Any]) -> LedgerRule: - """The ledger's CHEAP read of ONE rule row: the recorded lifecycle facts, plus the - paper-gate distance for `paper` rows. No backtest runs here -- the entry render must - stay cheap on any deployment size (see the module docstring for what the entry-time - re-backtest cost). One poisoned row degrades to its own error line, never a crash.""" - params = row["params"] or {} - paper_gate: tuple[str, ...] = () - if row["status"] == "paper": - try: - paper_gate = tuple(_paper_gate_lines(repo, config, row)) - except Exception as exc: # noqa: BLE001 -- one row's read must cost only that row - paper_gate = ( - f"paper gate unreadable for this row: {exc!r} -- see `keel insights " - "summary` for the kind's own reading", - ) - return LedgerRule( - rule_id=row["id"], - kind=row["kind"], - status=row["status"], - product_id=params.get("product_id"), - promoted_at=row.get("promoted_at"), - demoted_at=row.get("demoted_at"), - paper_gate_lines=paper_gate, - params=params, - ) - - -def compute_rule_verdict( - repo: Repository, config: Config, entry: LedgerRule -) -> RuleVerdict: - """THE explicit per-rule re-compute (Enter-gated in the console): the full-window - backtest over the repo's cached candles -- DELEGATED to the `rules` service's compute - core (`resolve_rule_backtest` + `backtest_resolved`, the same read/build/backtest - `keel rules backtest` runs; this view once re-derived the granularity loop and the - input assembly here, a drifting twin of the service's) -- judged by the promotion gate - exactly as `rules promote` judges it (`can_promote`, no PBO session -- so the G4 axis - renders as its own honest NOT RUN reason). This is REAL WORK -- minutes on long series - -- which is precisely why nothing calls it from a render path. A backtest that raises - (stale params, e.g. a quoted float a pre-guard row still carries) is an honest per-row - error line, following `build_rule_track_record`'s graceful-degradation precedent.""" - from keel.commands.rules import _describe_fee - from keel.strategy import promotion as promotion_mod - - try: - resolved = resolve_rule_backtest(repo, config, entry.rule_id) - except ValueError: - return RuleVerdict( - stats_line=None, - reason_lines=( - f"rule kind {entry.kind!r} is no longer in RULE_REGISTRY -- a stale row " - "the engine cannot rebuild (see `keel rules list`)", - ), - ) - except RulesRefused as exc: - return RuleVerdict( - stats_line=None, - reason_lines=( - f"no backtest on record -- the backtest service refused this row: {exc} " - f"(see `keel rules backtest {entry.rule_id}` for the CLI's own refusal)", - ), - ) - - if not resolved.candles: - return RuleVerdict( - stats_line=None, - reason_lines=( - f"no backtest on record -- the repo holds no cached " - f"{resolved.granularity.value} candles for " - f"{entry.product_id or 'this product'} to backtest against", - ), - ) - - promo_cfg = promotion_mod.PromotionConfig( - min_trades=config.promotion.min_trades, - min_expectancy=config.promotion.min_expectancy, - min_rr=config.promotion.min_rr, - min_win_rate=float(config.promotion.min_win_rate), - ) - gate = promotion_mod.pbo_gate_from_config(config.research) - try: - stats = backtest_resolved(resolved) - except Exception as exc: # noqa: BLE001 -- the row's own error, never the view's - return RuleVerdict( - stats_line=None, - reason_lines=( - f"the backtest itself failed on this row's stored params: {exc!r} -- " - f"see `keel rules backtest {entry.rule_id}` for the same failure " - "(a stale param shape the add service now refuses)", - ), - ) - decision = promotion_mod.can_promote(stats, promo_cfg, None, gate) - stats_line = ( - f"backtest: n_trades={stats.n_trades} win_rate={stats.win_rate:.2%} " - f"expectancy={stats.expectancy} profit_factor={stats.profit_factor} " - f"{_describe_fee(resolved.fee_pct, resolved.fee_source)}" - ) - return RuleVerdict(stats_line=stats_line, reason_lines=tuple(decision.reasons)) - - -def build_strategy_ledger( - repo: Repository, config: Config, now_ts: int -) -> list[LedgerRule]: - """Every rules-table row as a `LedgerRule`, grouped in-use-first (live, paper, - candidate, disabled; by id within a group) -- the tried-vs-used ledger's data. CHEAP by - contract: recorded rows plus the bounded paper-track read, ZERO backtests (pinned by - spy in the tests -- the entry-time re-backtest this view shipped with cost minutes per - rule on long series). The console builds this on entering the view and holds it, - together with any re-computed verdicts; re-entering rebuilds both, which is the - held-verdict invalidation: a rules-table write between visits can never leak a stale - verdict onto a changed row.""" - del now_ts # the ledger renders the RECORDS' stamps, never "now" - rows = repo.get_rules() - grouped: list[dict[str, Any]] = [] - for _status, _label in _LEDGER_GROUPS: - grouped.extend(row for row in rows if row["status"] == _status) - known = {status for status, _ in _LEDGER_GROUPS} - grouped.extend(row for row in rows if row["status"] not in known) - return [_ledger_rule(repo, config, row) for row in grouped] - - -def _date(ts: int | None) -> str: - """Local-time `YYYY-MM-DD` for a recorded stamp -- the recorded DATE, not a raw int. - A missing stamp (a row that never left the status it was inserted at) renders `?` - rather than guessing an epoch.""" - import time - - if ts is None: - return "?" - return time.strftime("%Y-%m-%d", time.localtime(ts)) - - -def build_ledger_lines( - ledger: list[LedgerRule], - *, - cursor: int = 0, - verdicts: dict[int, RuleVerdict] | None = None, -) -> list[ScreenLine]: - """The ledger screen: every lifecycle group with its rows, each row's RECORDED context - and any HELD re-computed verdict beneath it, exactly one cursor-marked rule row. PURE -- - no verdict is computed here; `verdicts` is the held state the loop owns.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- rules / tried-vs-used ledger", "heading"), - ] - for wrapped in _wrap( - "which strategies are in use, which were tried -- recorded state, freshly read; " - "verdicts are per-rule re-computes (Enter), never entry-time backtests", - indent="", - ): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - if not ledger: - lines.append( - ScreenLine("no rules found -- `add a strategy` or `keel rules seed`.", "normal") - ) - lines.append(_blank()) - lines.append(ScreenLine("Press q, Esc or m to return to the Rules menu.", "muted")) - return lines - cursor = max(0, min(cursor, len(ledger) - 1)) - by_status: dict[str, list[LedgerRule]] = {} - for entry in ledger: - by_status.setdefault(entry.status, []).append(entry) - ordered = [ - (status, label) for status, label in _LEDGER_GROUPS if status in by_status - ] + [(status, status) for status in by_status if status not in dict(_LEDGER_GROUPS)] - index = 0 - for status, label in ordered: - lines.append(ScreenLine(label.upper(), "heading")) - for entry in by_status[status]: - marker = ">" if index == cursor else " " - product = entry.product_id or "?" - lines.append( - ScreenLine( - f"{marker} [{entry.rule_id}] {entry.kind} {product}", - "heading" if index == cursor else "normal", - ) - ) - index += 1 - if status == "paper": - for reason in entry.paper_gate_lines: - for wrapped in _wrap(f"- {reason}", indent=" "): - lines.append(ScreenLine(wrapped, "warn")) - verdict = (verdicts or {}).get(entry.rule_id) - if verdict is not None: - for reason in verdict.reason_lines: - for wrapped in _wrap(f"- {reason}", indent=" "): - lines.append(ScreenLine(wrapped, "warn")) - if verdict.stats_line is not None: - for wrapped in _wrap(verdict.stats_line, indent=" "): - lines.append(ScreenLine(wrapped, "muted")) - else: - for wrapped in _wrap( - "- no verdict computed in this view -- Enter opens the rule, where " - "Enter again re-computes it (a full-window backtest; minutes on " - "long series)", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "muted")) - if status == "live" and entry.promoted_at is not None: - lines.append( - ScreenLine(f" promoted {_date(entry.promoted_at)} (recorded)", "muted") - ) - if status == "paper" and entry.promoted_at is not None: - # `update_rule_status` writes every non-disabled transition into - # `promoted_at` (repository.py's own column choice), so a paper row's - # stamp is its demotion stamp when it arrived live->paper -- rendered - # with wording that names the column, never a false "was promoted". - for wrapped in _wrap( - f"paper since {_date(entry.promoted_at)} -- the column is " - "promoted_at; the runbook's documented demotion path (live->paper) " - "writes here too", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "muted")) - if status == "disabled": - if entry.demoted_at is not None: - lines.append( - ScreenLine( - f" disabled {_date(entry.demoted_at)} (recorded)", "muted" - ) - ) - for wrapped in _wrap( - "restore path: `keel rules enable` -- returns the rule at candidate, " - "never at the status it held", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - for wrapped in _wrap( - "up/k down/j move · Enter the rule's detail (params; Enter there re-computes its " - "verdict) · q/Esc/m back", - indent="", - ): - lines.append(ScreenLine(wrapped, "muted")) - return lines - - -def _default_display(help_: ParamHelp) -> str: - """The param's default as the ADD FORM renders it -- `.value` for a granularity, - `true/false` for a bool, else the raw default.""" - default = help_.default - if isinstance(default, Granularity): - return default.value - if isinstance(default, bool): - return "true" if default else "false" - if isinstance(default, tuple): - return json.dumps(list(default)) - return str(default) - - -#: Sentinel for "the row does not carry this param" (a row written before its kind -#: grew a field) -- rendered as the kind's own default, named as such. -_DEFAULT_MISS = object() - - -def build_ledger_detail_lines( - entry: LedgerRule, *, verdict: RuleVerdict | None = None -) -> list[ScreenLine]: - """One rule's detail: its lifecycle status, EVERY param rendered through - `describe_params` (the O8 per-field help, single-sourced from the class), the paper - gate's recorded distance again, and -- without a held `verdict` -- the ARMED - re-compute: the warning renders BEFORE any Enter can start the work, exactly the - simulate view's confirm step. PURE.""" - lines: list[ScreenLine] = [ - ScreenLine( - f"keel console -- rules / rule {entry.rule_id} ({entry.kind})", "heading" - ), - ScreenLine( - f"status={entry.status} product={entry.product_id or '?'}", "normal" - ), - _blank(), - ] - try: - params_help = describe_params(entry.kind) - except ValueError: - params_help = {} - for name, help_ in params_help.items(): - value = entry.params.get(name, _DEFAULT_MISS) - shown = ( - "(default)" if value is _DEFAULT_MISS else json.dumps(value, default=str) - ) - head = f" {name} = {shown}" - if value is _DEFAULT_MISS: - head += f" [{_default_display(help_)}]" - lines.append(ScreenLine(head, "normal")) - for wrapped in _wrap(f"{help_.doc} ({help_.type_name})", indent=" "): - lines.append(ScreenLine(wrapped, "muted")) - if not params_help: - lines.append( - ScreenLine(f" (params: {json.dumps(entry.params, default=str)})", "normal") - ) - lines.append(_blank()) - if entry.status == "paper": - lines.append( - ScreenLine("the recorded paper-gate distance (kind-wide):", "heading") - ) - for reason in entry.paper_gate_lines: - for wrapped in _wrap(f"- {reason}", indent=" "): - lines.append(ScreenLine(wrapped, "warn")) - lines.append(_blank()) - if verdict is not None: - lines.append( - ScreenLine("this session's re-computed verdict (the machine's own):", "heading") - ) - for reason in verdict.reason_lines: - for wrapped in _wrap(f"- {reason}", indent=" "): - lines.append(ScreenLine(wrapped, "warn")) - if verdict.stats_line is not None: - for wrapped in _wrap(verdict.stats_line, indent=" "): - lines.append(ScreenLine(wrapped, "muted")) - else: - lines.append( - ScreenLine("re-compute this rule's verdict -- ARMED, nothing has run", "heading") - ) - for wrapped in _wrap( - "Enter runs the FULL-WINDOW backtest over the repo's cached candles and " - "judges it through the promotion gate -- the same numbers `keel rules " - f"backtest {entry.rule_id}` prints and `rules promote` refuses on.", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "normal")) - for wrapped in _wrap( - "WARNING: this is real work -- on long series it can take MINUTES (one " - "rsi_meanrev rule over 5y of hourly candles measured ~7.5 minutes); the " - "screen freezes while it runs, exactly like simulate/fetch, and the result " - "is held here when it ends. Esc or q returns without running anything.", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "warn")) - lines.append(_blank()) - lines.append( - ScreenLine( - "Enter re-computes the verdict · q/Esc/m back to the ledger", "muted" - ) - ) - return lines - - -# -- simulate (O11.1): the ARMED view, the run, the results ---------------------------------------- - - -@dataclass(frozen=True) -class SimulatePlan: - """What the simulate pass WILL do, shown BEFORE any of it runs (the confirm step): - the deployment's db, the CLI's own defaults, the products the allowlist resolves to, - and the report path `run_simulation` will write.""" - - db_path: str - years: int - monthly_contribution: Decimal - products: tuple[str, ...] - report_path: Path - - -def simulate_plan( - config: Config, - db_path: str, - *, - now_ts: int, - years: int = 5, - contribution: Decimal = Decimal("500"), -) -> SimulatePlan: - """The plan for a console simulate run -- `keel simulate`'s own defaults (5y, $500/month, - the allowlist's products in the settlement currency) and the report path the service - will write (`default_report_path`, the same directory the Research readers list).""" - from keel.commands._products import _default_sim_products - - return SimulatePlan( - db_path=db_path, - years=years, - monthly_contribution=contribution, - products=tuple(_default_sim_products(config)), - report_path=default_report_path(now_ts), - ) - - -def build_simulate_armed_lines(plan: SimulatePlan) -> list[ScreenLine]: - """The simulate view's ARMED state: NOTHING has run, and the screen says exactly what - Enter will do -- the target report path FIRST (O11.1's confirm step), the products, and - the fetch/write the pass makes. PURE.""" - lines = [ - ScreenLine("keel console -- rules / simulate", "heading"), - _blank(), - ScreenLine("ARMED -- nothing has run yet.", "normal"), - _blank(), - ScreenLine( - f"Enter runs the simulate pass over THIS deployment ({plan.db_path}):", - "normal", - ), - ] - for wrapped in _wrap( - f"products {', '.join(plan.products)} · {plan.years}y window · " - f"{plan.monthly_contribution}/month contribution", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "normal")) - for wrapped in _wrap( - "it FETCHES candle history for those products when the cache does not already " - "cover the window (the same reads `keel fetch` makes) -- read-only w.r.t. money: " - "no orders, no rails.", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "normal")) - lines.append(ScreenLine("the report will be WRITTEN at:", "normal")) - for wrapped in _wrap(str(plan.report_path), indent=" "): - lines.append(ScreenLine(wrapped, "normal")) - for wrapped in _wrap( - "the run can take seconds to minutes; the screen freezes while it runs (exactly " - "like the CLI) and holds the result here when it ends. Enter again re-runs.", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append(ScreenLine("Press q or Esc to return to the Rules menu.", "muted")) - return lines - - -def run_simulate( - repo: Repository, - config: Config, - plan: SimulatePlan, - *, - now_ts: int, - build_client: Callable[[], Any] | None, - run_fn: Callable[..., SimulationOutcome] = run_simulation, - progress: list[str] | None = None, -) -> SimulationOutcome: - """THE simulate run, dispatched: `run_simulation` itself over the active profile's - repo/config with the CLI's own defaults, its progress lines collected into `progress` - (the CLI streamed them; the console shows them at the head of the results). `build_client` - is the CLI's own seam (`None` = `--no-fetch`); `run_fn` is injectable so the loop's - confirm-gate tests can spy the call without computing anything.""" - sink = progress.append if progress is not None else (lambda _message: None) - return run_fn( - repo, - config, - build_client, - db_path=plan.db_path, - products=list(plan.products), - years=plan.years, - monthly_contribution=plan.monthly_contribution, - now_ts=now_ts, - # The path the ARMED screen pre-showed is the path the run writes: pinned here so - # a run crossing UTC midnight cannot write a different filename than the one the - # operator confirmed (`default_report_path(now_ts)` inside the service would - # otherwise re-derive the date from the run-time `now_ts`). - out_path=plan.report_path, - echo=sink, - ) - - -def _verdict_style(status: str) -> str: - """The verdict's own severity: GO-LIVE is the pass, TRAIN-MORE is the deliberate - not-yet (a warn, not an alert -- the report's own vocabulary), anything else is - fail-loud.""" - if status == "GO-LIVE": - return "ok" - if status == "TRAIN-MORE": - return "warn" - return "alert" - - -def build_simulate_result_lines( - outcome: SimulationOutcome, progress: tuple[str, ...] = () -) -> list[ScreenLine]: - """The simulate RESULTS: the service's own verdict headline and failing gates, the - progress the CLI would have streamed, and then the report it wrote -- VERBATIM, the - net-negative honesty included (the report states its own caveats; this screen renders - them, it does not summarize them). Long report lines WRAP to the 80-column budget - rather than clipping. PURE.""" - lines = [ - ScreenLine("keel console -- rules / simulate results", "heading"), - ScreenLine(f"verdict: {outcome.verdict_status}", _verdict_style(outcome.verdict_status)), - ] - # The paths wrap rather than clip: the tail of a report path is exactly the part that - # identifies the file (and this screen's own footer carries them pinned, unwrapped, - # on their own rows). - for wrapped in _wrap(f"report: {outcome.report_path}", indent=""): - lines.append(ScreenLine(wrapped, "muted")) - if outcome.verdict_reasons: - lines.append(ScreenLine("failing gates:", "normal")) - for reason in outcome.verdict_reasons: - for wrapped in _wrap(f"- {reason}", indent=" "): - lines.append(ScreenLine(wrapped, "warn")) - if outcome.artifact_path is not None: - for wrapped in _wrap(f"artifact: {outcome.artifact_path}", indent=""): - lines.append(ScreenLine(wrapped, "muted")) - if progress: - lines.append(_blank()) - lines.append(ScreenLine("run progress (what the CLI streamed):", "muted")) - for line in progress: - for wrapped in _wrap(line, indent=" "): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append(ScreenLine("the report written by the run (verbatim):", "heading")) - for line in outcome.report_markdown.splitlines(): - if not line.strip(): - lines.append(_blank()) - continue - for wrapped in textwrap.wrap(line, width=_WIDTH) or [""]: - lines.append(ScreenLine(wrapped, "normal")) - lines.append(_blank()) - for wrapped in _wrap( - "Enter re-runs the pass · q/Esc/m back to the Rules menu (the report stays on " - "disk -- reachable from Research / promotion reports)", - indent="", - ): - lines.append(ScreenLine(wrapped, "muted")) - return lines - - -def simulate_verdict_footer(outcome: SimulationOutcome) -> list[ScreenLine]: - """The simulate results' PINNED footer: the verdict and the report path, reserved off - the window before the body is sliced (`compliance_console.pinned_frame`) so no scroll - offset can hide what the run concluded or where it was written. PURE -- the verdict - and the path each get their own row (the path wraps if it must), so neither - load-bearing fact can lose its tail to the 80-column clip.""" - footer = [ - ScreenLine( - f"verdict: {outcome.verdict_status}", _verdict_style(outcome.verdict_status) - ) - ] - footer.extend( - ScreenLine(wrapped, "muted") - for wrapped in _wrap(f"report: {outcome.report_path}", indent="") - ) - return footer - - -# -- the forms (O11.3 add / O11.4 retry + restore) --------------------------------------------- - - -def _service_result(out: list[str], err: list[str]) -> str: - """A form's result from the service's collected lines: its stdout lines on success, - its already-`Error:`-prefixed stderr lines on a refusal -- the service's own words, - never a re-wording.""" - if err: - return "\n".join(err) - return "\n".join(out) - - -def _ask(prompt_fn: PromptFn, question: str) -> str: - return prompt_fn(question).strip() - - -def run_add_form( - repo: Repository, config: Config, prompt_fn: PromptFn, now_ts: int -) -> str: - """The `rules add` flow in-console: kind (from `RULE_REGISTRY`), product, then ONE - PROMPT PER PARAM with its O8 help rendered from `describe_params` -- doc, type and - default; an empty answer keeps the kind's default exactly as omitting the key from - `--params` does. Dispatches to `add_rule_row` (the same service the CLI command - calls), so the row lands as `candidate` with the same validations and the same - messages.""" - kinds = sorted(agent.RULE_REGISTRY) - kind = _ask(prompt_fn, f"rule kind -- one of: {', '.join(kinds)} (empty cancels)") - if not kind: - return "add cancelled -- nothing written" - try: - params_help = describe_params(kind) - except ValueError as exc: - return f"Error: {exc}" - product = _ask(prompt_fn, "product id (e.g. BTC-USD) -- empty cancels") - if not product: - return "add cancelled -- nothing written" - - supplied: dict[str, Any] = {} - for name, help_ in params_help.items(): - choices = f", one of {list(help_.choices)}" if help_.choices else "" - quoted = " -- a QUOTED value is correct here" if help_.quotable else "" - answer = prompt_fn( - f"{name} ({help_.type_name}, default {_default_display(help_)}{choices})" - f"{quoted} -- {help_.doc}\n" - f" [empty keeps the default]" - ).strip() - if not answer: - continue - try: - supplied[name] = json.loads(answer) - except json.JSONDecodeError as exc: - return ( - f"Error: {name}: not valid JSON ({exc}) -- values are typed as the CLI's " - "--params JSON types them (numbers unquoted, Decimal params quoted)" - ) - - params_json = json.dumps(supplied) if supplied else None - out: list[str] = [] - err: list[str] = [] - try: - outcome = add_rule_row( - repo, - config, - kind=kind, - product=product, - params_json=params_json, - now_ts=now_ts, - echo=out.append, - echo_err=err.append, - ) - except RulesUsageError as exc: - return f"Error: {exc}" - except RulesRefused: - return _service_result(out, err) - del outcome - return _service_result(out, err) - - -def run_retry_form( - repo: Repository, - config: Config, - prompt_fn: PromptFn, - now_ts: int, - *, - typed_force_fn: Callable[[int, str, str, str], bool] | None = None, -) -> str: - """The retry flow (O11.4): re-run the backtest (always -- its line is the result's - first content), then re-attempt the promotion through `attempt_promotion` -- only on - an explicit y/N (the O3 promote confirmation), with the PBO session named or honestly - absent (the gate's own NOT RUN reason renders when it is). `--force` is offered after - a declined-or-refused promote and runs ONLY behind `typed_force_fn` -- the console's - typed gate (`clis_typed_promote_force_gate`, the default: console-added ceremony over - the CLI's bare `--force` flag, quoting its warning over the shared - typed-confirmation gate), never pre-filled, failing closed: a wrong phrase writes - nothing.""" - if typed_force_fn is None: - typed_force_fn = clis_typed_promote_force_gate - del now_ts # the services stamp their own times - rule_id_raw = _ask(prompt_fn, "rule id to retry (see the ledger) -- empty cancels") - if not rule_id_raw: - return "retry cancelled -- nothing done" - try: - rule_id = int(rule_id_raw) - except ValueError: - return f"Error: rule id must be a number, got {rule_id_raw!r}" - - out: list[str] = [] - err: list[str] = [] - try: - _outcome, _stats = run_rule_backtest( - repo, config, rule_id, echo=out.append, echo_err=err.append - ) - except RulesRefused: - return _service_result(out, err) - - rows = {row["id"]: row for row in repo.get_rules()} - row = rows.get(rule_id) - - answer = _ask( - prompt_fn, "attempt promotion through the gate now? (y/N)" - ).lower() - promoted = False - if answer.startswith("y"): - session = _ask( - prompt_fn, - "pbo-session label for the G4 check (empty = NOT RUN -- the gate will " - "refuse; `keel trials list` shows the labels)", - ) - try: - outcome = attempt_promotion( - repo, - config, - rule_id, - pbo_session=session or None, - echo=out.append, - echo_err=err.append, - ) - except RulesRefused: - return _service_result(out, err) - promoted = row is not None and outcome.new_status != row["status"] - - if not promoted: - force = _ask( - prompt_fn, - "force-promote past the gate? the documented bypass (--force) -- it needs " - "the TYPED phrase (y/N)", - ).lower() - if force.startswith("y"): - target = None - if row is not None: - from keel.strategy import promotion as promotion_mod - - target = promotion_mod.next_status(row["status"]) - if target is None or row is None: - out.append( - f"rule {rule_id}: nothing to promote (already at a terminal status)" - ) - elif not typed_force_fn(rule_id, row["kind"], row["status"], target): - out.append( - "retry cancelled -- the typed confirmation was not given; " - "nothing promoted" - ) - else: - try: - attempt_promotion( - repo, - config, - rule_id, - force=True, - echo=out.append, - echo_err=err.append, - ) - except RulesRefused: - return _service_result(out, err) - - return _service_result(out, err) - - -def clis_typed_promote_force_gate( - rule_id: int, kind: str, from_status: str, target: str -) -> bool: - """The typed gate for the console's `--force` (O3): the CLI's OWN - `_require_interactive_confirmation`, with action wording that quotes the CLI's force - warning verbatim in substance -- pinned by test so the two front-ends can never drift - into two ceremonies for one bypass. Fails CLOSED: a wrong phrase, a Ctrl-C, any - exception answers False and the status does not move.""" - from keel.commands._common import _require_interactive_confirmation - - try: - _require_interactive_confirmation( - f"force-promote rule {rule_id} ({kind}): {from_status} -> {target}, " - "BYPASSING the backtest/promotion gate", - "This is the CLI's `rules promote --force` bypass, for a deliberate, un-gated " - "paper-forward start (e.g. a low-frequency trend-follower whose backtest can " - "never reach the min_trades floor). It writes a WARNING-level audit record -- " - "confirm this is intentional and monitor accordingly.", - ) - return True - except Exception: - return False - - -def _lifecycle_form( - repo: Repository, - prompt_fn: PromptFn, - verb: str, - service: Callable[..., Any], -) -> str: - """The shared shape of the one-rule-id lifecycle forms (enable/disable/demote): ask - the id, dispatch to the extracted service with collecting sinks, render the service's - own lines -- its refusal lines are already `Error:`-prefixed.""" - raw = _ask(prompt_fn, "rule id (see the ledger) -- empty cancels") - if not raw: - return f"{verb} cancelled -- nothing changed" - try: - rule_id = int(raw) - except ValueError: - return f"Error: rule id must be a number, got {raw!r}" - out: list[str] = [] - err: list[str] = [] - try: - service(repo, rule_id, echo=out.append, echo_err=err.append) - except RulesRefused: - pass - return _service_result(out, err) - - -def run_enable_form( - repo: Repository, config: Config, prompt_fn: PromptFn, now_ts: int -) -> str: - """`rules enable` as a form -- the DOCUMENTED restore path for a disabled rule, back - at `candidate` (never at the status it held: `disable` records no prior status).""" - del config, now_ts - return _lifecycle_form(repo, prompt_fn, "enable", apply_rule_enable) - - -def run_disable_form( - repo: Repository, config: Config, prompt_fn: PromptFn, now_ts: int -) -> str: - del config, now_ts - return _lifecycle_form(repo, prompt_fn, "disable", apply_rule_disable) - - -def run_demote_form( - repo: Repository, config: Config, prompt_fn: PromptFn, now_ts: int -) -> str: - del config, now_ts - return _lifecycle_form(repo, prompt_fn, "demote", apply_rule_demote) diff --git a/keel/commands/trading_console.py b/keel/commands/trading_console.py deleted file mode 100644 index 5976daab..00000000 --- a/keel/commands/trading_console.py +++ /dev/null @@ -1,763 +0,0 @@ -"""The Trading menu (issue #391 C5; PRD §3's Trading branch, O3's typed contracts). - -Everything here is DISPATCH, never behavior (PRD O2, the discipline `compliance_console` -and `strategy_console` keep): the agent cycle is `agent.run_once` with the CLI's own -order-confirmation gate (`keel.commands.confirm._interactive_confirm` -- the SAME function -`keel agent` hands the executor, so an in-console cycle IS the agent pipeline and there is -no TUI-originated order path), the monitor poll is `keel.commands.monitor.monitor_cycle`, -and every state mutation goes through `keel.commands.trading`'s services with the CLI's OWN -typed gates rendered in-console (the curses suspend/restore dance) -- never piped, never -pre-filled, never bypassed. The gate wording and the confirmation lines are IMPORTED from -`trading.py` (their one home, the C1 style), so the two front-ends cannot drift. - -The asymmetries are mirrored honestly (O3): - -* `kill` ENGAGES a halt -- the CLI is one command with NO confirmation, and the console - adds no ceremony of its own: selecting the entry dispatches `engage_kill_switch` and - toasts the CLI's own line. -* `resume`/`resume-entries`/`reset-hwm`/`record-flow` RELEASE halts -- each keeps the - CLI's typed `_require_interactive_confirmation` gate, verbatim, failing closed. -* `autonomy`'s ON direction is the CLI's own arm gate (extracted to - `keel.commands.autonomy.autonomy_on_gate`); OFF only ever reduces capability and stays - ungated, exactly as `keel autonomy off` does. - -The session honesty on the cycle's confirm step is DISPLAY of B1 semantics, never new -logic: the plan reads the RECORDED session (`agent.latest_recorded_session`) and says, -for a session-bound venue whose record says CLOSED, that the cycle will skip with -`market_closed` (the clock-unavailable case gets its own line); running it anyway renders -the skip's logged reason verbatim through `render_loop_result`. - -All the pure builders here are directly unit-testable without curses, mirroring the -`build_*`/`run_*` split of the other console modules. -""" - -from __future__ import annotations - -import textwrap -from collections.abc import Callable -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any - -from keel import agent -from keel.commands.monitor import MonitorCycle, monitor_cycle -from keel.commands.trading import ( - KILL_ENGAGED_LINE, - RECORD_FLOW_DETAIL, - RESET_HWM_ACTION, - RESET_HWM_DETAIL, - RESET_HWM_DONE_LINE, - RESUME_ACTION, - RESUME_DETAIL, - RESUME_DISENGAGED_LINE, - RESUME_ENTRIES_ACTION, - RESUME_ENTRIES_CLEARED_LINE, - RESUME_ENTRIES_DETAIL, - clear_consecutive_loss_halt, - disengage_kill_switch, - engage_kill_switch, - parse_flow_amount, - record_flow, - record_flow_action, - render_blocked_entries, - render_flow_recorded, - render_loop_result, - reset_high_water_mark, -) -from keel.commands.tui import CTRL_C_DISCLOSURE, ScreenLine, _blank, _message_style -from keel.types import Granularity - -if TYPE_CHECKING: - from keel.config import Config - from keel.data.repository import Repository - -#: One terminal prompt: injected so every form is unit-testable with a scripted fake, and -#: so the live loop can run the whole form through the curses suspend/restore dance. -PromptFn = Callable[[str], str] - -#: The width every console line must fit (`_paint` clips at the window width; 80-column -#: terminals are this dashboard's stated target) -- the same budget the other console -#: modules keep, applied by wrapping rather than clipping. -_WIDTH = 78 - - -def _wrap(text: str, *, indent: str = " ", width: int = _WIDTH) -> list[str]: - """Wrap `text` on spaces to the 80-column budget, continuation lines carrying `indent`. - PURE -- the same rule every console module keeps, over `textwrap` so a fact can never - lose its tail to `_paint`'s clip.""" - return textwrap.wrap(text, width=width, initial_indent=indent, subsequent_indent=indent) or [ - indent - ] - - -# -- the sub-menu model (PRD §3's Trading branch) -------------------------------------------------- - - -@dataclass(frozen=True) -class TradingEntry: - """One entry of the Trading sub-menu. `kind` is the closed dispatch vocabulary: - `"armed"` opens an ARMED view (Enter is the confirm step -- the agent cycle and the - monitor poll), `"form"` runs a service at the terminal through the suspend/restore - dance (a typed gate, a prompt, or both), and `"action"` dispatches immediately (kill - -- per its own CLI contract, no ceremony). `typed` marks the entries whose write - carries a typed confirmation (the CLI's own `_HALT_COMMANDS`, plus autonomy's ON).""" - - ordinal: int - label: str - description: str - kind: str # "armed" | "form" | "action" - target: str - typed: bool = False - - -#: PRD §3's Trading branch in tree order. The descriptions are O8's plain-English "what -#: will this do" in miniature, naming the dispatch honestly (which service, which gate). -TRADING_MENU: tuple[TradingEntry, ...] = ( - TradingEntry( - ordinal=1, - label="agent cycle (single)", - description=( - "ONE agent cycle on the ACTIVE profile (Enter confirms first: it can place " - "orders); blocks like simulate/fetch and holds the cycle's own result lines" - ), - kind="armed", - target="cycle", - ), - TradingEntry( - ordinal=2, - label="monitor poll (single)", - description=( - "ONE poll: record the venue session, skip while a session-bound venue is " - "closed, else fetch fresh candles for every allowlisted product (Enter " - "confirms first)" - ), - kind="armed", - target="monitor", - ), - TradingEntry( - ordinal=3, - label="autonomy", - description=( - "whether the agent places orders without asking first -- ON is typed (the " - "CLI's own gate); OFF only ever reduces capability and is ungated" - ), - kind="form", - target="autonomy", - typed=True, - ), - TradingEntry( - ordinal=4, - label="record-flow", - description=( - "declare a deposit/withdrawal so rail 11 does not mistake it for P&L -- " - "typed, with the CLI's own amount validation" - ), - kind="form", - target="record-flow", - typed=True, - ), - TradingEntry( - ordinal=5, - label="reset-hwm", - description=( - "clear rail 11's high-water mark so the next cycle re-seeds it -- typed" - ), - kind="form", - target="reset-hwm", - typed=True, - ), - TradingEntry( - ordinal=6, - label="resume-entries", - description=( - "clear an armed consecutive-loss halt (rail 16), re-permitting new entries " - "-- the ONLY early release, typed" - ), - kind="form", - target="resume-entries", - typed=True, - ), - TradingEntry( - ordinal=7, - label="kill", - description=( - "engage the kill-switch, halting all trading immediately -- one key, no " - "confirmation, per the CLI's own contract (halting is the safe direction)" - ), - kind="action", - target="kill", - ), - TradingEntry( - ordinal=8, - label="resume", - description=( - "disengage the kill-switch; trading resumes on the next cycle -- typed" - ), - kind="form", - target="resume", - typed=True, - ), -) - - -def trading_entry(ordinal: int) -> TradingEntry | None: - """The entry selected by its displayed ordinal, or `None` -- the one-lookup rule - every console menu keeps, so the rendered ordinals and the shortcut keys cannot - drift.""" - for entry in TRADING_MENU: - if entry.ordinal == ordinal: - return entry - return None - - -#: This module's screens' contextual help (O8, issue #394 C7) -- the rows the `?` -#: overlay renders, keyed by the live loop's mode names. Plain `(subject, description)` -#: pairs so the text stays HERE with the module that owns the screens; -#: `keel.commands.help_console` is the registry and renderer. Every TYPED action's row -#: states the O3 contract explicitly: the prompt cannot be pre-filled. -CONTEXT_HELP: dict[str, tuple[tuple[str, str], ...]] = { - "trading": ( - ( - "agent cycle / monitor poll", - "ONE cycle or ONE poll on the ACTIVE deployment -- both open ARMED with the " - "plan shown first, and Enter is the confirm step (a cycle can place orders; " - "in confirm mode the CLI's own order gate runs at the terminal)", - ), - ( - "autonomy", - "arming lets the agent place orders unattended -- the ON direction asks the " - "CLI's own arm gate at the terminal; OFF only ever reduces capability", - ), - ( - "kill / resume (the kill switch)", - "kill ENGAGES the halt immediately, one command with no ceremony -- that IS " - "its CLI contract; resume RELEASES it and is TYPED: you type the release " - "phrase yourself at the terminal, and the prompt cannot be pre-filled, " - "piped or bypassed", - ), - ( - "resume-entries, reset-hwm, record-flow", - # [review #406] the typed disclosure LEADS the row: all three ARE typed - # gates in the CLI (`_require_interactive_confirmation`), so scoping it to - # resume-entries' parenthetical read as though the other two could be - # pre-filled. - "the other halt-releasers and bookkeeping, all three TYPED at the terminal " - "-- the prompt cannot be pre-filled, piped or bypassed: resume-entries " - "clears the consecutive-loss halt, reset-hwm resets the drawdown reference, " - "record-flow records a deposit or withdrawal against the equity base", - ), - ), - "trading-cycle": ( - ( - "the ARMED view", - "the plan names the profile, its paper/confirm semantics, the autonomy " - "state and the session honesty line -- Enter runs the cycle through the " - "agent pipeline itself (there is no TUI-originated order path), blocking " - "like a fetch, and the result lines are held here", - ), - ), - "trading-monitor": ( - ( - "the ARMED view", - "ONE monitor poll over the ACTIVE profile's products: Enter runs the same " - "monitor cycle the CLI runs, and its result lines (or a skip's logged " - "reason, verbatim) are held here", - ), - ), -} - - -def build_trading_menu_lines(*, cursor: int = 0, message: str | None = None) -> list[ScreenLine]: - """The Trading sub-menu screen: every entry with its description wrapped to the - 80-column budget, the typed entries marked, exactly one cursor-marked row, and the - last action's confirmation lines as the toast. PURE.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- trading", "heading"), - _blank(), - ] - cursor = max(0, min(cursor, len(TRADING_MENU) - 1)) - for index, entry in enumerate(TRADING_MENU): - marker = ">" if index == cursor else " " - head = f"{marker} {entry.ordinal:>2} {entry.label}" - if entry.typed: - head += " [typed]" - style = "heading" if index == cursor else "normal" - lines.append(ScreenLine(head, style)) - for wrapped in _wrap(f"{entry.description}.", indent=" "): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append(ScreenLine("up/k down/j move · Enter/Space select · 1-8 jump", "muted")) - lines.append(ScreenLine("q/Esc/m to the console menu", "muted")) - if message is not None: - lines.append(_blank()) - for part in message.splitlines(): - lines.append(ScreenLine(part, _message_style(part))) - return lines - - -# -- the agent cycle: the ARMED confirm step, the run, the held result ----------------------------- - - -@dataclass(frozen=True) -class CyclePlan: - """What one agent cycle WILL do, shown BEFORE any of it runs (the confirm step): the - deployment it runs against, the mode's own semantics (paper simulates; confirm is - live money), the autonomy state (who gets asked), and the SESSION HONESTY line - sourced from the recorded session state -- never a TUI-side calendar.""" - - db_path: str - profile_label: str | None - mode: str - autonomous: bool - session_line: str | None - - -def session_honesty_line( - session_bound: bool, recorded: agent.RecordedSession | None -) -> str | None: - """What the RECORDED session says a cycle will do on a session-bound venue, as one - line -- or `None` when there is nothing to disclose (a 24/7 venue, or a recorded OPEN - session). PURE display of B1 semantics (`agent.run_once`'s own gate): CLOSED means - the cycle will skip with `market_closed`; a clock that is absent, stale or unreadable - means the cycle reads it fresh and skips with `market_clock_unavailable` if it cannot - (fail-closed). No new session logic is born here -- the cycle itself may find a - different answer when it runs, and its own logged skip reason is what renders then. - """ - if not session_bound: - return None - if recorded is None or not recorded.fresh: - return ( - "no fresh recorded clock -- the cycle reads the venue clock fresh and, if it " - "cannot, skips with market_clock_unavailable (fail-closed)" - ) - if recorded.state == "closed": - return "the recorded venue session is CLOSED -- the cycle will skip with market_closed" - if recorded.state != "open": - return ( - f"the recorded clock state is {recorded.state!r} -- the cycle will skip with " - "market_clock_unavailable (fail-closed)" - ) - return None - - -def cycle_plan( - repo: Repository, - config: Config, - db_path: str, - now_ts: int, - *, - profile_label: str | None, - session_bound: bool, - recorded: agent.RecordedSession | None, -) -> CyclePlan: - """The plan for a console cycle run: the deployment's db, its profile label, the - mode and the profile's CURRENT autonomy state (read fresh, never cached -- the same - freshness `agent._effective_mode` keeps), and the session honesty line over the - recorded session.""" - return CyclePlan( - db_path=db_path, - profile_label=profile_label, - mode=config.auto_trade.mode, - autonomous=repo.get_profile().is_autonomous(now_ts), - session_line=session_honesty_line(session_bound, recorded), - ) - - -def build_cycle_armed_lines(plan: CyclePlan) -> list[ScreenLine]: - """The cycle view's ARMED state: NOTHING has run, and the screen says exactly what - Enter will do -- the confirm step. The ACTIVE profile and its mode's semantics lead - (on a live-mode deployment the REAL MONEY line carries the alert style, unmistakable), - the autonomy state says who gets asked, and the session honesty line (when there is - one) says what the recorded session already knows will happen. PURE.""" - label = plan.profile_label or "the active deployment" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- trading / agent cycle", "heading"), - _blank(), - ScreenLine("ARMED -- nothing has run yet.", "normal"), - _blank(), - ScreenLine(f"Enter runs ONE agent cycle on THIS deployment ({plan.db_path}):", "normal"), - ] - for wrapped in _wrap(f"profile: {label} · mode={plan.mode}", indent=" "): - lines.append(ScreenLine(wrapped, "normal")) - if plan.mode == "paper": - for wrapped in _wrap( - "paper mode: fills are SIMULATED -- no order ever reaches the venue; the " - "hard rails and the paper account run exactly as `keel agent` runs them.", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "ok")) - else: - # Anything that is not `paper` reaches the executor's confirm path: REAL MONEY, - # in the alert style the banner keeps for the live pair. - for wrapped in _wrap( - "mode=confirm: REAL MONEY -- live orders on this profile. Each order asks " - "at the terminal first (the CLI's own confirm gate), every hard rail runs.", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "alert")) - if plan.autonomous: - for wrapped in _wrap( - "autonomy is ON: orders place with NO further prompt -- who is asked " - "changes, never what is allowed.", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "alert")) - if plan.session_line is not None: - for wrapped in _wrap(f"session: {plan.session_line}", indent=" "): - lines.append(ScreenLine(wrapped, "warn")) - for wrapped in _wrap( - "the cycle can take seconds to minutes (it polls the venue); the screen freezes " - "while it runs, exactly like the CLI, and the cycle's own result lines are held " - "here when it ends. Enter again re-runs.", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "muted")) - for wrapped in _wrap(CTRL_C_DISCLOSURE, indent=" "): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append(ScreenLine("Press q/Esc/m to return to the Trading menu.", "muted")) - return lines - - -def run_agent_cycle( - repo: Repository, - config: Config, - *, - now_ts: int, - build_broker: Callable[[], Any], - run_fn: Callable[..., agent.LoopResult] = agent.run_once, - confirm_fn: Callable[..., bool] | None = None, -) -> agent.LoopResult: - """THE cycle, dispatched: `agent.run_once` itself over the active profile's - repo/config, handing the executor the CLI's own `_interactive_confirm` gate -- the - SAME function `keel agent` passes (`keel.commands.confirm`, one gate, two - front-ends), so the console has no order path of its own and O3's acceptance ("no - TUI-originated order path that bypasses the agent pipeline") holds by construction. - `run_fn`/`confirm_fn` are injectable so the loop's tests can spy without computing.""" - from keel.commands.confirm import _interactive_confirm - - return run_fn( - build_broker(), - repo, - config, - now_ts=now_ts, - confirm_fn=confirm_fn if confirm_fn is not None else _interactive_confirm, - ) - - -def build_cycle_result_lines(result: agent.LoopResult) -> list[ScreenLine]: - """The held cycle result: `render_loop_result`'s exact lines (the shared twin the - CLI prints, a skip's logged reason verbatim) plus the blocked-entry lines the CLI - prints when a rule's gating bar was not confirmed ready. PURE -- the renderer is - `trading.py`'s own; this screen never re-words a cycle.""" - lines = [ - ScreenLine("keel console -- trading / agent cycle result", "heading"), - ] - for line in render_loop_result(result): - for wrapped in _wrap(line, indent=""): - lines.append(ScreenLine(wrapped, "normal")) - for blocked in render_blocked_entries(result): - for wrapped in _wrap(blocked, indent=""): - lines.append(ScreenLine(wrapped, "warn")) - lines.append(_blank()) - for wrapped in _wrap(CTRL_C_DISCLOSURE, indent=""): - lines.append(ScreenLine(wrapped, "muted")) - lines.append( - ScreenLine("Enter re-runs the cycle · q/Esc/m back to the Trading menu", "muted") - ) - return lines - - -# -- the monitor poll: one poll, ARMED ------------------------------------------------------------- - - -@dataclass(frozen=True) -class MonitorPlan: - """What one monitor poll WILL do, shown before any of it runs: the products and - granularities the config resolves to (exactly what `keel monitor` polls), the - interval the session record trusts, and the session honesty line.""" - - db_path: str - products: tuple[str, ...] - granularities: tuple[Granularity, ...] - interval_sec: float - session_line: str | None - - -def monitor_plan( - config: Config, - db_path: str, - *, - session_bound: bool, - recorded: agent.RecordedSession | None, -) -> MonitorPlan: - """The plan for one console poll -- `keel monitor`'s own derivation (the allowlist's - products in the settlement currency, the config's granularities, the config's - interval), plus the same session honesty line the cycle plan carries (a poll skips - while a session-bound venue is closed).""" - from keel.commands._products import _default_sim_products - - return MonitorPlan( - db_path=db_path, - products=tuple(_default_sim_products(config)), - granularities=tuple(config.market_data.granularities), - interval_sec=float(config.auto_trade.interval_sec), - session_line=session_honesty_line(session_bound, recorded), - ) - - -def build_monitor_armed_lines(plan: MonitorPlan) -> list[ScreenLine]: - """The monitor view's ARMED state: NOTHING has run, and the screen says exactly what - one poll does -- record the venue session, skip while a session-bound venue is - closed, else fetch fresh candles for every product. PURE.""" - lines: list[ScreenLine] = [ - ScreenLine("keel console -- trading / monitor poll", "heading"), - _blank(), - ScreenLine("ARMED -- nothing has run yet.", "normal"), - _blank(), - ScreenLine(f"Enter runs ONE poll against THIS deployment ({plan.db_path}):", "normal"), - ] - products = ", ".join(plan.products) - for wrapped in _wrap(f"products {products}", indent=" "): - lines.append(ScreenLine(wrapped, "normal")) - granularities = ", ".join(g.value for g in plan.granularities) - for wrapped in _wrap(f"granularities {granularities}", indent=" "): - lines.append(ScreenLine(wrapped, "normal")) - # The interval the session record trusts -- operationally relevant (it is what - # `keel monitor`'s own loop would sleep between polls), so the plan renders it. - for wrapped in _wrap( - f"interval {plan.interval_sec:g}s -- the cadence the session record trusts", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "normal")) - for wrapped in _wrap( - "the poll records the venue session, then either skips (a shut venue mints no " - "bars) or fetches fresh candles -- read-only w.r.t. money, exactly `keel " - "monitor`'s own cycle.", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "normal")) - if plan.session_line is not None: - for wrapped in _wrap(f"session: {plan.session_line}", indent=" "): - lines.append(ScreenLine(wrapped, "warn")) - for wrapped in _wrap( - "the screen freezes while it runs, exactly like the CLI, and the cycle's own " - "line is held here when it ends. Enter again re-polls.", - indent=" ", - ): - lines.append(ScreenLine(wrapped, "muted")) - for wrapped in _wrap(CTRL_C_DISCLOSURE, indent=" "): - lines.append(ScreenLine(wrapped, "muted")) - lines.append(_blank()) - lines.append(ScreenLine("Press q/Esc/m to return to the Trading menu.", "muted")) - return lines - - -def run_monitor_poll( - repo: Repository, - config: Config, - *, - now_ts: int, - build_broker: Callable[[], Any], - cycle_fn: Callable[..., MonitorCycle] = monitor_cycle, -) -> MonitorCycle: - """THE poll, dispatched: `monitor_cycle` itself (the unit `keel monitor`'s loop - repeats) over the active profile's broker/repo/config, with the CLI's own product - and granularity derivation. `cycle_fn` is injectable so the loop's tests can spy.""" - from keel.commands._products import _default_sim_products - - return cycle_fn( - build_broker(), - repo, - config, - _default_sim_products(config), - list(config.market_data.granularities), - now_ts, - float(config.auto_trade.interval_sec), - ) - - -def build_monitor_result_lines(cycle: MonitorCycle) -> list[ScreenLine]: - """The held poll result: the cycle's OWN line, verbatim -- the exact line the CLI - prints for that cycle, skip line included. PURE.""" - lines = [ - ScreenLine("keel console -- trading / monitor poll result", "heading"), - ] - for wrapped in _wrap(cycle.line, indent=""): - lines.append(ScreenLine(wrapped, "normal")) - lines.append(_blank()) - for wrapped in _wrap(CTRL_C_DISCLOSURE, indent=""): - lines.append(ScreenLine(wrapped, "muted")) - lines.append( - ScreenLine("Enter re-polls · q/Esc/m back to the Trading menu", "muted") - ) - return lines - - -# -- the typed gates: the CLI's own, verbatim, failing closed -------------------------------------- - - -def _clis_typed_gate(action: str, detail: str) -> bool: - """The shared shape of every Trading-menu typed gate (O3): the CLI's OWN - `_require_interactive_confirmation` with the CLI's OWN action/detail wording -- - imported from `trading.py`/`autonomy.py`, their one home, so the console and the CLI - can never drift into two ceremonies for one release. The console wraps it in the - curses suspend/restore dance so the prompt renders in-console; the gate itself is - untouched -- never pre-filled, never piped. Fails CLOSED: a wrong phrase, a Ctrl-C, - any exception answers False and the halt stays.""" - from keel.commands._common import _require_interactive_confirmation - - try: - _require_interactive_confirmation(action, detail) - return True - except Exception: - return False - - -def clis_typed_resume_gate() -> bool: - """`keel resume`'s typed gate, called verbatim.""" - return _clis_typed_gate(RESUME_ACTION, RESUME_DETAIL) - - -def clis_typed_resume_entries_gate() -> bool: - """`keel resume-entries`'s typed gate, called verbatim.""" - return _clis_typed_gate(RESUME_ENTRIES_ACTION, RESUME_ENTRIES_DETAIL) - - -def clis_typed_reset_hwm_gate() -> bool: - """`keel reset-hwm`'s typed gate, called verbatim.""" - return _clis_typed_gate(RESET_HWM_ACTION, RESET_HWM_DETAIL) - - -def clis_typed_record_flow_gate(amount: str) -> bool: - """`keel record-flow`'s typed gate, called verbatim -- the action phrase names the - RAW amount (sign included), because the exact rebase is what is being confirmed.""" - return _clis_typed_gate(record_flow_action(amount), RECORD_FLOW_DETAIL) - - -def clis_autonomy_on_gate(config: Config) -> bool: - """`keel autonomy on`'s OWN arm gate (`autonomy.autonomy_on_gate`, extracted from the - CLI body -- its one home), fail-closed. The console arms with no expiry, the CLI's - own default, so the gate's window reads "until you turn it off" exactly as the CLI's - does.""" - import time - - from keel.commands.autonomy import autonomy_on_gate - - try: - autonomy_on_gate(config, None, int(time.time())) - return True - except Exception: - return False - - -# -- kill: one key, no ceremony (the CLI's own contract) ------------------------------------------- - - -def run_kill(repo: Repository) -> str: - """`keel kill` as a console action: ENGAGE the halt immediately -- the CLI's own - contract is one command with no confirmation (halting is the safe direction), and no - ceremony is added or removed here. Returns the CLI's own line, verbatim.""" - engage_kill_switch(repo) - return KILL_ENGAGED_LINE - - -# -- the forms ------------------------------------------------------------------------------------- - - -def run_resume_form(repo: Repository, *, gate_fn: Callable[[], bool] | None = None) -> str: - """`keel resume` as a form: the CLI's own typed gate, then `disengage_kill_switch` - and the CLI's own line. A declined gate means not a single state row is written.""" - if gate_fn is None: - gate_fn = clis_typed_resume_gate - if not gate_fn(): - return "resume cancelled -- typed confirmation not given; the halt stays engaged" - disengage_kill_switch(repo) - return RESUME_DISENGAGED_LINE - - -def run_resume_entries_form( - repo: Repository, *, gate_fn: Callable[[], bool] | None = None -) -> str: - """`keel resume-entries` as a form: the CLI's own typed gate, then - `clear_consecutive_loss_halt` and the CLI's own line. A declined gate writes - nothing -- rail 16's halt and the loss counter both stay exactly as they were.""" - if gate_fn is None: - gate_fn = clis_typed_resume_entries_gate - if not gate_fn(): - return "resume-entries cancelled -- typed confirmation not given; the halt stays armed" - clear_consecutive_loss_halt(repo) - return RESUME_ENTRIES_CLEARED_LINE - - -def run_reset_hwm_form(repo: Repository, *, gate_fn: Callable[[], bool] | None = None) -> str: - """`keel reset-hwm` as a form: the CLI's own typed gate, then `reset_high_water_mark` - and the CLI's own line. A declined gate writes nothing.""" - if gate_fn is None: - gate_fn = clis_typed_reset_hwm_gate - if not gate_fn(): - return "reset-hwm cancelled -- typed confirmation not given; the mark is untouched" - reset_high_water_mark(repo) - return RESET_HWM_DONE_LINE - - -def run_record_flow_form( - repo: Repository, - prompt_fn: PromptFn, - *, - gate_fn: Callable[[str], bool] | None = None, -) -> str: - """`keel record-flow` as a form, in the CLI's own ORDER: ask the amount, run the - typed gate (naming the RAW amount the operator typed), THEN validate with the CLI's - own messages, then declare the flow through `record_flow` and render the CLI's own - lines. A declined gate or an invalid amount writes nothing.""" - if gate_fn is None: - gate_fn = clis_typed_record_flow_gate - raw = prompt_fn( - "signed flow in quote currency (positive = deposit, negative = withdrawal) -- " - "empty cancels" - ).strip() - if not raw: - return "record-flow cancelled -- nothing recorded" - if not gate_fn(raw): - return "record-flow cancelled -- typed confirmation not given; nothing recorded" - try: - parsed = parse_flow_amount(raw) - except ValueError as exc: - return f"Error: {exc}" - hwm = record_flow(repo, parsed) - return "\n".join(render_flow_recorded(parsed, hwm)) - - -def run_autonomy_form( - repo: Repository, - config: Config, - prompt_fn: PromptFn, - now_ts: int, - *, - arm_gate: Callable[[], bool] | None = None, -) -> str: - """`keel autonomy` as a form, with the CLI's own asymmetry: `on` RELEASES the - confirm prompt, so it demands the CLI's own typed gate (`clis_autonomy_on_gate` - unless a test injects its own) and arms with NO expiry -- the CLI's own default; - `off` only ever reduces capability and is ungated, exactly as `keel autonomy off` - is. Both directions' result lines are the CLI's own.""" - if arm_gate is None: - arm_gate = lambda: clis_autonomy_on_gate(config) # noqa: E731 -- closes over config - answer = prompt_fn( - "turn autonomy on or off? (on/off) -- empty cancels" - ).strip().lower() - if answer not in ("on", "off"): - return "autonomy cancelled -- nothing changed" - if answer == "off": - repo.set_autonomous(False, now_ts) - from keel.commands.autonomy import AUTONOMY_OFF_LINE - - return AUTONOMY_OFF_LINE - if not arm_gate(): - return "autonomy cancelled -- typed confirmation not given; autonomy stays off" - # The CLI's own call shape (`autonomy_on` passes the expiry explicitly, `None` for - # the never-lapses default -- mirrored, not re-derived). - repo.set_autonomous(True, now_ts, expires_ts=None) - from keel.commands.autonomy import render_autonomy_on - - return "\n".join(render_autonomy_on(None)) diff --git a/keel/commands/tui.py b/keel/commands/tui.py deleted file mode 100644 index a878b9b4..00000000 --- a/keel/commands/tui.py +++ /dev/null @@ -1,5063 +0,0 @@ -"""`keel tui` -- a live, full-screen operator dashboard, with a browsable help overlay and a -handful of gated actions. - -`keel status` (`keel/commands/status.py`) was deliberately built as the substrate for this: its -`gather_status(repo, config, now_ts) -> StatusReport` is a pure, broker-free report, and -`keel status --json` is its forward-compatible shape. `keel tui` is strictly a *view* over that -same report -- it never re-derives Rail 11, freshness, or autonomy logic, only styles it. - -v1 was strictly read-only, like `keel status`. v2 (this module) relaxes that: the live loop can -now toggle autonomy, trigger a data fetch, and refresh on demand -- but with the SAME asymmetric -gating the rest of the CLI already enforces (spec-wide principle): a de-risking action (autonomy -OFF) is immediate and ungated; an action that *adds* capability (autonomy ON) demands a typed -`yes` from a human at a terminal, exactly like `keel autonomy on`. The hard rails are untouched -- -autonomy only changes *who is asked*, never *what is allowed*. `fetch` only ever pulls candle -data (money-safe, no orders). `--once` stays a static, non-interactive snapshot. - -Two layers, mirroring `status.py`'s split: - -- `build_screen`, `build_help_screen`, `_visible_slice`, `_scroll_offset`, `_footer_lines`, - `_freshness_style`, `toggle_autonomy` and `_guarded` are all PURE (or take only injected - fakes), directly unit-testable without curses, a CliRunner, or the network. `render_plain` is - the same, dropping styles. -- `_paint` (curses rendering), `run_once` (single-frame, `--once`/pipes/CI), `run_live` (the - auto-refreshing `curses.wrapper` loop), and `_confirm_arm_autonomy` (the cooked-mode typed-`yes` - prompt) are the thin I/O layer. `curses` is imported lazily inside the functions that need it, - so this module stays importable -- and the pure-function tests stay portable -- even where a - real terminal is absent. - -v3 (this revision) wires the allowlist-admission workflow (`keel/commands/admission.py`, already -fully built and covered by its own tests) into three more overlays, reusing that module's report -builders/renderers VERBATIM rather than reimplementing any of it -- exactly the same discipline -`i` insights already keeps toward `keel/commands/insights.py`: - -- `s` **screen** -- `build_admission_screen_overlay` over `build_screen_report`: the current - allowlist's admission verdicts. OFFLINE, DB reads only. -- `p` **propose** -- `build_propose_overlay` over `build_propose_view`: screens the newest - shortlist file in `config.proposals_dir` (or names why there is none). OFFLINE, DB + local - filesystem reads only. -- `d` **discover** -- `build_discover_overlay` over `build_discover_report`: proposes NEW - candidates from the venue's own product list. This is the one of the three overlays that needs - the network, and it is the THIRD deliberate network exception in this dashboard -- the other - two being the automatic ~30s live-balance refresh (`_refresh_balance`, a real `get_accounts` - call that has been firing on its own cadence since v3) and `f` fetch. Counting only fetch, as - this docstring used to, understates by one and tells an operator the dashboard is offline - between keypresses when it is not. Opening the overlay makes no call at all (it renders an - ARMED, not-yet-run explanation), and only an explicit Enter keypress *inside* the overlay - triggers `_do_discover_report`'s one `_build_broker(config).list_products()` call. The result - is then HELD -- every following poll while the overlay stays open repaints the same cached - result (or error) rather than re-fetching, and closing the overlay discards it, so reopening is - armed but not yet run again. - -None of the three attests, admits, or trades -- they only ever PROPOSE or REPORT, and cannot -themselves put an asset on `allowlist` in `config.yaml`. `attest` -- the human judgment the -whole gate rests on -- stopped being CLI-only when the console's Compliance menu grew the -typed attest form (C3, `keel/commands/compliance_console.py`): it IS invokable from the -console now, from the menu and from the scout browser's `a` step, but never on a keypress -alone -- the form ends in a typed confirmation (type the ASSET CODE back; withdrawals attest -types its own CLI phrase, `yes`), so the safety is the phrase, not CLI-only-ness. - -v4 (this revision) adds `v` **activity** -- `build_activity_overlay` over -`keel.commands.activity.build_activity_feed`, reusing that module's pure grouping/summarising -VERBATIM exactly as `i`/`s`/`p`/`d` reuse theirs. It answers the one question none of the other -five could: *what has keel been DOING*. Every overlay above this one reports STATE, and state is -what looks dead when nothing trades -- a deployment that has run flawlessly for three weeks and -correctly declined every setup shows exactly the same zeroes as one that died on day one. The -activity feed is the narrative instead: one row per engine cycle, newest first, expandable to the -events inside it, so a run of quiet cycles reads as the positive observation it is rather than as -an absence. - -Its source is the structured JSONL engine log -- NOT the database, and not a new table. See -`keel/commands/activity.py`'s own docstring for the full argument, but the short of it is that a -new table would start EMPTY on the very deployment this exists to explain, while the log is -already months deep. No schema change, no migration, no engine change. - -**That makes `v` the one overlay that reads a file rather than the DB, so it is worth saying -plainly why it is admissible.** This dashboard's iron rule -- stated for `s` screen above -- is -"DB reads only; never builds a broker, never touches the network." Reading a local log file is -NEITHER of the two things that rule forbids: 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, and a bounded read of a file on the same disk violates none of that. It -is the same latitude `p` propose already takes to read `config.proposals_dir`, and the network -exception count in this module stands unchanged at three. - -The read is BOUNDED -- 1 MiB of the log's tail, 5000 lines, 200 cycles, all named constants in -`activity.py` -- because the log grows without limit and this overlay rebuilds every poll. A -dashboard whose responsiveness degrades with how long the deployment has been running would be a -worse bug than the one this feature fixes. - -v5 SCOPES that feed to the current local calendar day by default, with `t` inside the overlay -cycling `today` -> `7 days` -> `all`. "What has keel been doing" means today unless asked -otherwise, and a fortnight of scrollback is not an answer to it. Two consequences are handled in -`activity.py` rather than here, and both are the point of the change rather than trimming around -it: the scope is a parameter that RESETS to `today` on every open (a widened view answers one -question once; it does not become tomorrow's default), and an empty "today" -- the normal state -of a once-a-day deployment every morning before 09:00 -- renders `describe_empty_scope`, which -names when keel last ran and when the next cycle is due. A blank panel there would be worse -than the dead-looking state dashboard the whole feature exists to fix, since a blank panel and a -dead agent look exactly alike. - -v6 (issue #388 C2, the PRD's operator-console slice 2) wraps the whole thing in the CONSOLE -SHELL -- `keel/commands/console.py`, whose own docstring owns the design. The dashboard stays -the landing screen and every existing mode is unchanged: the shell adds an `m` menu mode over -the PRD §3 tree (future slices' entries render a "lands in C3/C4/C5" notice), a Profile menu -that rebinds the console's config+db pair through the same `_load_cfg`/`_open_repo` loaders -every CLI command uses (LIVE guarded by an explicit y/N, never O3's typed contract), and the -session banner (O9): a two-line header on EVERY screen -- active deployment, then the recorded -market session + clock (24/7, or OPEN/CLOSED with the recorded next open/close, or CLOCK -UNAVAILABLE fail-loud when the record is absent or stale) -- composed from `keel.agent`'s -recording alone. `run_live` takes the shell as an OPTIONAL `console_binding`; a caller that -passes none gets the pre-C2 dashboard byte-for-byte, which is what keeps every existing test -- -and `--once`, and any embedded consumer -- on the unchanged path. The shell adds NO network -touch of its own: the banner reads the repo and the adapter's offline capabilities -declaration, profile switching reads local files, and the three deliberate network exceptions -below are still the only three. -""" - -from __future__ import annotations - -import sys -import textwrap -import time -from collections.abc import Callable -from dataclasses import dataclass -from decimal import Decimal -from pathlib import Path -from typing import TYPE_CHECKING, Any - -import click - -from keel.commands._common import DISCLAIMER -from keel.commands.activity import ( - ACTIVITY_HEADER, - DEFAULT_ACTIVITY_SCOPE, - ActivityFeed, - build_activity_feed, - cycle_style, - describe_empty_scope, - describe_status, - event_style, - footer_notes, - next_activity_scope, - render_cycle_row, - render_event_row, - scope_headline, -) -from keel.commands.admission import ( - DiscoverReport, - ProposeView, - ScreenReport, - build_discover_report, - build_propose_view, - build_screen_report, - render_discover_report, - render_propose_view, - render_screen_report, -) -from keel.commands.status import ( - StatusReport, - _human_age, - _rail17_line, - _session_line, - gather_status, -) -from keel.config import Config -from keel.data.repository import Repository -from keel.types import Granularity -from keel.version import _package_version - -if TYPE_CHECKING: - # `keel.commands.insights` imports `_human_dt` back from this module, so importing it at - # module load time would be a circular import -- these names are only used in type - # annotations here (never evaluated at runtime, `from __future__ import annotations` keeps - # them as strings), and every call site below lazy-imports the real symbols it needs. - from keel.commands.insights import InsightsReport, JournalReport - -# -- the pure screen model (the testable core) -------------------------------------------------- - - -@dataclass(frozen=True) -class ScreenLine: - text: str - style: str # one of: "heading" | "normal" | "ok" | "alert" | "warn" | "muted" - - -# Period, in seconds, of each configured candle granularity -- keyed by `Granularity.value` (the -# same strings `ProductFreshness.granularity` stores) so `_freshness_style` never has to import -# the enum member itself, just compare strings. -_GRANULARITY_PERIOD_SEC: dict[str, int] = { - Granularity.ONE_MINUTE.value: 60, - Granularity.FIVE_MINUTE.value: 300, - Granularity.FIFTEEN_MINUTE.value: 900, - Granularity.ONE_HOUR.value: 3600, - Granularity.SIX_HOUR.value: 21600, - Granularity.ONE_DAY.value: 86400, -} - - -def _freshness_style( - granularity: str | None, age_sec: int | None, *, market_closed: bool = False -) -> str: - """`"ok"` when a product's newest candle is within 2x its own granularity's period, `"warn"` - when it is staler than that -- or when there is no local data / unknown granularity to begin - with (a daily series a couple of days old is fine; a couple of *periods* old is stale). - - `market_closed` (the report's own session answer, closed AND inside its trust window -- - `MarketSessionStatus.defused`) downgrades the AGE-based warn to `"muted"`: a behind - series during a closure is the expected weekend shape, and painting it warn while the - session line two rows up says CLOSED muted would be one screen disagreeing with itself. - The no-data/unknown-granularity warn is deliberately NOT downgraded -- a closed venue - still serves history, so a cold cache is a pipeline problem, not a session artifact (the - `fetch --check` rule, carried into colour).""" - if granularity is None or age_sec is None: - return "warn" - period = _GRANULARITY_PERIOD_SEC.get(granularity) - if period is None: - return "warn" - if age_sec > 2 * period: - return "muted" if market_closed else "warn" - return "ok" - - -def _blank() -> ScreenLine: - return ScreenLine("", "normal") - - -def _human_dt(ts: int) -> str: - """Local-time `YYYY-MM-DD HH:MM:SS` for a unix timestamp -- readable in the title, each open - position's `opened_at`, and the autonomy lapsed/lapses-at lines. Freshness keeps its own - relative `_human_age` ("4h ago"); this is for absolute points in time.""" - return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) - - -def _short_version(raw: str) -> str: - """`v..` from a full version string (`0.5.2` -> `v0.5.2`). - - The patch segment is shown because major.minor alone cannot tell two deployments apart: every - release in the 0.5 line rendered as `v0.5`, so an operator glancing at the header could not - see whether the box was running the build they just shipped or the one before it. - - Three degradations, each preferring the most information it can still vouch for: - - * **Build metadata is stripped from the patch.** A release version is `0.5.2+79f35b9e73d5`, so - `parts[2]` is `2+79f35b9e73d5` -- not a digit. Taking it verbatim would print the whole - commit hash into a header line budgeted for a version; rejecting it would drop the patch on - precisely the shape a released build emits. Split on `+` and keep the numeric head. - * **No patch segment** (`2.0`) -> `v2.0`. Show what exists rather than inventing a `.0` the - version string never claimed. - * **A non-numeric patch** (`0.5.2rc1`) -> `v0.5`. Falling all the way back to `v?` would throw - away the two segments that did parse. - - Falls back to `v?` only when major/minor themselves are unparseable, so the header never shows - a bare `unknown` and never raises. - """ - parts = raw.split(".") - if len(parts) < 2 or not parts[0].isdigit() or not parts[1].isdigit(): - return "v?" - head = f"v{parts[0]}.{parts[1]}" - if len(parts) < 3: - return head - patch = parts[2].split("+", 1)[0] - return f"{head}.{patch}" if patch.isdigit() else head - - -#: The short header version, resolved ONCE at import (it never changes within a run). Uses the -#: lightweight package-metadata reader, not `build_info()`, so no git subprocess runs per repaint. -_SHORT_VERSION = _short_version(_package_version()) - - -def _title_lines(report: StatusReport, now_ts: int) -> list[ScreenLine]: - return [ScreenLine(f"keel {_SHORT_VERSION} · {report.mode} mode", "heading")] - - -def _kill_switch_lines(report: StatusReport) -> list[ScreenLine]: - if report.kill_switch_engaged: - return [ScreenLine("kill_switch: ENGAGED (halted)", "alert")] - return [ScreenLine("kill_switch: clear", "ok")] - - -def _market_session_style(state: str) -> str: - """Colour for the session line, by what the state MEANS to an operator: - - * `open` is the working state -- `ok`, the same green a clear kill-switch gets. - * `closed` is an EXPECTED state (every weekend, every holiday) -- `muted`, deliberately - NOT warn/alert: a closed market is the system working as designed, and painting it - yellow would spend the warning colour on ~2 days of every 7 until an operator stops - looking at it. The line still names the skip and the alert relief, so the quiet is - legible without being loud. - * `clock_unavailable` is a degraded read the fail-closed posture is papering over -- - `warn`. Unlike a weekend it is never routine, and it is one clock outage away from - every cycle skipping silently. - - No paper-mode divergence (unlike rail 17's): the session gate skips PAPER cycles too, so - the same severity is truthful in every mode. - """ - if state == "open": - return "ok" - if state == "clock_unavailable": - return "warn" - return "muted" - - -def _market_session_lines(report: StatusReport) -> list[ScreenLine]: - """`render_human`'s exact session text, styled -- the `_rail17_line` discipline: the TUI - and `keel status` render ONE string, so they can never disagree about whether the venue - is closed. Nothing to say (a 24/7 venue, no cycle recorded) renders nothing, matching the - text renderer byte for byte.""" - line = _session_line(report.market_session) - if line is None: - return [] - return [ScreenLine(line, _market_session_style(report.market_session.state or ""))] - - -def _autonomy_lines(report: StatusReport) -> list[ScreenLine]: - a = report.autonomy - lines: list[ScreenLine] = [] - if not a.profile_readable: - lines.append( - ScreenLine( - " WARNING: profile row unreadable -- reporting autonomy as OFF (safe reading).", - "warn", - ) - ) - if a.live: - lines.append(ScreenLine("autonomy: ON -- orders placed WITHOUT asking", "alert")) - else: - lines.append(ScreenLine("autonomy: off", "muted")) - if a.autonomous and not a.live: - # An expiry is what makes this branch reachable (autonomy recorded ON, its deadline - # passed), so `autonomous_until` is set here in practice. Guarded anyway because the - # failure is silent rather than loud: `_human_dt(None)` does not raise -- - # `time.localtime(None)` means "now" -- so a missing deadline would render as having - # lapsed at this very instant, which reads as fact. - lapsed_at = _human_dt(a.autonomous_until) if a.autonomous_until is not None else "unknown" - lines.append(ScreenLine(f" (was ON but LAPSED at {lapsed_at})", "muted")) - elif a.live and a.autonomous_until is not None: - lines.append(ScreenLine(f" lapses at {_human_dt(a.autonomous_until)}", "muted")) - return lines - - -def _rail11_style(status: str) -> str: - if status == "HALTED": - return "alert" - if status == "unknown": - return "warn" - return "ok" - - -def _equity_lines(report: StatusReport) -> list[ScreenLine]: - lines: list[ScreenLine] = [] - mode_text = f"equity_state_mode: {report.equity_state_mode or 'unknown'}" - lines.append(ScreenLine(mode_text, "normal")) - hwm = report.high_water_mark if report.high_water_mark is not None else "unknown" - lines.append(ScreenLine(f"high_water_mark: {hwm}", "normal")) - dd_total = report.drawdown_total_pct if report.drawdown_total_pct is not None else "unknown" - dd_weekly = report.drawdown_weekly_pct if report.drawdown_weekly_pct is not None else "unknown" - lines.append( - ScreenLine( - f"drawdown: total={dd_total} (ceiling {report.max_total_dd_pct}) " - f"weekly={dd_weekly} (ceiling {report.max_weekly_dd_pct})", - "normal", - ) - ) - rail11_text = f"rail11 (drawdown breaker): {report.rail11_status}" - lines.append(ScreenLine(rail11_text, _rail11_style(report.rail11_status))) - # `render_human`'s exact rail-17 text, so the TUI and `keel status` can never disagree - # about whether entries are halted. Every state but `attested` fails rail 17 closed -- - # a halt -- so each is an alert; EXCEPT in paper mode, where the rail is not evaluated - # and a stale attestation halts nothing (a permanently-red alert there is fatigue, not - # information), so the same states downgrade to warn. - rail17 = report.withdrawal_attestation - rail17_evaluated = report.mode != "paper" - style = "ok" - if rail17.state != "attested": - style = "alert" if rail17_evaluated else "warn" - lines.append(ScreenLine(_rail17_line(rail17, rail17_evaluated), style)) - if report.mode == "paper": - lines.append(ScreenLine(f"paper_cash_usdc: {report.paper_cash_usdc}", "normal")) - return lines - - -@dataclass(frozen=True) -class AvailableBalance: - """The live, real-account balance of `config.quote_currency` available to fund a buy -- - fetched via the exact same `_fetch_available_quote` rail 13 funds a buy against, so the TUI - and the rail never disagree. `amount is None` means the balance could not be read (`error` - explains why); `updated_ts` is when the read was attempted, whether or not it succeeded.""" - - amount: Decimal | None - quote: str - updated_ts: int | None - error: str | None - - -def _available_lines(available: AvailableBalance | None) -> list[ScreenLine]: - """PURE: `available is None` (e.g. `--once`, which never touches the network) renders - nothing. A successful read is an `"ok"` line naming the live account and when it was read; an - unreadable balance (any broker/network failure, fail-soft) is a `"warn"` line with the - reason -- never a crash, never a silently blank line. - - Labelled `"live account"`, not `"available to buy"`: in paper mode a buy spends - `paper_cash_usdc`, not this real-account balance, so calling it "available to buy" would - mislead an operator watching the paper dashboard.""" - if available is None: - return [] - if available.amount is not None: - # `updated_ts` is a separate field from `amount` and can be absent while the amount is - # present; same silent-"now" hazard as the autonomy line above, and on a freshness - # stamp specifically, where a wrong value is worse than an admitted missing one. - as_of = _human_dt(available.updated_ts) if available.updated_ts is not None else "unknown" - text = ( - f"live account: {available.amount:,.2f} {available.quote} available ({as_of})" - ) - return [ScreenLine(text, "ok")] - return [ScreenLine(f"live account: unavailable -- {available.error}", "warn")] - - -def _open_position_lines(report: StatusReport) -> list[ScreenLine]: - lines: list[ScreenLine] = [] - if not report.open_positions: - lines.append(ScreenLine("open positions: no open positions", "normal")) - return lines - lines.append(ScreenLine(f"open positions ({len(report.open_positions)}):", "normal")) - for pos in report.open_positions: - bracket_note = "bracketed" if pos.has_bracket else "NO bracket" - row_style = "normal" if pos.has_bracket else "warn" - lines.append( - ScreenLine( - f" [{pos.id}] {pos.product_id} qty={pos.qty} entry={pos.entry_price} " - f"opened_at={_human_dt(pos.opened_at)} rule={pos.rule_name} ({bracket_note})", - row_style, - ) - ) - return lines - - -def _rule_lines(report: StatusReport) -> list[ScreenLine]: - lines: list[ScreenLine] = [] - counts = " ".join(f"{status}={count}" for status, count in sorted(report.rule_counts.items())) - lines.append(ScreenLine(f"rules: {counts or 'none'}", "normal")) - for rule in report.live_rules: - lines.append( - ScreenLine( - f" live [{rule.id}] {rule.kind} product={rule.product_id} params={rule.params}", - "alert", - ) - ) - return lines - - -def _freshness_lines(report: StatusReport) -> list[ScreenLine]: - # The session answer the dashboard's own session line renders (`_market_session_lines` - # reads the same `report.market_session`): closed AND still inside its trust window - # (`defused`) mutes the staleness colour, so the cells and the line cannot disagree - # about the same weekend. Anything else -- open, unreadable clock, an expired record -- - # keeps the ordinary warn. - market_closed = ( - report.market_session.state == "closed" and report.market_session.defused - ) - lines: list[ScreenLine] = [ScreenLine("data freshness:", "normal")] - for f in report.data_freshness: - style = _freshness_style(f.granularity, f.age_sec, market_closed=market_closed) - if f.last_ts is None: - lines.append(ScreenLine(f" {f.product_id}: no data", style)) - else: - age_text = f" {f.product_id} ({f.granularity}): {_human_age(f.age_sec or 0)}" - lines.append(ScreenLine(age_text, style)) - return lines - - -def _subscription_lines(report: StatusReport) -> list[ScreenLine]: - if not report.subscriptions: - return [] - lines: list[ScreenLine] = [ScreenLine("subscriptions:", "normal")] - for s in report.subscriptions: - cap = "unlimited" if s.effective_cap is None else str(s.effective_cap) - sub_text = f" {s.venue}: tier={s.tier_name} status={s.effective_status} cap={cap}" - lines.append(ScreenLine(sub_text, "normal")) - return lines - - -def _footer_lines() -> list[ScreenLine]: - """The keybinding hint bar shown at the bottom of the normal-mode dashboard. Deliberately - interval-independent (see `build_screen`'s note) and pure, so it's directly testable. - - Two lines, not one: the first (kept byte-for-byte as it was before the admission overlays - existed, so nothing that already reads it needs to change) is already close to 80 columns, - and cramming more keys onto the end of it would either wrap on a normal terminal or - silently truncate (`_paint` clips every line to the window width). A second line costs one - more row of screen -- cheap, next to a footer line an operator can no longer read. - - That second line was labelled `admission:` while all three keys on it belonged to the - admission workflow. v4's `v` activity did not, so the label became the accurate - `overlays:` -- a footer that mis-files a key is worse than one that groups it loosely. - The console shell's `m` (issue #388 C2) joins the same line, single-spaced so the row - still fits the 80-column budget the two-line split exists to protect: `m` opens a - whole mode (the menu), not an overlay, but it is a one-key destination exactly like - the others, and an operator hunting for the console would not think to look anywhere - but the footer.""" - return [ - ScreenLine( - "keys: [q] quit [h] help [i] insights [r] refresh [a] autonomy [f] fetch", - "muted", - ), - ScreenLine( - "overlays: [s] screen [p] propose [d] discover (network) [v] activity [m] menu", - "muted", - ), - ] - - -def build_screen( - report: StatusReport, now_ts: int, *, available: AvailableBalance | None = None -) -> list[ScreenLine]: - """Turn a `StatusReport` into styled rows -- a PURE function of the report, reusing every - logic decision (Rail 11, freshness, autonomy) `gather_status` already made. Never re-derives - status; only styles it. - - `available` is the live "available to buy" balance (v3) -- keyword-only and defaulted to - `None` so every existing caller (`--once`, `render_plain`, the whole pre-v3 test suite), which - passes no `available`, renders EXACTLY as before and stays network-free. Only `run_live` - threads a real `AvailableBalance` through, refreshed on its own slow cadence.""" - lines: list[ScreenLine] = [] - lines.extend(_title_lines(report, now_ts)) - lines.extend(_kill_switch_lines(report)) - lines.extend(_market_session_lines(report)) - lines.extend(_autonomy_lines(report)) - lines.append(_blank()) - lines.extend(_equity_lines(report)) - lines.extend(_available_lines(available)) - lines.append(_blank()) - lines.extend(_open_position_lines(report)) - lines.append(_blank()) - lines.extend(_rule_lines(report)) - lines.append(_blank()) - lines.extend(_freshness_lines(report)) - sub_lines = _subscription_lines(report) - if sub_lines: - lines.append(_blank()) - lines.extend(sub_lines) - lines.append(_blank()) - # Deliberately interval-independent: `build_screen` doesn't know the poll interval, so it - # cannot say "refreshing every Ns" without threading that through its signature. The live - # loop is free to show its own interval-bearing status line if desired. - lines.extend(_footer_lines()) - return lines - - -def render_plain(report: StatusReport, now_ts: int) -> list[str]: - """The `.text` of each `build_screen` line, styles dropped -- drives `--once` and any - non-tty use.""" - return [line.text for line in build_screen(report, now_ts)] - - -def build_help_screen() -> list[ScreenLine]: - """A titled, scrollable help overlay documenting every keybinding and the safety notes for - the two capability-adding actions. PURE -- deliberately longer than a small terminal so the - `_visible_slice` scrolling the live loop drives against it actually matters.""" - lines: list[ScreenLine] = [ScreenLine("keel tui -- help", "heading"), _blank()] - - def _row(text: str) -> None: - lines.append(ScreenLine(text, "normal")) - - def _note(text: str) -> None: - lines.append(ScreenLine(text, "muted")) - - _row("Normal mode") - _row(" q quit") - _row(" h open the Help menu (glossary, screens, params, keys & safety)") - _row(" ? open the current screen's own help, wherever you are") - _row(" i open the insights overlay (per-rule track record + promotion gates)") - _row(" r refresh now (poll immediately, instead of waiting for the interval)") - _row(" a toggle autonomy (arm / disarm the agent placing orders unattended)") - _row(" f fetch all data (pull candles for every configured product)") - _note(" can pull up to 5y of candles; the dashboard freezes until it finishes") - _note(" (Ctrl-C aborts the whole TUI, not just the fetch)") - _row(" s open the screen overlay (allowlist admission verdicts, read-only)") - _row(" p open the propose overlay (screens the newest shortlist file, read-only)") - _row(" d open the discover overlay (propose NEW candidates from the venue)") - _note(" armed, not run, on open -- see 'Discover overlay' below") - _row(" v open the activity feed (what keel has been DOING, cycle by cycle)") - _note(" reads the engine log, offline -- see 'Activity overlay' below") - _note(" opens scoped to TODAY; press t inside it to widen") - _row(" m open the console menu (the shell over this dashboard)") - _note(" deployment switching + the PRD's menu tree -- see 'Console menu' below") - lines.append(_blank()) - _row("Which account is this?") - _note(" paper and live are SEPARATE deployments -- separate config, database, allowlist,") - _note(" caps and history -- and no figure on this screen describes the other one. Read") - _note(" `equity_state_mode` to tell which is on screen; paper_cash_usdc appears in paper") - _note(" mode only.") - _note(" Switching is now in-app: press m for the console menu, then Profile -- each entry") - _note(" is a config+db PAIR, selecting LIVE asks an explicit y/N first, and every screen's") - _note(" banner names the active pair. The command line keeps the same rule, and --db") - _note(" DEFAULTS to keel.db, so omitting it shows PAPER. The explicit pair still works:") - _note(" keel --config config.live-sandbox.yaml --db keel-live.db tui") - lines.append(_blank()) - _row("Console menu (m)") - _note(" The console's tree over this dashboard. Dashboard returns here; Profile") - _note(" switches deployment; Help holds the glossary and this screen. Trading (agent") - _note(" cycle, kill/resume, autonomy), Rules (the strategy console: ledger, simulate,") - _note(" add), Compliance (screen, attest, scout, Shariah in force), Data (fetch,") - _note(" freshness) and Research (docs, trials) are LIVE menus; Account is the one") - _note(" placeholder left (it lands in C6).") - _note(" Keys: up/k down/j move, Enter/Space select, 1-9 jump, q/Esc/m back to here.") - _row(" Profile") - _note(" Lists the four deployments by their config+db pair (the pairs the keel-paper /") - _note(" keel-live / keel-paperhourly / keel-equities wrappers pin). Selecting one") - _note(" rebinds config AND database together everywhere, in one action -- the header") - _note(" banner on every screen names the active pair. Selecting LIVE asks an explicit") - _note(" y/N at the terminal first and is marked unmistakably once active; declining") - _note(" changes nothing. This is a VIEW switch (which deployment the console answers") - _note(" about), not an engine switch -- the running agent keeps its own pair.") - _row(" The session banner (on every screen)") - _note(" The two header lines: the active deployment, then the market session + clock") - _note(" for its venue. 24/7 venues (crypto) say so; session-bound venues (equities)") - _note(" show OPEN or CLOSED with the recorded NEXT OPEN / NEXT CLOSE; CLOCK") - _note(" UNAVAILABLE means the recorded clock is absent or stale and is rendered") - _note(" fail-loud on purpose. All of it comes from what the agent cycle RECORDED --") - _note(" the same session state `fetch --check` and `keel status` read -- never from a") - _note(" clock call or calendar of the TUI's own.") - lines.append(_blank()) - _row("Live balance") - _note(" 'live account' shows the REAL account's spendable quote balance (e.g. USDC),") - _note(" refreshed every ~30s and immediately on 'r' or 'f' -- so a deposit or sell shows up.") - _note(" Each refresh is a LIVE call to the venue (get_accounts) -- one of the three network") - _note(" touches this dashboard makes, and the only one that happens without a keypress. It") - _note(" is a read: it places no orders and changes nothing.") - _note(" In paper mode, paper buys spend paper_cash_usdc instead -- not this balance.") - lines.append(_blank()) - _row("Glossary (the field names the dashboard prints verbatim)") - _row(" cycle") - _note(" One pass of the agent loop: poll the feed, evaluate every rule against every") - _note(" allowlisted product, decide. This deployment runs ONE cycle per day. A cycle that") - _note(" happened and found nothing is the NORMAL case, not a fault.") - _row(" signal") - _note(" A rule's setup that passed the engine's gates. `signals=0` means no rule found a") - _note(" setup at all -- which is NOT the same as a setup being found and then vetoed.") - _row(" sig / blk / ent / exi / err") - _note(" The activity overlay's per-cycle columns: signals, blocked (rail vetoes),") - _note(" entered, exited, errors. `sig 1 blk 1` means keel DID find something and a rail") - _note(" stopped it; `sig 0` means it found nothing to stop. Read the two together: they") - _note(" are the difference between 'no setup' and 'setup, declined'.") - _row(" paper_cash_usdc") - _note(" The synthetic cash balance paper buys spend -- seeded once, then tracked in the") - _note(" DB. It is NOT a real broker balance, and it appears only in paper mode.") - _row(" equity_state_mode") - _note(" Whether the equity figures above describe the PAPER account or the LIVE one. The") - _note(" two are separate accounts with separate histories; neither reflects the other.") - _row(" high_water_mark / drawdown / rail11") - _note(" The peak equity the drawdown breaker measures against, how far equity has fallen") - _note(" from that peak now, and whether the breaker is holding trading. The ceilings in") - _note(" parentheses on the drawdown line come from config.") - lines.append(_blank()) - _row("Help mode (this screen)") - _row(" up / k scroll up one line") - _row(" down / j scroll down one line") - _row(" PgUp scroll up one page") - _row(" PgDn scroll down one page") - _row(" Home jump to the top") - _row(" End jump to the bottom") - _row(" q / Esc / h close this screen, back to the Help menu") - _row(" ? open this screen's own help (the context overlay)") - lines.append(_blank()) - _row("Insights overlay (i)") - _note(" Read-only, like the whole dashboard: per-rule track record, distance to the") - _note(" promotion gate, an account summary, and a compact recent-trades tail -- the same") - _note(" scrolling keys as help mode (up/k, down/j, PgUp/PgDn, Home/End).") - _row(" q / Esc / i close insights, back to the dashboard") - lines.append(_blank()) - _row("Screen overlay (s)") - _note(" OFFLINE, read-only: runs the current allowlist through the SAME admission gate") - _note(" `keel assets screen` uses (`_screen_product`) -- ADMIT/REJECT per product, plus WHY.") - _note(" DB reads only; never builds a broker, never touches the network.") - _row(" q / Esc / s close screen, back to the dashboard") - lines.append(_blank()) - _row("Propose overlay (p)") - _note(" OFFLINE, read-only: screens the newest *.json shortlist file in config.proposals_dir") - _note(" (produced externally -- an LLM + web-search scout, or the discover overlay's output") - _note(" saved to disk) through the same admission gate. No shortlist yet is reported plainly,") - _note(" not as an error. DB + local filesystem reads only; never touches the network.") - _row(" q / Esc / p close propose, back to the dashboard") - lines.append(_blank()) - _row("Discover overlay (d)") - _note(" Opens ARMED, NOT yet run -- pressing 'd' makes NO network call. It explains what") - _note(" running it will do and that it is a LIVE call to the venue. Only Enter, pressed") - _note(" INSIDE this overlay, actually contacts the venue (`list_products`) and proposes") - _note(" candidates from the result -- the same cheap pre-filter `keel assets discover` runs.") - _note(" The result is then HELD: every poll while the overlay stays open repaints the same") - _note(" cached result, with NO further network calls, until Enter is pressed again. Closing") - _note(" the overlay discards the held result, so reopening it is armed-but-not-run again.") - _row(" Enter run discover now (the ONE network call this overlay ever makes)") - _row(" q / Esc / d close discover, back to the dashboard (discards the held result)") - lines.append(_blank()) - _row("Activity overlay (v)") - _note(" OFFLINE, read-only: a chronological feed of what the agent has actually been doing,") - _note(" newest first, ONE ROW PER ENGINE CYCLE, grouped by the cycle_id every event carries.") - _note(" Every other overlay here reports STATE -- and state is what looks dead when nothing") - _note(" trades, because a deployment that ran flawlessly for three weeks and correctly") - _note(" declined every setup shows the same zeroes as one that died on day one. This shows") - _note(" the narrative instead. A QUIET cycle still gets a row: the run of quiet cycles IS") - _note(" the answer to 'is it alive'.") - _note(" SCOPED TO TODAY by default -- the local calendar day, midnight to now, in the same") - _note(" clock the rows are stamped in. 'What has keel been doing' means today unless you") - _note(" ask otherwise; t cycles the scope today -> 7 days -> all, and the scope goes back") - _note(" to today every time the overlay is reopened (a widened view is never remembered).") - _note(" When today holds no cycle yet -- the normal state of a once-a-day deployment every") - _note(" morning before its run -- the panel is NOT blank: it says keel has not run yet") - _note(" today, names when the last cycle was and when the next one is due, and tells you") - _note(" which key widens the window. A quiet cycle that DID run is still a row.") - _note(" If the bounded read cannot prove it reached back to midnight, the footer says so,") - _note(" rather than letting an unread morning read as a quiet one.") - _note(" Each row: local time, mode, and sig/blk/ent/exi/err (signals, blocked, entered,") - _note(" exited, errors), then what was notable -- a rail veto and its rule, the gate that") - _note(" rejected a setup, the reason an entry was not placed. Colour follows the same") - _note(" convention as the rest of the dashboard: quiet is muted, withheld/vetoed is a") - _note(" warning, a real fill is green, and an ERROR-level run is an alert.") - _note(" Source: the structured JSONL engine log at `logging.file` in config.yaml (default") - _note(" `logs/keel.log`, resolved against the WORKING DIRECTORY -- so run keel tui from the") - _note(" deployment root). Not the database: a new table would start empty on exactly the") - _note(" deployment this feed exists to explain, while the log is already months deep.") - _note(" Reading a local file is neither a broker nor the network, so this overlay keeps the") - _note(" same offline guarantee s and p do -- the three network exceptions above are still") - _note(" the only three.") - _note(" The read is BOUNDED (newest 1 MiB / 5000 lines / 200 cycles) so a log that grows for") - _note(" months can never slow the dashboard down; the feed says when the bound bit.") - _note(" A missing, empty, unreadable or unparseable log is explained in plain words -- never") - _note(" a traceback, and never a blank screen.") - _row(" up / k select the previous (newer) cycle") - _row(" down / j select the next (older) cycle") - _row(" Enter / Space expand or collapse the selected cycle's events") - _row(" t cycle the scope: today -> last 7 days -> all history in the window") - _row(" PgUp / PgDn / Home / End move the selection by a page, or to either end") - _row(" q / Esc / v close activity, back to the dashboard (scope resets to today)") - lines.append(_blank()) - _row("Safety notes") - _note( - " autonomy OFF is immediate and ungated -- de-risking must never be obstructed, so" - ) - _note(" turning it off never asks for confirmation, exactly like `keel autonomy off`.") - lines.append(_blank()) - _note( - " autonomy ON is DANGEROUS: once armed, the agent places rule-generated orders" - ) - _note( - " WITHOUT asking first, subject to all the same hard rails as every other mode. Arming" - ) - _note( - " from here suspends the screen and requires a typed \"yes\" at the terminal, exactly" - ) - _note(" like `keel autonomy on` -- it is never armed silently or on a keystroke alone.") - lines.append(_blank()) - _note( - " fetch is money-safe: it only pulls candle history from the venue's public market-data" - ) - _note(" endpoints. It places no orders and touches no rails.") - lines.append(_blank()) - _note(" screen, propose and activity are fully OFFLINE: DB reads, plus (for propose and") - _note(" activity) local filesystem reads. None constructs a broker or touches the network.") - lines.append(_blank()) - _note( - " discover is the THIRD deliberate network exception in this dashboard. The other two" - ) - _note(" are the automatic ~30s live-balance refresh above and [f] fetch -- three in total,") - _note(" and nothing else here ever leaves this machine. Discover never fires on opening the") - _note(" overlay and never fires again on its own while the overlay stays open -- only an") - _note(" explicit Enter, pressed inside it, runs the one live venue call it ever makes.") - lines.append(_blank()) - _note( - " NONE of screen, propose or discover attests, admits, or trades. They can only PROPOSE" - ) - _note( - " or REPORT -- putting an asset on `allowlist` in config.yaml still needs a human to run" - ) - _note( - " `keel assets attest` with a source, or the console's Compliance attest form. That form" - ) - _note( - " (and the scout browser's `a` step) never attests on a keypress alone: it ends by" - ) - _note( - " making you TYPE THE ASSET CODE back, and withdrawals attest types its own CLI phrase" - ) - _note( - " ('yes') -- the typed phrase is the safety, never where you invoked it from. attest is" - ) - _note( - " the one step in this whole gate that rests on human judgment, not code -- the form" - ) - _note(" collects it, it never supplies it.") - lines.append(_blank()) - _row("Every action shows a one-line result at the bottom of the dashboard until the next") - _row("action replaces it.") - lines.append(_blank()) - _row("Press q, Esc or h to return to the Help menu; ? opens this screen's own help.") - return lines - - -#: This module's screens' contextual help (O8, issue #394 C7) -- the rows the `?` -#: overlay renders for the DASHBOARD's own modes and the help surfaces, keyed by the live -#: loop's mode names. Plain `(subject, description)` pairs so the text stays HERE with -#: the screens it describes; `keel.commands.help_console` is the registry and renderer -#: (it imports THIS module, so this dict must import nothing from it). -CONTEXT_HELP: dict[str, tuple[tuple[str, str], ...]] = { - "normal": ( - ( - "the dashboard", - "the live view of the ACTIVE deployment: rails, the market session, " - "positions, data freshness, recent activity -- read-only, refreshed every " - "poll from the DB a separate `keel agent` process writes", - ), - ( - "the session banner (the two header lines)", - "the active config+db pair, then the market session and clock from the " - "venue's own recorded state -- 24/7 venues say so, session-bound ones show " - "OPEN/CLOSED with the next open/close, CLOCK UNAVAILABLE renders fail-loud", - ), - ( - "a / f (autonomy, fetch)", - "a arms/disarms unattended ordering (arming asks a typed yes at the " - "terminal); f pulls candle history for every configured product -- " - "money-safe, data only, and it can take minutes", - ), - ( - "m", - "the console menu -- the operator console's tree over this dashboard", - ), - ), - "help": ( - ( - "keys & safety", - "the full keybinding reference, every overlay's contract, and the safety " - "notes for the capability-adding actions -- the screen the Help menu's " - "'keys & safety' entry opens", - ), - ), - "help-menu": ( - ( - "the Help menu", - "the glossary (every console term defined once, fiqh terms anchored to " - "docs/fiqh-basis.md), every screen's what-am-I-looking-at rows, every rule " - "kind's parameter help, and the keys/safety notes", - ), - ( - "? anywhere", - "the same help, in place: pressing ? in any console screen opens that " - "screen's own rows", - ), - ), - "help-glossary": ( - ( - "the glossary", - "docs/glossary.md rendered whole -- THE single source for console term " - "definitions; each fiqh term cites the fiqh-basis section it is quoted " - "from, and a term fiqh-basis does not state says so", - ), - ), - "help-params": ( - ( - "rule parameters", - "one row per rule kind; opening one renders every parameter's help FROM " - "THE CLASS ITSELF (describe_params, by introspection) -- doc, type, " - "choices and default, never a hand-maintained duplicate table", - ), - ), - "help-params-kind": ( - ( - "one kind's parameters", - "each parameter's meaning and default as its rule class defines it -- the " - "same source the add form's per-field help and the ledger detail render", - ), - ), - "help-screens": ( - ( - "screens & actions", - "every console screen's contextual-help rows in one scroll -- the " - "consolidated catalog of what each screen shows and each action does", - ), - ), - "context-help": ( - ( - "this overlay", - "the screen you pressed ? in, explained: its rows come from the module " - "that owns it. q, Esc, ? or m returns to that screen, unmoved", - ), - ), - "insights": ( - ( - "insights (i)", - "read-only: each rule's track record, its distance from the promotion " - "gate, an account summary and a recent-trades tail", - ), - ), - "screen": ( - ( - "screen (s)", - "offline, read-only: the current allowlist through the SAME admission gate " - "`keel assets screen` uses -- ADMIT/REJECT per product, plus why", - ), - ), - "propose": ( - ( - "propose (p)", - "offline, read-only: the newest shortlist file screened through the same " - "admission gate; a missing shortlist is reported plainly, never an error", - ), - ), - "discover": ( - ( - "discover (d) -- ARMED", - "opening makes NO network call; only Enter, pressed inside, makes the ONE " - "live venue read this overlay ever makes, and the result is held until " - "the overlay closes -- propose and report, never attest or trade", - ), - ), - "activity": ( - ( - "activity (v)", - "offline, read-only: one row per engine cycle from the structured log -- " - "what keel has been DOING (a quiet cycle still gets a row); t widens the " - "day scope, Enter expands a cycle into its events", - ), - ), -} - - -def _insights_line_style(text: str) -> str: - """Style a single rendered line from `keel.commands.insights.render_summary`/`render_journal` - (plain, unstyled `str`s -- that module has no notion of `ScreenLine`) by its own textual - conventions, so the overlay reads at a glance exactly like the rest of the dashboard: a - passing gate is reassuring green, a blocked one and its reasons are a warning, the - small-sample caveat and "nothing yet" placeholders are muted, never alarming.""" - stripped = text.strip() - if stripped.startswith("gate: PASSING"): - return "ok" - if stripped.startswith("gate: blocked"): - return "warn" - if stripped.startswith("- "): - return "warn" - if stripped.startswith(_SMALL_SAMPLE_NOTE_PREFIX): - return "muted" - lowered = stripped.lower() - if "no rule track record yet" in lowered or "no closed trades yet" in lowered: - return "muted" - if stripped.startswith("rail11") and "HALTED" in stripped: - return "alert" - return "normal" - - -#: The first few words of `keel.commands.insights._SMALL_SAMPLE_NOTE` -- matched as a prefix -#: rather than importing the full constant (which would defeat the point of the lazy import used -#: to avoid the `insights` <-> `tui` circular import). -_SMALL_SAMPLE_NOTE_PREFIX = "n<30:" - -#: How many of the most recent trades the insights overlay's optional journal tail shows -- -#: compact by design (the overlay is meant to be skimmed, not to replace `keel insights journal`). -_INSIGHTS_JOURNAL_TAIL = 5 - - -def build_insights_screen( - insights_report: InsightsReport, journal_report: JournalReport | None = None -) -> list[ScreenLine]: - """A titled, scrollable, READ-ONLY overlay: the per-rule track record + promotion-gate - distance + account summary (`render_summary`), plus an optional compact recent-journal tail - (`render_journal`) when `journal_report` is supplied. PURE -- both inputs are already-built - reports (`build_insights_report`/`build_journal_report`, called by the live loop, never by - this function), so this never touches the repo/network/broker itself; it only styles text - that `keel/commands/insights.py` already rendered, exactly the way `build_screen` only styles - `StatusReport`. Never raises on a zero-rule/zero-trade report -- `render_summary`'s own - friendly "no rule track record yet" line covers that, so this never renders a blank overlay. - """ - from keel.commands.insights import render_journal, render_summary - - lines: list[ScreenLine] = [ScreenLine("keel tui -- insights", "heading"), _blank()] - for text in render_summary(insights_report): - lines.append(ScreenLine(text, _insights_line_style(text)) if text else _blank()) - - if journal_report is not None: - lines.append(_blank()) - lines.append(ScreenLine(f"recent trades (last {_INSIGHTS_JOURNAL_TAIL}):", "heading")) - for text in render_journal(journal_report): - lines.append(ScreenLine(text, _insights_line_style(text)) if text else _blank()) - - lines.append(_blank()) - lines.append(ScreenLine("Press i or Esc to return to the dashboard.", "muted")) - return lines - - -def _admission_line_style(text: str) -> str: - """Style a single rendered line from `keel.commands.admission`'s renderers - (`render_screen_report`/`render_propose_view`/`render_discover_report` -- plain, unstyled - `str`s, exactly the shape `_insights_line_style` already keys off) by the textual conventions - those renderers already share with `keel assets screen`/`propose`/`discover`'s own CLI output. - - `ADMIT`/`REJECT` are the screen's actual verdict, so they carry the strongest legible - contrast: reassuring green for an admit, a warning (never `"alert"` -- a reject is the system - working as intended, not an emergency) for a reject. A `✗ ` line is a real, FAILED admission - criterion -- `"warn"`. An `INVALID` line (`render_propose_view`'s malformed-shortlist-entry - report) is a data problem in a file on disk, not a live threat -- also `"warn"`, not `"alert"`. - - The `! no local history` line -- and its MISSING-DATA continuation line from - `missing_history_lines` -- are deliberately `"muted"`, NOT `"warn"`/`"alert"`, even though the - first starts with the same `!` marker every other warning does. `keel.compliance.screen. - split_failures`'s entire reason for existing is that "never fetched" is not a verdict about - the asset (see `render_screen_report`'s own docstring: a candidate this deployment has simply - never fetched candles for must not read as indistinguishable from one genuinely too young) -- - painting it in the same colour as a real rejection reason would visually assert the opposite - of what the text says. Every OTHER `! ` line is a genuine warning (`ScreenResult.warnings`, - e.g. a §65.5 bay' al-sarf note that applies even to an ADMITted asset) and stays `"warn"`. - - `render_discover_report`'s closing `⚠️ These are PROPOSALS, not admissions` line is the one - line in this whole workflow that must never be missed -- discover is the network-touching - overlay, and every candidate it lists is unvetted -- so it is `"alert"`, the same weight - `_message_style` gives to arming autonomy ON.""" - stripped = text.strip() - if stripped.startswith("ADMIT"): - return "ok" - if stripped.startswith("REJECT"): - return "warn" - if stripped.startswith("✗"): - return "warn" - if stripped.startswith("!"): - if "no local history" in stripped.lower(): - return "muted" - return "warn" - if "missing-data" in stripped.lower(): - return "muted" - if stripped.startswith("INVALID"): - return "warn" - if stripped.startswith("⚠"): - return "alert" - return "normal" - - -def build_admission_screen_overlay(report: ScreenReport) -> list[ScreenLine]: - """A titled, scrollable, READ-ONLY overlay over an already-built `ScreenReport` -- PURE, - mirroring `build_insights_screen` exactly: the caller (`run_live`'s `screen` branch, via - `_do_screen_report`) does the OFFLINE work of building the report fresh each poll; this - function only styles the lines `render_screen_report` already rendered. Never touches the - repo, network, or broker itself, and never admits, attests, or trades -- see the module - docstring.""" - lines: list[ScreenLine] = [ScreenLine("keel tui -- screen", "heading"), _blank()] - for text in render_screen_report(report): - lines.append(ScreenLine(text, _admission_line_style(text)) if text else _blank()) - lines.append(_blank()) - lines.append(ScreenLine("Press s or Esc to return to the dashboard.", "muted")) - return lines - - -def build_propose_overlay(view: ProposeView) -> list[ScreenLine]: - """Same shape as `build_admission_screen_overlay`, over an already-built `ProposeView` - (which itself NEVER raises -- every failure mode, a missing directory through a malformed - shortlist file, is already a calm `status`/`detail` pair; see its own docstring). - PURE -- only styles what `render_propose_view` already rendered.""" - lines: list[ScreenLine] = [ScreenLine("keel tui -- propose", "heading"), _blank()] - for text in render_propose_view(view): - lines.append(ScreenLine(text, _admission_line_style(text)) if text else _blank()) - lines.append(_blank()) - lines.append(ScreenLine("Press p or Esc to return to the dashboard.", "muted")) - return lines - - -#: Named once so the ARMED explanation's own text and the actual keypress `run_live`'s discover -#: branch checks for can't silently drift apart -- a mismatch here (the overlay says one key, -#: the loop listens for another) would be worse than almost anywhere else in this module, since -#: the whole point of the ARMED state is that the operator can trust what it says before it ever -#: touches the network. -_DISCOVER_RUN_KEY_HINT = "Enter" - - -def build_discover_overlay( - report: DiscoverReport | None, error: str | None = None -) -> list[ScreenLine]: - """A titled, scrollable overlay over `keel.commands.admission.build_discover_report` -- PURE, - but unlike `build_admission_screen_overlay`/`build_propose_overlay` it renders THREE distinct - states, not one, because `discover` is the one overlay of this trio that needs the network - (see the module docstring and `run_live`'s discover branch for the full gating story): - - - `report is None and error is None`: **ARMED, not yet run.** This is the state the overlay - opens into on `d` -- no network call has happened yet, and this rendering is the proof of - that: it names what pressing `_DISCOVER_RUN_KEY_HINT` will do (fetch the venue's product - list and propose candidates from it, the same cheap pre-filter `keel assets discover` - runs), that it is a LIVE call to the venue, and which key runs it. A test asserting this - state renders (rather than, say, a blank or "loading" screen) is the test that proves - opening the overlay alone never touches the network. - - `error is not None`: the last Enter's fetch failed (broker construction, auth, a network - error) -- rendered as one readable line, never a raw traceback, with the same key hint so - the operator knows how to retry. - - `report is not None`: the HELD result of the last successful Enter, rendered via - `render_discover_report` exactly like the other two overlays reuse their own renderer. - - Whichever state, `report`/`error` are furnished by the caller -- this function itself never - fetches, never re-fetches, and never decides staleness; it only styles whatever it is handed. - """ - lines: list[ScreenLine] = [ScreenLine("keel tui -- discover", "heading"), _blank()] - if error is not None: - lines.append(ScreenLine(f"discover failed: {error}", "alert")) - lines.append(_blank()) - lines.append( - ScreenLine(f"Press {_DISCOVER_RUN_KEY_HINT} to contact the venue again.", "normal") - ) - elif report is None: - lines.append(ScreenLine("ARMED -- no network call has been made yet.", "normal")) - lines.append(_blank()) - lines.append( - ScreenLine( - f"Pressing {_DISCOVER_RUN_KEY_HINT} makes ONE live call to the venue " - "(list_products) and proposes candidates from it -- the same cheap pre-filter " - "`keel assets discover` runs, on the exact same data.", - "normal", - ) - ) - lines.append( - ScreenLine( - "This is the third deliberate network exception in this dashboard -- the other " - "two are the automatic ~30s live-balance refresh and [f] fetch. It never fires " - "just from opening this overlay, and it never fires again on its own while this " - "overlay stays open.", - "normal", - ) - ) - lines.append(_blank()) - lines.append(ScreenLine("Nothing here is admitted -- discover only proposes.", "muted")) - lines.append(_blank()) - lines.append( - ScreenLine(f"Press {_DISCOVER_RUN_KEY_HINT} to contact the venue now.", "normal") - ) - else: - for text in render_discover_report(report): - lines.append(ScreenLine(text, _admission_line_style(text)) if text else _blank()) - lines.append(_blank()) - lines.append(ScreenLine("Press d or Esc to return to the dashboard.", "muted")) - return lines - - -def _activity_lines( - feed: ActivityFeed, *, cursor: int = 0, expanded: frozenset[str] = frozenset() -) -> tuple[list[ScreenLine], int]: - """The activity overlay's lines, PLUS the index of the line the cursor currently sits on. - - Returning both from ONE function is deliberate, and is why `build_activity_overlay` is a thin - wrapper over this rather than the other way round. This overlay is the only one of the six - with a *cursor* -- the others scroll a fixed body, but a feed of collapsible rows needs a - selected row to collapse. Keeping the cursor's screen position as a separate function would - mean a second copy of the layout arithmetic ("a row is 1 line, plus one per event when - expanded, plus a note line when events were dropped"), and the two copies would drift the - first time the layout changed -- with the symptom being a cursor that scrolls to the wrong - row, which is exactly the class of bug that is invisible in a unit test of either half alone. - - PURE: `feed` is already built by the caller (`run_live`, via `build_activity_feed`), and this - only styles what `keel.commands.activity`'s renderers already rendered -- the same discipline - `build_insights_screen` and `build_admission_screen_overlay` keep toward their own modules. - Never raises, and never returns an empty body: a broken log renders `describe_status`'s - explanation, which is the whole point (a blank overlay would look exactly like the dead - dashboard this feature exists to disprove).""" - lines: list[ScreenLine] = [ScreenLine("keel tui -- activity", "heading")] - # Clamped here as well as in `run_live`, which already clamps it every poll against a feed - # that can shrink underneath it. Belt and braces for the same reason `_visible_slice` clamps - # its own offset: an out-of-range `cursor` would otherwise mark no row as selected while - # `cursor_line` still reported the first row's position, and the view would scroll to a row - # nothing appears to have selected. - cursor = max(0, min(cursor, max(0, len(feed.cycles) - 1))) - - if feed.status != "ok": - # No scope line here on purpose: when the FILE could not be read, "scope: today" would - # invite an operator to press `t`, and widening a window over a log that does not exist - # changes nothing. `describe_status` owns this screen. - lines.append(_blank()) - for text in describe_status(feed): - lines.append(ScreenLine(text, "warn") if text else _blank()) - lines.append(_blank()) - lines.append(ScreenLine("Press v or Esc to return to the dashboard.", "muted")) - return lines, 0 - - # WHAT is being shown, directly under the title and before what happened in it. Without this - # line a one-row "today" view is indistinguishable from a log that only had one row in it, - # and the `t` key that would settle the question is invisible. - lines.append(ScreenLine(scope_headline(feed), "normal")) - lines.append(_blank()) - - # The column header belongs over columns. When the scope holds no cycle there are none, and - # `describe_empty_scope`'s prose sits directly under the scope line instead. - if feed.cycles: - lines.append(ScreenLine(ACTIVITY_HEADER, "heading")) - cursor_line = len(lines) - - if not feed.cycles: - for text in describe_empty_scope(feed): - lines.append(ScreenLine(text, "warn") if text else _blank()) - for index, cycle in enumerate(feed.cycles): - is_open = cycle.key in expanded - selected = index == cursor - if selected: - cursor_line = len(lines) - lines.append( - ScreenLine( - # `feed.now_ts` rather than a fresh `time.time()`: every row's `age` is measured - # against the same instant the scope boundary was, so the column cannot disagree - # with the header above it or drift row-to-row within one repaint. - render_cycle_row( - cycle, now_ts=feed.now_ts, selected=selected, expanded=is_open - ), - cycle_style(cycle), - ) - ) - if not is_open: - continue - if cycle.events_dropped: - lines.append( - ScreenLine( - f" ... {cycle.events_dropped} earlier event(s) in this cycle are not " - "retained (per-cycle cap) -- the counts above still cover all of them", - "muted", - ) - ) - for ev in cycle.events: - lines.append(ScreenLine(render_event_row(ev), event_style(ev))) - - lines.append(_blank()) - for text in footer_notes(feed): - lines.append(ScreenLine(text, "muted")) - lines.append(_blank()) - lines.append( - ScreenLine( - "up/k down/j · Enter/Space expand · t scope · PgUp/PgDn/Home/End · q/Esc/v close", - "muted", - ) - ) - return lines, cursor_line - - -def build_activity_overlay( - feed: ActivityFeed, *, cursor: int = 0, expanded: frozenset[str] = frozenset() -) -> list[ScreenLine]: - """The activity overlay's lines alone -- the pure, directly-testable surface, matching the - shape of every other `build_*_overlay` in this module. `run_live` uses `_activity_lines` - instead, because it also needs the cursor's screen position to scroll it into view.""" - return _activity_lines(feed, cursor=cursor, expanded=expanded)[0] - - -def _activity_cursor( - ch: int, cursor: int, height: int, total: int, curses_mod: Any, *, banner_lines: int = 0 -) -> int: - """The new, clamped SELECTED-ROW index for a keypress in the activity overlay -- the cursor - analogue of `_scroll_offset`, taking `curses_mod` as a parameter for the identical reasons - (lazy `curses` import; testable against the suite's existing fake curses module). - - The same keys move it that scroll the other five overlays, on purpose: an operator should not - have to remember that this one overlay rebound up/down. What differs is what they move -- a - row, not a line -- because a row here can be one line or seventy, and scrolling by lines - through an expanded cycle would make selecting the next cycle a matter of counting its - events. `_follow_cursor` then does the scrolling, so the view still moves. - - `total` is the number of CYCLES. A page is `height - 3` rows (leaving the title, blank and - header rows in view), floored at 1 so a two-line terminal still advances. - `banner_lines` is the console banner's height -- the feed is PAINTED with the banner - prepended (the same combined list `_follow_cursor` already accounts for at its call site), - so a page must leave those rows in view too or it over-advances by exactly the banner and - lands the selection further down than the rows the operator actually saw. `0` (the - default) is the pre-console shape, when no binding supplied a banner.""" - if total <= 0: - return 0 - page = max(height - 3 - banner_lines, 1) - if ch in (curses_mod.KEY_UP, ord("k")): - cursor -= 1 - elif ch in (curses_mod.KEY_DOWN, ord("j")): - cursor += 1 - elif ch == curses_mod.KEY_PPAGE: - cursor -= page - elif ch == curses_mod.KEY_NPAGE: - cursor += page - elif ch == curses_mod.KEY_HOME: - cursor = 0 - elif ch == curses_mod.KEY_END: - cursor = total - 1 - return max(0, min(cursor, total - 1)) - - -def _follow_cursor(offset: int, cursor_line: int, height: int) -> int: - """The smallest change to `offset` that brings `cursor_line` back into a `height`-line - window -- scroll up to it if it is above, down to it if it is below, leave the view exactly - where it is otherwise. PURE, and total: a zero or negative `height` (a terminal mid-resize) - returns the offset unchanged rather than dividing by anything. - - "Smallest change" is the behaviour that matters: recentring on every keypress would make the - whole feed jump under the operator's eyes each time they moved one row, which is precisely - what makes a scrolling list unreadable.""" - if height <= 0: - return max(0, offset) - if cursor_line < offset: - return max(0, cursor_line) - if cursor_line >= offset + height: - return max(0, cursor_line - height + 1) - return max(0, offset) - - -def _cursor_line_index(lines: list[ScreenLine]) -> int: - """The index of the single `>`-marked cursor row in `lines`, 0 when there is none -- - the input to `_follow_cursor`'s scroll math for the cursor-driven console lists (the - Compliance menu, the scout file list), whose builders mark the selected row the same - way. Scanning beats duplicating each builder's header height here: the two can never - disagree about where the cursor is. PURE.""" - for index, line in enumerate(lines): - if line.text.startswith(">"): - return index - return 0 - - -def _visible_slice(lines: list[ScreenLine], offset: int, height: int) -> list[ScreenLine]: - """The `height`-line window of `lines` starting at `offset`, clamped so `offset` never runs - past what would leave a partial screen at the end (or before the start). PURE -- never raises - on a tiny/zero `height` or an `offset` far past the end of `lines`.""" - if height <= 0: - return [] - max_offset = max(0, len(lines) - height) - offset = max(0, min(offset, max_offset)) - return lines[offset : offset + height] - - -def _scroll_offset(ch: int, offset: int, height: int, total: int, curses_mod: Any) -> int: - """The new, clamped scroll offset for a keypress inside any of the five scrollable overlays - (help, insights, screen, propose, discover). Factored out of `run_live` because its help and - insights branches used to each hand-roll an identical ~8-line up/down/PgUp/PgDn/Home/End - chain -- copy-pasting that a further three times for the new overlays, onto a function that - was already long, would have made it worse rather than better. - - PURE: takes `curses_mod` as a parameter rather than importing `curses` itself, for two - reasons that both matter here -- `curses` is imported lazily inside `run_live` (this module - must stay importable with no real terminal present, and the pure-function tests must stay - portable), and passing it in is what lets this function be unit-tested against the SAME fake - `curses` module the rest of the `run_live` test suite already builds, with no real terminal - or `curses.wrapper` involved. - - `total` is the number of lines in the list being SCROLLED, banner included when one is - prepended (`len(banner) + len(overlay_lines)` at every call site in `run_live`): the list - this offset is applied to is the same combined list `_visible_slice` slices, so the clamp - here and the window there must agree about its length -- a banner-excluded total left `End` - short of the true last page by exactly the banner's height. Used exactly the way - `help_offset`/`insights_offset` always were: `End` jumps toward the bottom (clamped, like - every other result, to the last full page) and every key's result is clamped to `[0, max(0, - total - height)]` so the view can never scroll past either end.""" - if ch in (curses_mod.KEY_UP, ord("k")): - offset -= 1 - elif ch in (curses_mod.KEY_DOWN, ord("j")): - offset += 1 - elif ch == curses_mod.KEY_PPAGE: - offset -= max(height - 1, 1) - elif ch == curses_mod.KEY_NPAGE: - offset += max(height - 1, 1) - elif ch == curses_mod.KEY_HOME: - offset = 0 - elif ch == curses_mod.KEY_END: - offset = total - return max(0, min(offset, max(0, total - height))) - - -# -- actions (injectable, unit-testable without curses/network) ---------------------------------- - - -def toggle_autonomy(repo: Any, now_ts: int, confirm_fn: Callable[[], bool]) -> str: - """Toggle the agent's autonomy, honouring the same asymmetric gating `keel autonomy` enforces: - turning OFF de-risks and is immediate; turning ON adds capability and only happens if - `confirm_fn()` returns `True` (the live loop's `confirm_fn` is `_confirm_arm_autonomy`, a - cooked-mode typed-`yes` prompt -- but this function takes it as an injected callable so it's - testable with a stub, no curses or terminal involved). Arms with no expiry, matching `keel - autonomy on`'s default.""" - profile = repo.get_profile() - if profile.is_autonomous(now_ts): - repo.set_autonomous(False, now_ts) - return "autonomy -> OFF (every order will ask first)" - if confirm_fn(): - repo.set_autonomous(True, now_ts) - return "autonomy -> ON (orders placed WITHOUT asking)" - return "autonomy unchanged (arming cancelled)" - - -def _guarded(label: str, fn: Callable[[], str]) -> str: - """Run `fn`, returning its result -- or, if it raises, `"{label} failed: {exc}"` instead of - letting the exception kill the live loop. Only `Exception` is caught: a `KeyboardInterrupt` - (Ctrl-C) must still propagate.""" - try: - return fn() - except Exception as exc: - return f"{label} failed: {exc}" - - -# -- render + loop (thin I/O) -------------------------------------------------------------------- - - -def _style_attrs() -> dict[str, int]: - """Map each `ScreenLine.style` to a curses attribute bitmask. Uses only attribute constants - that are safe to read without a real terminal having called `initscr()` (`A_BOLD`, `A_DIM`, - ...); colour pairs are layered on top only when `curses.has_colors()` can be queried without - raising (i.e. a real terminal did initialise), so this stays callable against a fake stdscr - in tests. Any `curses.error` while querying/initialising colour support is swallowed -- the - attribute-only styling below is still a coherent, if colourless, rendering.""" - import curses - - attrs: dict[str, int] = { - "heading": curses.A_BOLD, - "alert": curses.A_BOLD | curses.A_REVERSE, - "warn": curses.A_BOLD | curses.A_UNDERLINE, - "ok": curses.A_NORMAL, - "normal": curses.A_NORMAL, - "muted": curses.A_DIM, - } - try: - if curses.has_colors(): - curses.start_color() - curses.use_default_colors() - curses.init_pair(1, curses.COLOR_RED, -1) - curses.init_pair(2, curses.COLOR_YELLOW, -1) - curses.init_pair(3, curses.COLOR_GREEN, -1) - attrs["alert"] |= curses.color_pair(1) - attrs["warn"] |= curses.color_pair(2) - attrs["ok"] |= curses.color_pair(3) - except curses.error: - pass - return attrs - - -def _paint(stdscr: Any, lines: list[ScreenLine]) -> None: - """Paint styled `lines` into a curses window, clipped to its current size so a tiny terminal - never raises `curses.error`. Testable against a fake `stdscr` (records `addstr(y, x, text, - attr)`, has `getmaxyx()`) -- no real terminal required.""" - import curses - - height, width = stdscr.getmaxyx() - attrs = _style_attrs() - stdscr.erase() - for y, line in enumerate(lines): - if y >= height: - break - max_width = max(width - 1, 0) - text = line.text[:max_width] - attr = attrs.get(line.style, curses.A_NORMAL) - try: - stdscr.addstr(y, 0, text, attr) - except curses.error: - # Classic bottom-right-corner write: some terminals raise when the cursor would - # advance past the last cell. Never fatal to the dashboard. - pass - stdscr.refresh() - - -OpenState = Callable[[], "tuple[Repository, Config]"] -NowFn = Callable[[], int] -Echo = Callable[[str], None] - -#: How long (seconds) an action's toast (`message`) stays painted on the dashboard before it is -#: cleared -- an unbounded toast can read as *current* state long after it stopped being true -#: (e.g. "autonomy -> ON" still showing after autonomy has since lapsed). -_MESSAGE_TTL_SEC = 12 - -#: The `r` toast. `r` re-reads local state and forces the live-balance re-fetch, but it used to -#: set no message at all -- and since the DB usually has not changed between two repaints, the -#: screen came back byte-identical and the key was indistinguishable from a dead one. Every other -#: action key toasts; this says what `r` did, and (unlike `f`) what it deliberately did NOT do. -#: The balance is re-read AFTER the paint, so it lands on the following frame -- hence "balance -#: updating" rather than a claim it is already showing. -_REFRESH_MESSAGE = "refreshed local state -- balance updating (no candle fetch; use [f] for that)" - -#: How often (seconds) `run_live` refreshes the live "available to buy" balance -- deliberately -#: SLOWER than the dashboard's own repaint interval (typically 5s): it is a real broker call -#: (`get_accounts`), and re-fetching it every repaint would hammer the venue for no operator -#: benefit. `r` (refresh-now) and a completed `f` (fetch) both force an immediate re-fetch by -#: resetting the cadence clock, so the balance still updates promptly on demand. -_BALANCE_REFRESH_SEC = 30 - -#: Network timeout (seconds) bounding the live balance's `get_accounts` call -- a hung request -#: must never freeze the whole dashboard indefinitely. Deliberately generous (this is a -#: background refresh, not something the operator is blocked waiting on) but finite. -_BALANCE_TIMEOUT_SEC = 10 - -#: Network timeout (seconds) bounding the discover overlay's one `list_products` call. TIGHTER -#: than `_BALANCE_TIMEOUT_SEC`'s rationale would suggest is needed, because the operator is -#: BLOCKED on this one: they pressed Enter, the screen is frozen behind a "contacting venue" -#: frame, and there is no other feedback until the call returns. A hung venue must become a -#: readable `discover failed:` line they can retry, never an indefinitely frozen dashboard whose -#: only exit is Ctrl-C (which kills the whole TUI, not just the request). -_DISCOVER_TIMEOUT_SEC = 20 - -#: What Ctrl-C does on a blocking console screen -- ONE sentence, rendered on every blocking -#: surface (the ARMED and result screens the builders paint, and the frozen notices the loop -#: paints mid-run): a frozen screen is exactly where an operator reaches for it, and it must -#: say BEFORE the press that it leaves the WHOLE console (gracefully -- `run_live` catches the -#: KeyboardInterrupt and restores the terminal) and that whatever the run was holding is gone. -CTRL_C_DISCLOSURE = ( - "during the run, Ctrl-C exits the whole console (gracefully) and discards held results" -) - -#: The frozen-run notices the loop paints while a blocking run holds it (the agent cycle, the -#: monitor poll, the fetch variants): each says the screen is frozen exactly like the CLI's -- -#: and the CYCLE one adds what happens to orders, which is the tail a one-line paint loses. -#: Module constants (not builder output) because the loop paints them directly, mid-run. -_CYCLE_RUN_NOTICE = ( - "running one agent cycle... please wait (the screen is frozen exactly like the " - "CLI; orders, if any, follow this profile's mode and confirm gates)" -) -_MONITOR_RUN_NOTICE = ( - "polling... please wait (one poll; the screen is frozen exactly like the CLI)" -) -_FETCH_RUN_NOTICE = ( - "fetching... please wait (this can take minutes; the screen is frozen exactly " - "like the CLI)" -) - - -def _refresh_balance( - open_state: OpenState, now_fn: NowFn, balance_fn: Callable[[Config], Decimal | None] -) -> AvailableBalance: - """Fetch the live "available to buy" balance -- injectable (`open_state`/`now_fn`/ - `balance_fn`) so it's testable with fakes, no curses/network/broker involved. FAIL-SOFT: any - exception anywhere (opening the repo, reading config, the broker call inside `balance_fn`) - becomes an error `AvailableBalance` with `quote="?"` (the configured quote currency itself may - be unreadable), never a raised exception -- a transient balance-read failure must never crash - the live loop. `balance_fn(config) -> Decimal | None` returning `None` covers BOTH "no matching - account" and any broker/auth/network error it swallowed internally, so the message deliberately - does not assert "no balance" -- that would wrongly tell an operator a deposit never landed when - the real cause could be an unreachable broker or bad credentials.""" - try: - _repo, config = open_state() - amount = balance_fn(config) - if amount is None: - error = ( - f"{config.quote_currency} balance unreadable " - "(no funds, or account/credentials unavailable)" - ) - return AvailableBalance(None, config.quote_currency, now_fn(), error) - return AvailableBalance(amount, config.quote_currency, now_fn(), None) - except Exception as exc: - # Truncated: this raw exception text is painted full-screen (`_available_lines`) -- keep - # a stray huge or sensitive blob (e.g. an HTTP error body) off the display. - error = str(exc)[:120] - return AvailableBalance(None, "?", now_fn(), error) - - -def run_once( - open_state: OpenState, - now_fn: NowFn, - echo: Echo, - banner_fn: Callable[[Any, Any, int], list[ScreenLine]] | None = None, -) -> None: - """Render a single frame and hand each line to `echo` -- drives `--once` (pipes/CI) and is - directly testable with fakes, no CliRunner or terminal needed. - - `banner_fn(repo, config, now_ts)` (v6, the console shell) optionally prepends the - session banner's lines to the frame -- the same header every interactive screen - carries, so a piped snapshot names its deployment and market session too. Keyword-only - and defaulted so every existing caller renders exactly as before.""" - repo, config = open_state() - now_ts = now_fn() - if banner_fn is not None: - for line in banner_fn(repo, config, now_ts): - echo(line.text) - report = gather_status(repo, config, now_ts) - for text in render_plain(report, now_ts): - echo(text) - - -def _message_style(message: str) -> str: - """`"alert"` for a message that reports a failure, arms autonomy ON, or switches the - console to the LIVE deployment -- the transitions that must never read as reassuring - green, because real money is what they change (a green `profile -> LIVE` would be the - toast colour saying all is well about real-account data starting to answer from every - screen); `"warn"` for a cancelled/unchanged action; `"ok"` for everything else (autonomy - OFF, fetch complete, a PAPER profile switch) -- purely cosmetic, so a quick glance at the - toast's colour tells you which it was.""" - lowered = message.lower() - if ( - "-> on" in lowered - or "without asking" in lowered - or "failed" in lowered - or "-> live" in lowered - ): - return "alert" - if "cancelled" in lowered or "unchanged" in lowered: - return "warn" - return "ok" - - -def _confirm_arm_autonomy(stdscr: Any, config: Config) -> bool: - """The live loop's `confirm_fn` for `toggle_autonomy`'s OFF->ON direction: suspends curses - (`def_prog_mode` -> `endwin`), runs the SAME `_require_interactive_confirmation` gate `keel - autonomy on` uses -- so the arm prompt shows the same decisive facts (mode=..., allowlist=...) - and demands a typed `yes` from a human at a terminal -- then restores the screen - (`reset_prog_mode` -> `refresh`). Fails CLOSED -- any exception anywhere in this (including - while restoring the screen) returns `False` rather than arming, since arming is the one - direction that must never happen silently.""" - import curses - - from keel.commands._common import _require_interactive_confirmation - - try: - curses.def_prog_mode() - curses.endwin() - try: - _require_interactive_confirmation( - "turn autonomy ON", - f"Orders will be placed with NO further prompt until you turn it off " - f"(mode={config.auto_trade.mode}, allowlist={config.allowlist}). " - f"Tip: prefer a supervised window via `keel autonomy on --for-hours N`.", - ) - return True - finally: - curses.reset_prog_mode() - stdscr.refresh() - except Exception: - return False - - -def _confirm_live_profile(stdscr: Any, profile: Any) -> bool: - """The Profile menu's LIVE guard (issue #388 C2): suspends curses and asks an explicit - y/N at the terminal -- the same suspend/restore dance `_confirm_arm_autonomy` keeps, - over `click.confirm` rather than the typed-word gate. - - Deliberately a confirm STEP, not O3's typed contract: pointing the console at the live - deployment changes what the operator is LOOKING at, not what the engine does (the - running agent keeps its own pair), so the ceremony matches `click.confirm`'s, not - `resume`'s. Fails CLOSED -- any exception anywhere here (including while restoring the - screen, or a Ctrl-C/EOF out of `click.confirm`) returns `False` and the binding stays - exactly where it was.""" - import curses - - try: - curses.def_prog_mode() - curses.endwin() - try: - return click.confirm( - f"Switch the console to the LIVE deployment " - f"({profile.config_path} + {profile.db_path})? " - "Real-account data answers from here on", - default=False, - ) - finally: - curses.reset_prog_mode() - stdscr.refresh() - except Exception: - return False - - -def _do_fetch(open_state: OpenState, now_fn: NowFn) -> str: - """Fetch fresh candle history for every allowlisted product, money-safe (data only, never - places an order). Lazy-imports the fetch primitives from `keel.data.history`/ - `keel.commands._common`/`keel.commands._products` to avoid a `cli` <-> `tui` import cycle at - module load time. Thin I/O -- not unit-tested directly, only smoke-tested via `run_live`.""" - from keel.commands._common import _build_broker - from keel.commands._products import _default_sim_products - from keel.data import history as history_mod - - repo, config = open_state() - products = _default_sim_products(config) - client = _build_broker(config) - now_ts = now_fn() - years = 5 # matches `keel fetch --years`'s own default - # The same config-driven list `keel fetch` warms (Issue #349): this is the dashboard twin - # of THAT command, not of `simulate`, so the engine's ONE_HOUR/ONE_DAY limit does not - # apply -- the FIFTEEN_MINUTE confirmation series must warm here too. - granularities = list(config.market_data.granularities) - history_mod.ensure_history( - client, - repo, - products, - granularities, - years, - now_ts, - sleep_fn=time.sleep, - ) - return f"fetch complete ({len(products)} products, {years}y history)" - - -def _do_screen_report(open_state: OpenState) -> ScreenReport: - """Build a fresh `ScreenReport` for the current allowlist -- OFFLINE, DB reads only, rebuilt - every poll while the screen overlay is open, exactly like insights' per-poll rebuild. - `screen_product` (THE single admission gate every candidate source must route through) lives - in `keel.commands.assets` (issue #387 C1) -- the shared service layer, importable here with - no `keel.cli` cycle and no lazy-import dodge: one gate, two front-ends. - Thin I/O -- not unit-tested directly, only smoke-tested via `run_live`.""" - from keel.commands.assets import screen_product - - repo, config = open_state() - return build_screen_report(repo, config, screen_product) - - -def _do_compliance_payload(open_state: OpenState, now_fn: NowFn, kind: str) -> Any: - """Gather the payload one Compliance view renders (issue #389 C3): the OFFLINE kinds - only -- a service report, rebuilt each poll, fail-soft in the caller. The two - network-gated kinds (holdings/discover) are NOT gathered here; they go through - `_do_compliance_network` behind an explicit Enter, the discover overlay's own gating - story. Thin I/O -- every branch dispatches to a service function; nothing is computed - here.""" - from keel.commands import compliance_console - - if kind == "screen": - return _do_screen_report(open_state) - if kind == "propose": - return _do_propose_view(open_state) - repo, config = open_state() - if kind == "purification": - from keel.commands.purification import render_purification_report - from keel.compliance.purification import build_report - - return render_purification_report(build_report(repo.get_transactions())) - if kind == "subscription": - from keel.commands.subscription import subscription_show_lines - - return subscription_show_lines(repo, config, now_fn()) - if kind == "shariah": - from keel.commands.assets import gather_attestations_in_force - from keel.execution.executor import _withdrawals_enabled - - inventory = gather_attestations_in_force(repo, config) - return compliance_console.build_shariah_lines( - inventory, withdrawals_enabled=_withdrawals_enabled(repo, now_fn()), now_ts=now_fn() - ) - raise ValueError(f"unknown offline compliance view: {kind}") - - -def _do_compliance_network(open_state: OpenState, kind: str) -> Any: - """The ONE live call each network-gated Compliance view makes, run only from an - explicit Enter (issue #389 C3): `get_accounts` for holdings (the same balance read - the dashboard's live-balance line makes) or `list_products` for discover (the same - product-metadata read `_do_discover_report` makes). Both are BOUNDED, for the same - reason `_refresh_balance`/`_do_discover_report` are: the operator is actively waiting - on a frozen screen. Thin I/O -- the report compute is the service's. - - The discover payload is `assets.run_discovery(client, products, config)` -- the SAME - sweep `keel assets discover` runs, because the view renders it through - `assets.render_discover` (`DiscoverSweep`'s own renderer). `admission. - build_discover_report`'s `DiscoverReport` is NOT that shape: it used to be handed to - `render_discover` here and the successful-Enter frame died on an `AttributeError` - (`DiscoverReport has no survivor_count`) -- the ARMED and error frames hid it, because - only a successful Enter ever reached the render.""" - from keel.commands._common import _build_broker - - if kind == "discover": - from keel.commands.assets import run_discovery - - _repo, config = open_state() - client = _build_broker(config, timeout=_DISCOVER_TIMEOUT_SEC) - return run_discovery(client, client.list_products(), config) - if kind == "holdings": - from keel.commands.assets import gather_holdings - - repo, config = open_state() - # min-balance 0 and no screen: `keel assets holdings`'s own defaults. - accounts = _build_broker(config, timeout=_BALANCE_TIMEOUT_SEC).get_accounts() - return gather_holdings(repo, config, accounts, Decimal("0")) - raise ValueError(f"unknown network compliance view: {kind}") - - -def _run_terminal_form(stdscr: Any, fn: Callable[[], str]) -> str: - """Run one terminal interaction inside the console session: suspend curses (the - `def_prog_mode` -> `endwin` -> `reset_prog_mode` dance - `_confirm_arm_autonomy`/`_confirm_live_profile` keep -- this is its one shared - generic copy, the seam every console's forms AND the agent-cycle dispatch ride), - let the form's prompts and O3's typed gates render in-console, restore the screen, - and return the form's result line (the confirmation of what was written). A form - that cannot run at all (an exception outside its own fail-soft handling) becomes a - readable toast, never a crash of the loop.""" - import curses - - try: - curses.def_prog_mode() - curses.endwin() - try: - return fn() - finally: - curses.reset_prog_mode() - stdscr.refresh() - except Exception as exc: - return f"form failed: {exc}"[:200] - - -def _run_notice_lines(notice: str) -> list[ScreenLine]: - """One frozen-run notice, WRAPPED to the 78-column budget the console builders keep: - `_paint` CLIPS at the window width, and the loop-painted notices are long (the - cycle's is 144 chars) -- painted as one line, the tail (the part that says what - happens to orders) is exactly what an 80-column terminal loses. The Ctrl-C - disclosure rides every frozen notice (a blocked screen is where an operator - reaches for it). PURE.""" - lines = [ - ScreenLine(wrapped, "normal") for wrapped in textwrap.wrap(notice, width=78) or [""] - ] - lines.append(_blank()) - lines.extend( - ScreenLine(wrapped, "muted") - for wrapped in textwrap.wrap(CTRL_C_DISCLOSURE, width=78) or [""] - ) - return lines - - -def _do_propose_view(open_state: OpenState) -> ProposeView: - """Build a fresh `ProposeView` over the newest shortlist in `config.proposals_dir` -- OFFLINE - (DB + local filesystem reads only), rebuilt every poll while the propose overlay is open. - `screen_product` comes from `keel.commands.assets` for the identical reason - `_do_screen_report` names: the shared gate, not a `keel.cli` import. - - `build_propose_view` is fail-soft about the shortlist FILE -- a missing directory, a - permissions error, a non-UTF-8 file, invalid JSON, a malformed top-level shape all come back - as a `status`/`detail` pair. That is not the same as "this function cannot raise", and the - earlier claim that any exception here could only come from `open_state()` was simply wrong: - it once let a `UnicodeDecodeError` from the read itself through (that specific hole is now - closed -- see `build_propose_view`'s own `except (OSError, UnicodeDecodeError)`), and - `build_propose_view` still SCREENS every parsed candidate afterwards, which means real DB - reads through `screen_product`. A locked DB, mid-screen, surfaces here exactly like one from - `open_state()` does. The caller's `try/except` is load-bearing for both.""" - from keel.commands.assets import screen_product - - repo, config = open_state() - return build_propose_view(repo, config, screen_product) - - -def cached_scout_view( - repo: Any, - config: Any, - screen_fn: Any, - path: Path, - cache: dict[tuple[str, int], ProposeView], -) -> ProposeView: - """`build_propose_view` for the scout browser, cached per (path, mtime_ns): the scout - view repaints every poll, and re-reading, re-parsing and re-SCREENING the same - unchanged file through the admission gate each poll (a DB read per candidate per - poll, and an unbounded read besides -- `build_propose_view` now bounds it at 1 MiB) - is pure waste. A file whose mtime changed (or could not be stat'd -- it is rebuilt, - fail-soft, rather than served stale) refreshes. The cache is the CALLER'S dict, single - purpose: one shortlist is open at a time, and the caller clears it when a write makes - the screening stale (the `a` attest step) so a fresh attestation re-screens at once. - - PURE over its inputs (the filesystem aside); `build_propose_view` itself never raises - for file problems, so an exception here is a repo problem the caller already fail-softs.""" - try: - mtime_ns = path.stat().st_mtime_ns - except OSError: - mtime_ns = -1 # unstatable: a key that can never be re-hit, so it never caches - key = (str(path), mtime_ns) - if key in cache: - return cache[key] - view = build_propose_view(repo, config, screen_fn, path=path) - cache.clear() # single entry: only the open shortlist is worth holding - cache[key] = view - return view - - -def _do_discover_report(open_state: OpenState) -> DiscoverReport: - """THE one network call anywhere in the discover overlay -- `_build_broker(config). - list_products()` -- followed by the PURE `build_discover_report`, which turns the venue's raw - product list into candidates. Called ONLY from `run_live`'s discover branch, and only from - its Enter-key handler: never on opening the overlay, never on an ordinary poll while it stays - open. `_build_broker` is lazy-imported from `keel.commands._common`, mirroring `_do_fetch`'s - own lazy broker import, so a test can monkeypatch `keel.commands._common._build_broker` (to - record calls, or to raise if called at all) and prove this function -- and therefore this - whole overlay -- was, or crucially was NOT, ever invoked. Thin I/O -- not unit-tested - directly, only smoke-tested via `run_live`. - - BOUNDED by `_DISCOVER_TIMEOUT_SEC`, for the reason `_refresh_balance` is bounded and `_do_fetch` - deliberately is not: this is a single, small metadata request the operator is actively waiting - on with the screen frozen behind a "contacting venue" frame, so a hung connection must fail and - say so rather than freeze the dashboard until Ctrl-C. (`f` fetch legitimately runs for minutes - pulling 5y of candles, which is why it is documented as freezing the dashboard instead of being - given a timeout that would abort honest work.)""" - from keel.commands._common import _build_broker - - _repo, config = open_state() - products = _build_broker(config, timeout=_DISCOVER_TIMEOUT_SEC).list_products() - return build_discover_report(products, config) - - -def run_live( - open_state: OpenState, - now_fn: NowFn, - interval: float, - console_binding: Any | None = None, -) -> None: - """The auto-refreshing, interactive dashboard: `curses.wrapper` a loop that re-opens the repo - (via `open_state`) every poll -- so it reflects writes committed by a separate `keel agent` - process -- gathers a fresh report, paints it, then waits up to `interval` seconds for a - keypress. - - Ten modes when `console_binding` is supplied (v6, issue #388 C2 -- the console shell), - the seven below plus `menu` (the PRD §3 tree over `console.build_menu_lines`), `profile` - (the deployment menu over `console.build_profile_menu_lines`, switching through - `console.switch_profile` with `_confirm_live_profile` guarding the LIVE pair) and - `placeholder` (a future slice's "lands in Cx" notice). EVERY screen -- all ten -- is - prepended the session banner (`console.console_banner_lines`, fail-soft), and `m` in - normal mode opens the menu. `console_binding is None` is the pre-C2 dashboard - byte-for-byte: no banner, no `m`, none of the new modes -- which is what every caller - that predates the shell (and every pre-existing test) passes. - - C3 (issue #389) adds the Compliance sub-tree's four modes, all console-bound only: - `compliance` (the sub-menu; a form entry runs its form at the TERMINAL through - `_run_terminal_form` -- curses suspended, O3's typed gates rendering in-console -- and - the write's confirmation line toasts on the menu), `compliance-view` (one service - report overlay; the offline kinds rebuild per poll fail-soft, the network kinds -- - holdings/discover -- open ARMED and hold what an explicit Enter fetched), - `scout-list`/`scout-view` (the scout-results browser, O6: list the config-named - proposals directory, screen the chosen shortlist through the admission services, and - offer -- never auto-run -- the TYPED attest step for a selected candidate). - - C4 (issue #390) adds the strategy console and the research readers, all - console-bound only: `strategy` (the Rules sub-menu over - `strategy_console.STRATEGY_MENU`; forms run at the terminal like Compliance's), - `strategy-ledger` (the tried-vs-used ledger -- every rule row with its RECORDED - context, read CHEAPLY once on entry: zero backtests run there, because the - entry-time re-backtest this view shipped with cost minutes per rule on long - series), `strategy-rule` (one rule's detail: params rendered through - `describe_params`, the O8 per-field help, and the rule's backtest verdict -- ARMED - until an explicit Enter, the ONE place the strategy console runs a rule backtest: - warned first, honestly blocking while it runs like simulate/fetch, held in - `strategy_verdicts` and rendered under its row once computed), - `strategy-simulate` (the O11.1 ARMED view -- Enter is the confirm - step; the run fetches/writes exactly as `keel simulate` does, blocks the loop exactly - like `f` fetch, and its verdict+report render under a pinned footer), and the four - `research*` modes (the O5 evidence readers over `research_console`: a corpus list, a - bounded mtime-cached document view, and the trials ledger with its chain verdict -- - all read-only). The strategy menu's `insights` entry opens the pre-existing insights - overlay with a BACK-POINTER so it closes onto the Rules menu, not the dashboard. - - Seven modes: `normal` (the dashboard, plus a transient one-line `message` toast from the last - action), `help` (a scrolled window of `build_help_screen()`), `insights` (a scrolled window of - `build_insights_screen()` -- a READ-ONLY overlay over `build_insights_report`/ - `build_journal_report`, rebuilt fresh each poll while open, fail-soft exactly like the - normal-mode status read below), `screen` and `propose` (the OFFLINE admission-workflow - overlays, `build_admission_screen_overlay`/`build_propose_overlay` over `_do_screen_report`/ - `_do_propose_view`, rebuilt fresh each poll while open, fail-soft the same way insights is), - `discover` (the one overlay that touches the network -- see below), and `activity` (the - chronological, one-row-per-cycle feed over the engine LOG rather than the DB -- also OFFLINE, - also rebuilt each poll, and also fail-soft, though `build_activity_feed` is itself total so - the handler's `try` only ever catches `open_state()` failing). Five of the six scrollable - overlays share one scrolling helper, `_scroll_offset`; `activity` is the exception, driving - `_activity_cursor` + `_follow_cursor` instead because its up/down keys move a SELECTED ROW - (which may be one line or seventy) rather than one line of a fixed body. The console's - `menu`/`profile` modes move a selected row the same way `activity` does, and `placeholder` - does not scroll (a notice is one screen). - - C5 (issue #391) adds the Trading and Data menus, all console-bound only: `trading` - (the sub-menu over `trading_console.TRADING_MENU`; forms -- autonomy, record-flow, - reset-hwm, resume-entries, resume -- run at the TERMINAL like Compliance's, so O3's - typed gates render in-console through the suspend/restore dance; kill dispatches - IMMEDIATELY, per its own one-key CLI contract), `trading-cycle` (the agent-cycle - ARMED view -- Enter is the confirm step: the screen names the profile, the mode's - paper/confirm semantics and the autonomy state, plus the SESSION HONESTY line from - the recorded B1 state; the run itself is `agent.run_once` with the CLI's own - `_interactive_confirm`, so there is no TUI-originated order path, and its result - lines -- a skip's logged reason verbatim -- are held), `trading-monitor` (one poll, - the same ARMED story over `monitor_cycle`), `data` (the sub-menu over - `data_console.DATA_MENU`), `data-fetch` (the fetch / fetch --check / repair-gaps - ARMED views: the plan -- products x granularities x window from the ACTIVE profile -- - shows first, Enter runs `run_fetch` itself, blocking, with the streamed lines held - and a check run's verdict pinned under the scroll), and `data-freshness` (the - offline freshness overview, rebuilt per poll, a check sweep that never constructs a - broker). - - C7 (issue #394) adds the Venues browser and the help & glossary system, all - console-bound only: `venues` (under Profile -- every installed adapter's declared - capabilities from the ONE `keel.commands.brokers` service payload, the SELECTED - adapter highlighted and the active deployment's binding named; the payload is read - ONCE per entry, never per poll), `help-menu` (the Help entry is a real sub-menu: - the glossary, the screens & actions catalog, the parameter help, the keys/safety - notes -- the pre-C7 help screen is its fourth entry), `help-glossary` / - `help-params` / `help-params-kind` / `help-screens` (docs/glossary.md read BOUNDED - and mtime-cached like a research doc; parameter help DELEGATED whole to - `rules.describe_params`, never a duplicate table), and `context-help` -- the O8 - overlay: `?` in EVERY console mode opens that mode's own help rows from the owning - module's `CONTEXT_HELP` (`keel.commands.help_console` is the registry), and q/Esc/? - returns to the screen it was opened from. Normal mode's `h` now opens the Help - MENU, and its `?` opens the dashboard's own contextual help. - - C6 (issue #392) lands the Account menu -- the tree's last placeholder -- all - console-bound only: `account` (the sub-menu over `account_console.ACCOUNT_MENU`: - pnl and versions as read-only views, plus the #415 update entry), `account-pnl` - (the EXACT `keel pnl` report through the C1 services over the active deployment's - imported transactions, offline and rebuilt per poll, with the honest empty state - when nothing is imported), `account-versions` (the same lines `keel versions` - prints through the ONE shared renderer, its environment scan run ONCE per entry - and the rows held -- the venues browser's contract), and `account-update` - (issue #415's self-update view: the entry-time release check runs ONCE and the - plan is held; Enter opens the CLI's OWN typed gate at the terminal through the - suspend dance, the blocking run's lines are held, and a verified success execv's - the new build's keel entry -- the process is replaced, terminal already - restored). The subprocess/HTTP/execv orchestration is all the update service's - (`keel.commands.update`); this loop only renders and asks. - - Normal-mode keys: `q`/`Q` quit; `h` opens the Help menu; `?` the contextual help; - `i` open insights; `s` open screen; `p` - open propose; `d` open discover; `v` open activity; `m` open the console menu (when a - console binding was supplied); `r` refresh now; `a` toggle autonomy - (`toggle_autonomy`, - gated by `_confirm_arm_autonomy` on the OFF->ON direction only); `f` fetch all data - (`_do_fetch`, money-safe). `a` and `f` are both wrapped in `_guarded` so a failure becomes a - toast, never a crash. help/insights/screen/propose all scroll (`up`/`k`, `down`/`j`, - `PgUp`/`PgDn`, `Home`/`End`) and close back to normal on `q`/`Esc`/. - `activity` binds the same keys to moving its selected row, and adds Enter/Space to expand or - collapse that row's cycle into its individual events, plus `t` to cycle the day scope - (`today` -> `7d` -> `all`). `t` was free: `q Q h ? i r a f s p d v m` are the dashboard's keys, - `k`/`j`/Enter/Space the in-overlay ones, and nothing bound `t` anywhere. The scope is reset to - `today` both when `v` opens the overlay and when any close key leaves it, so it can never - become sticky across visits. - - `discover` is different on purpose, and is the whole reason this docstring calls out the - overlays individually rather than treating them identically: it needs the network, and that - network call - must never fire just from pressing `d`. Opening it renders an ARMED, not-yet-run explanation - (`build_discover_overlay(None)`) with NO call made. Only Enter (`10`/`13`/`curses.KEY_ENTER`), - pressed INSIDE the overlay, runs `_do_discover_report` -- the one - `_build_broker(config).list_products()` call in this entire workflow. The result (or error) - is then HELD in `discover_result`/`discover_error`: every later poll while the overlay stays - open just repaints what is held, with NO further network calls, until Enter is pressed again. - Closing the overlay (`q`/`Esc`/`d`) discards the held result, so reopening it is armed but - not yet run again. `discover` still scrolls with the same keys as the other four. - - The console shell adds NO network touch of its own: the banner reads the repo and the - adapter's offline capabilities declaration, profile switching reads local files, and a - switch RESETS the held live-balance line (it described the previous venue's account). - - Also refreshes the live "available to buy" balance (`_refresh_balance`) on its own slow - cadence (`_BALANCE_REFRESH_SEC`, not every repaint -- it's a real broker call), and - immediately on `r`. Between that automatic slow-cadence read, `f` fetch, and now `d`+Enter, - those are the only three places this whole dashboard ever touches the network -- everything - else, including all of `screen`/`propose`/`discover`'s own reads, is DB/filesystem-only. - - C3's Compliance menu adds two more EXPLICITLY-GATED network touches, both behind their - own Enter inside an ARMED view (never on open, never on a poll): the holdings view's - one `get_accounts` (the same read the slow-cadence balance line makes) and the discover - view's one `list_products` (the same read `d`+Enter makes, reached from the menu). - Both are bounded (`_BALANCE_TIMEOUT_SEC`/`_DISCOVER_TIMEOUT_SEC`) and both hold their - result until re-Enter or close, the discover overlay's own story. - - Quits on `q`/`Q` in normal mode; a `KeyboardInterrupt` (Ctrl-C) exits gracefully rather than - dumping a traceback onto a terminal `curses.wrapper` may not have fully restored.""" - import curses - - # Lazy import, the established cycle-dodge for this module (see `insights`): console - # imports THIS module at load time (its builders speak `ScreenLine`), so importing it - # here keeps the two modules loadable in either order. The strategy/research consoles - # follow the same rule (both speak `ScreenLine` and dispatch to the service layer), - # and so do the C5 trading/data consoles, and the C7 help console (the O8 registry, - # which resolves THIS module's own CONTEXT_HELP rows) and the brokers service (the - # O7 payload the Venues browser renders). - from keel.commands import ( - account_console, - brokers, - compliance_console, - console, - data_console, - help_console, - research_console, - strategy_console, - trading_console, - update, - ) - - def _balance_fn(cfg: Config) -> Decimal | None: - # Lazy imports -- `keel.commands._common` and `keel.execution.executor` both import - # (transitively) from `keel.cli`/`keel.commands`, so importing them at module load time - # would create an import cycle with this module. `_fetch_available_quote` is the EXACT - # live-balance read rail 13 funds a buy against, reused verbatim so the TUI and the rail - # never disagree. - from keel.commands._common import _build_broker - from keel.execution.executor import _fetch_available_quote - - return _fetch_available_quote( - _build_broker(cfg, timeout=_BALANCE_TIMEOUT_SEC), cfg.quote_currency - ) - - def _loop(stdscr: Any) -> None: - try: - curses.curs_set(0) - except curses.error: - # Not every terminal has a hideable cursor -- never fatal to the dashboard. - pass - - def _console_banner() -> list[ScreenLine]: - """The session banner for the screen about to be painted -- O9's per-screen - header, read fresh each poll from the RECORDED state. Empty when no console - binding was supplied (the pre-C2 dashboard, unchanged), and FAIL-SOFT with a - warn line when the read itself fails: a banner read must never take the screen - it heads down with it.""" - if console_binding is None: - return [] - try: - banner_repo, banner_config = open_state() - return console.console_banner_lines( - console_binding, banner_repo, banner_config, now_fn() - ) - except Exception as exc: - return [ScreenLine(f"console header read failed: {exc}", "warn")] - - mode = "normal" - help_offset = 0 - insights_offset = 0 - screen_offset = 0 - propose_offset = 0 - discover_offset = 0 - activity_offset = 0 - activity_cursor = 0 - menu_cursor = 0 - profile_cursor = 0 - placeholder_entry: console.MenuEntry | None = None - # ALWAYS reset to `today` on open (below), never carried across one -- see this module's - # docstring. A widened scope answers a question the operator asked once; making it sticky - # would quietly turn "what is keel doing" back into "here is a fortnight of scrollback". - activity_scope = DEFAULT_ACTIVITY_SCOPE - # Keyed by `ActivityCycle.key`, NOT by row index: the feed is rebuilt from the log every - # poll, so a new cycle appearing at the top would silently shift every index down one and - # expand the wrong row. The key is stable across rebuilds, so an expanded cycle stays - # expanded even as the feed grows underneath it. - activity_expanded: frozenset[str] = frozenset() - discover_result: DiscoverReport | None = None - discover_error: str | None = None - # -- the Compliance menu (issue #389 C3): sub-menu cursor, the open view's kind, - # its held network result (holdings/discover open ARMED and hold what Enter ran), - # and the scout browser's listing/selection state. - compliance_cursor = 0 - compliance_offset = 0 - compliance_view_kind: str | None = None - compliance_result: Any = None - compliance_error: str | None = None - scout_files: tuple[Any, ...] = () - scout_dir: Any = None - scout_cursor = 0 - scout_selected: Any = None - scout_view_offset = 0 - scout_candidate_cursor = 0 - # The menu's own scroll offset (the sub-menu is 15 entries -- more rows than a - # small terminal once every description fits the 80-column budget), the scout - # LIST's (older shortlists sit below the fold on any but a huge window), and the - # scout view's per-(path, mtime) cache (`cached_scout_view` -- repaints do not - # re-screen an unchanged file). - compliance_menu_offset = 0 - scout_list_offset = 0 - scout_view_cache: dict[tuple[str, int], ProposeView] = {} - # -- the strategy console (issue #390 C4 / PRD O11): the Rules sub-menu's cursor, - # the ledger's HELD rows (built CHEAPLY once per entry -- recorded state only, - # ZERO backtests; the entry-time re-backtest this view shipped with cost minutes - # per rule on long series), the per-rule verdicts the operator EXPLICITLY - # re-computes (Enter-gated in the rule's detail; held here and dropped with the - # rows when the ledger is re-entered -- the rules-table-write invalidation), the - # simulate view's ARMED/held state (Enter is the confirm step, the discover - # overlay's gating story), and the research readers' list/selection state. - strategy_cursor = 0 - strategy_menu_offset = 0 - strategy_ledger: list[Any] = [] - strategy_ledger_built = False - strategy_ledger_cursor = 0 - strategy_ledger_offset = 0 - strategy_rule_detail: Any = None - strategy_rule_offset = 0 - strategy_verdicts: dict[int, Any] = {} - strategy_simulate_plan: Any = None - strategy_simulate_result: Any = None - strategy_simulate_error: str | None = None - strategy_simulate_progress: list[str] = [] - strategy_simulate_offset = 0 - # -- the research readers (issue #390 C4 / PRD O5) - research_cursor = 0 - research_menu_offset = 0 - research_files: tuple[Any, ...] = () - research_dir: Any = None - research_title = "" - research_list_cursor = 0 - research_list_offset = 0 - research_doc_path: Any = None - research_doc_cache: dict[tuple[str, int], list[str]] = {} - research_doc_offset = 0 - research_trials_offset = 0 - # -- the Trading menu (issue #391 C5): the sub-menu's cursor, and the two ARMED - # views' plan/held-result state (the agent cycle and the monitor poll -- Enter - # is the confirm step, the discover overlay's gating story; the run blocks the - # loop exactly like simulate/fetch and its rendered result is held until close - # re-arms the view). - trading_cursor = 0 - trading_menu_offset = 0 - trading_cycle_plan: Any = None - trading_cycle_result: Any = None - trading_cycle_error: str | None = None - trading_cycle_offset = 0 - trading_monitor_plan: Any = None - trading_monitor_result: Any = None - trading_monitor_error: str | None = None - trading_monitor_offset = 0 - # -- the Data menu (issue #391 C5): the sub-menu's cursor, the fetch variants' - # ARMED plan/held state (fetch / fetch --check / repair gaps, one mode branch), - # and the freshness overview's scroll offset (the view itself is offline and - # rebuilt per poll, the offline views' contract). - data_cursor = 0 - data_menu_offset = 0 - data_fetch_plan: Any = None - data_fetch_progress: list[str] = [] - data_fetch_result: Any = None - data_fetch_error: str | None = None - data_fetch_offset = 0 - data_freshness_offset = 0 - # -- the Account menu (issue #392 C6): the sub-menu's cursor/scroll, the pnl - # view's scroll (the view itself is offline and rebuilt per poll), and the - # versions view's HELD rows -- the environment scan (an importlib.metadata walk - # through build_info/check_install) runs ONCE per entry and every repaint - # renders what is held, the Venues browser's contract, never per poll. - account_cursor = 0 - account_menu_offset = 0 - account_pnl_offset = 0 - account_versions_rows: list[tuple[str, bool]] | None = None - account_versions_offset = 0 - # the update view's HELD state (the versions view's contract): the plan (or - # the check's error), the streamed step lines, and the finished result -- - # read/computed ONCE, never per poll. `account_update_relaunch` is the view's - # ONE extra state: a verified update whose execv FAILED, its relaunch closure - # held so Enter retries ONLY the execv (the update is installed and verified; - # re-running it would re-download and re-install for nothing). - account_update_plan: Any = None - account_update_error: str | None = None - account_update_progress: list[str] = [] - account_update_result: Any = None - account_update_offset = 0 - account_update_relaunch: Any = None - # -- the Venues browser (issue #394 C7 / PRD O7): the O7 payload, read ONCE per - # entry (an importlib-metadata scan plus one credential-less adapter - # construction per adapter -- the same walk `venue_session_bound` makes, never - # per poll), with the ACTIVE deployment's binding facts (selected venue, - # endpoint/data feed where the venue declares them) resolved at the same moment - # so the browser's header cannot describe a pair other than the one bound. - venues_infos: list[Any] = [] - venues_selected: str | None = None - venues_endpoint: str | None = None - venues_data_feed: str | None = None - venues_offset = 0 - # -- the Help menu and the contextual overlay (issue #394 C7 / PRD O8): the - # Help sub-menu's cursor, the glossary's per-mtime cache (the research doc - # view's contract -- repaints do not re-read an unchanged file), the parameter - # help's kind list/detail state (the detail holds the chosen kind; the rows - # themselves always render fresh through describe_params), the consolidated - # screens catalog's scroll, and the `?` overlay's remember-where-it-was-opened - # mode + scroll. - help_cursor = 0 - help_menu_offset = 0 - glossary_cache: dict[tuple[str, int], list[Any]] = {} - glossary_offset = 0 - help_params_cursor = 0 - help_params_kind: str | None = None - help_params_offset = 0 - help_screens_offset = 0 - context_help_for: str = "normal" - context_help_offset = 0 - # Where the insights overlay closes back to: the dashboard by default, the Rules - # menu when the strategy console opened it (the shell is a hierarchy). - insights_back = "normal" - message: str | None = None - message_ts = 0 - available: AvailableBalance | None = None - last_balance_ts = 0 - - def _toast_ttl() -> str | None: - """The current toast, if it has not aged out -- the compliance screens render - it too (a write's confirmation belongs on the screen it was invoked from).""" - if message is not None and now_fn() - message_ts <= _MESSAGE_TTL_SEC: - return message - return None - - def _form_prompt(text: str) -> str: - """One form field, asked at the terminal while curses is suspended -- the - prompt side of every Compliance form (the typed gates run their own - `click.prompt` inside `_require_interactive_confirmation`, unchanged).""" - return click.prompt(text, default="", show_default=False) - - def _run_form_at_terminal(form_target: str) -> None: - """A record-write entry: open the state through the console's own loaders and - run the form through `compliance_console.run_form` -- THE dispatch seam, the - same function the unit tests drive -- inside the suspend/restore dance. The - form's result line (confirmation or cancellation) becomes the toast.""" - nonlocal message, message_ts - - form_repo, form_config = open_state() - message = _run_terminal_form( - stdscr, - lambda: compliance_console.run_form( - form_target, form_repo, form_config, _form_prompt, now_fn() - ), - ) - message_ts = now_fn() - - def _enter_compliance_entry(entry: compliance_console.ComplianceEntry) -> None: - """Where a Compliance selection goes -- the same closed-mapping rule - `_enter_menu_entry` keeps, for the sub-tree: a view (the network-gated ones - open ARMED), the scout browser, or a form run right here at the terminal.""" - nonlocal mode, compliance_view_kind, compliance_result, compliance_error - nonlocal compliance_offset, scout_files, scout_dir, scout_cursor - nonlocal scout_selected, scout_view_offset, scout_candidate_cursor - nonlocal scout_list_offset - if entry.kind == "view": - compliance_view_kind = entry.target - compliance_result = None - compliance_error = None - compliance_offset = 0 - mode = "compliance-view" - elif entry.kind == "scout": - _scout_repo, scout_config = open_state() - scout_files, scout_dir = compliance_console.scout_listing(scout_config) - scout_cursor = 0 - scout_list_offset = 0 - scout_selected = None - mode = "scout-list" - else: - _run_form_at_terminal(entry.target) - - def _run_strategy_form_at_terminal(form_target: str) -> None: - """A strategy-console form entry: open the state through the console's own - loaders and run the form through `strategy_console.run_*` inside the - suspend/restore dance -- the same seam the Compliance forms use, so the O3 - typed gates (the retry `--force` phrase) render in-console.""" - nonlocal message, message_ts - - form_repo, form_config = open_state() - - def _run() -> str: - if form_target == "add": - return strategy_console.run_add_form( - form_repo, form_config, _form_prompt, now_fn() - ) - if form_target == "retry": - return strategy_console.run_retry_form( - form_repo, form_config, _form_prompt, now_fn() - ) - if form_target == "enable": - return strategy_console.run_enable_form( - form_repo, form_config, _form_prompt, now_fn() - ) - if form_target == "disable": - return strategy_console.run_disable_form( - form_repo, form_config, _form_prompt, now_fn() - ) - if form_target == "demote": - return strategy_console.run_demote_form( - form_repo, form_config, _form_prompt, now_fn() - ) - raise ValueError(f"unknown strategy form: {form_target}") - - message = _run_terminal_form(stdscr, _run) - message_ts = now_fn() - - def _enter_strategy_entry(entry: Any) -> None: - """Where a Rules selection goes -- the closed-mapping rule again: the ledger - (a CHEAP recorded-state read -- no backtest runs on entry; the per-rule - verdict is the detail view's explicit Enter-gated re-compute), the ARMED - simulate view, a form at the terminal, or the insights overlay with a - back-pointer to this menu.""" - nonlocal mode, strategy_ledger, strategy_ledger_built, strategy_ledger_cursor - nonlocal strategy_ledger_offset, strategy_rule_detail, strategy_rule_offset - nonlocal strategy_verdicts - nonlocal strategy_simulate_plan, strategy_simulate_result - nonlocal strategy_simulate_error, strategy_simulate_progress - nonlocal strategy_simulate_offset, insights_back - nonlocal message, message_ts, insights_offset - if entry.kind == "view": # the tried-vs-used ledger - try: - ledger_repo, ledger_config = open_state() - strategy_ledger = strategy_console.build_strategy_ledger( - ledger_repo, ledger_config, now_fn() - ) - except Exception as exc: - strategy_ledger = [] - message = f"ledger read failed: {str(exc)[:160]}" - message_ts = now_fn() - return - # Re-entering rebuilds the rows from the rules table AND drops every held - # verdict -- a rules-table write between visits can never keep a stale - # verdict on a changed row. - strategy_verdicts = {} - strategy_ledger_built = True - strategy_ledger_cursor = 0 - strategy_ledger_offset = 0 - strategy_rule_detail = None - mode = "strategy-ledger" - elif entry.kind == "armed": # simulate: opens ARMED, Enter confirms and runs - sim_repo, sim_config = open_state() - strategy_simulate_plan = strategy_console.simulate_plan( - sim_config, console_binding.db_path if console_binding else "?", - now_ts=now_fn(), - ) - strategy_simulate_result = None - strategy_simulate_error = None - strategy_simulate_progress = [] - strategy_simulate_offset = 0 - mode = "strategy-simulate" - elif entry.kind == "insights": - insights_back = "strategy" - mode = "insights" - insights_offset = 0 - else: - _run_strategy_form_at_terminal(entry.target) - - def _enter_research_entry(entry: Any) -> None: - """Where a Research selection goes: a corpus list (read on entry, the scout - browser's contract) or the trials-ledger view.""" - nonlocal mode, research_files, research_dir, research_title - nonlocal research_list_cursor, research_list_offset, research_trials_offset - if entry.kind == "trials": - research_trials_offset = 0 - mode = "research-trials" - else: - research_title = entry.target - research_dir = research_console.corpus_path(entry.target) - research_files = research_console.list_documents(research_dir) - research_list_cursor = 0 - research_list_offset = 0 - mode = "research-list" - - def _run_trading_form_at_terminal(form_target: str) -> None: - """A Trading-menu form entry: open the state through the console's own - loaders and run the form through `trading_console.run_*` inside the - suspend/restore dance -- the same seam the Compliance/strategy forms use, so - O3's typed gates (resume's, resume-entries', record-flow's, reset-hwm's, - autonomy's) render in-console, never piped, never pre-filled.""" - nonlocal message, message_ts - - form_repo, form_config = open_state() - - def _run() -> str: - if form_target == "autonomy": - return trading_console.run_autonomy_form( - form_repo, form_config, _form_prompt, now_fn() - ) - if form_target == "record-flow": - return trading_console.run_record_flow_form(form_repo, _form_prompt) - if form_target == "reset-hwm": - return trading_console.run_reset_hwm_form(form_repo) - if form_target == "resume-entries": - return trading_console.run_resume_entries_form(form_repo) - if form_target == "resume": - return trading_console.run_resume_form(form_repo) - raise ValueError(f"unknown trading form: {form_target}") - - message = _run_terminal_form(stdscr, _run) - message_ts = now_fn() - - def _enter_trading_entry(entry: Any) -> None: - """Where a Trading selection goes -- the closed-mapping rule again: the two - ARMED views (the cycle/poll confirm steps; their plans read the RECORDED - session for the honesty line, never a TUI-side calendar), a form at the - terminal (the typed gates), or kill -- dispatched IMMEDIATELY, per its own - CLI contract (one key, no ceremony), with the CLI's own line as the toast.""" - nonlocal mode, trading_cycle_plan, trading_cycle_result, trading_cycle_error - nonlocal trading_cycle_offset, trading_monitor_plan, trading_monitor_result - nonlocal trading_monitor_error, trading_monitor_offset - nonlocal message, message_ts - # The B1 recording read the honesty line renders -- the same broker-free - # seam the session banner reads (`latest_recorded_session`), never a - # TUI-side calendar. - from keel import agent as agent_mod - - if entry.kind == "armed" and entry.target == "cycle": - cycle_repo, cycle_config = open_state() - profile = console.active_profile( - console_binding.config_path if console_binding else "", - console_binding.db_path if console_binding else "", - ) - trading_cycle_plan = trading_console.cycle_plan( - cycle_repo, - cycle_config, - console_binding.db_path if console_binding else "?", - now_fn(), - profile_label=profile.label if profile else None, - session_bound=( - console_binding.session_bound(cycle_config) - if console_binding - else False - ), - recorded=agent_mod.latest_recorded_session( - cycle_repo, cycle_config, now_fn() - ), - ) - trading_cycle_result = None - trading_cycle_error = None - trading_cycle_offset = 0 - mode = "trading-cycle" - elif entry.kind == "armed": # the monitor poll - monitor_repo, monitor_config = open_state() - trading_monitor_plan = trading_console.monitor_plan( - monitor_config, - console_binding.db_path if console_binding else "?", - session_bound=( - console_binding.session_bound(monitor_config) - if console_binding - else False - ), - recorded=agent_mod.latest_recorded_session( - monitor_repo, monitor_config, now_fn() - ), - ) - trading_monitor_result = None - trading_monitor_error = None - trading_monitor_offset = 0 - mode = "trading-monitor" - elif entry.kind == "action": # kill: one key, no ceremony, per its contract - kill_repo, _kill_config = open_state() - message = _guarded("kill", lambda: trading_console.run_kill(kill_repo)) - message_ts = now_fn() - else: - _run_trading_form_at_terminal(entry.target) - - def _run_data_form_at_terminal(form_target: str) -> None: - """A Data-menu form entry: the db-import path form, run at the terminal - through the same suspend/restore seam (the CLI's own validation messages - render verbatim).""" - nonlocal message, message_ts - - form_repo, _form_config = open_state() - - def _run() -> str: - if form_target == "db-import": - return data_console.run_db_import_form(form_repo, _form_prompt) - raise ValueError(f"unknown data form: {form_target}") - - message = _run_terminal_form(stdscr, _run) - message_ts = now_fn() - - def _enter_data_entry(entry: Any) -> None: - """Where a Data selection goes: an ARMED fetch variant (the plan is built on - entry so the confirm screen shows what the ACTIVE profile resolves to), the - offline freshness view, or the import form at the terminal.""" - nonlocal mode, data_fetch_plan, data_fetch_progress - nonlocal data_fetch_result, data_fetch_error, data_fetch_offset - nonlocal data_freshness_offset - if entry.kind == "armed": - fetch_repo, fetch_config = open_state() - data_fetch_plan = data_console.fetch_plan( - fetch_config, - console_binding.db_path if console_binding else "?", - entry.target, - ) - data_fetch_progress = [] - data_fetch_result = None - data_fetch_error = None - data_fetch_offset = 0 - mode = "data-fetch" - elif entry.kind == "view": # the freshness overview: offline, per poll - data_freshness_offset = 0 - mode = "data-freshness" - else: - _run_data_form_at_terminal(entry.target) - - def _enter_account_entry(entry: Any) -> None: - """Where an Account selection goes: the pnl view (offline, rebuilt per - poll like every offline view), the versions view (its environment scan - read ONCE here, the rows HELD -- the Venues browser's contract, so a - repaint never re-walks importlib.metadata), or the update view -- whose - entry-time CHECK (one public-API read + the plan) is held the same way; - the run itself happens only on Enter, behind the typed gate.""" - nonlocal mode, account_pnl_offset - nonlocal account_versions_rows, account_versions_offset - nonlocal account_update_plan, account_update_error - nonlocal account_update_progress, account_update_result, account_update_offset - nonlocal account_update_relaunch - if entry.target == "versions": - try: - account_versions_rows = account_console.versions_rows() - except Exception as exc: - account_versions_rows = [ - (f"versions read failed: {str(exc)[:160]}", True), - ] - account_versions_offset = 0 - mode = "account-versions" - elif entry.target == "update": - try: - account_update_plan = account_console.update_check() - except Exception as exc: - account_update_plan = None - account_update_error = str(exc)[:400] - else: - account_update_error = None - account_update_progress = [] - account_update_result = None - account_update_offset = 0 - account_update_relaunch = None - mode = "account-update" - else: # "pnl" - account_pnl_offset = 0 - mode = "account-pnl" - - def _recheck_account_update() -> None: - """Re-run the update view's entry-time check (Enter on a refused plan or a - failed check): one more public read, held again -- never per poll.""" - nonlocal account_update_plan, account_update_error - nonlocal account_update_progress, account_update_result, account_update_offset - nonlocal account_update_relaunch - try: - account_update_plan = account_console.update_check() - except Exception as exc: - account_update_plan = None - account_update_error = str(exc)[:400] - else: - account_update_error = None - account_update_progress = [] - account_update_result = None - account_update_offset = 0 - account_update_relaunch = None - - def _run_account_update_at_terminal() -> str: - """THE update run, dispatched at the terminal through the shared - suspend/restore seam: the service's own typed gate renders there, the - streamed lines collect into the held progress list, the RESULT is held - for the view, and a verified success execv's the new build's keel entry - with the ORIGINAL TUI argv (the terminal is already restored -- the run - happens after endwin, so the replacement starts on a clean terminal). A - relaunch that RAISES is held (not lost to a crash): the failure and the - manual `keel tui` start land in the held progress, and the closure is - kept for an Enter retry that re-installs nothing.""" - nonlocal account_update_progress, account_update_result - nonlocal account_update_relaunch - import sys as _sys - - relaunch = None - if account_update_plan is not None and account_update_plan.offered: - relaunch = update.relaunch_tui( - account_update_plan.venv_python, list(_sys.argv) - ) - - def _hold_failed_relaunch(_exc: BaseException) -> None: - nonlocal account_update_relaunch - account_update_relaunch = relaunch - - assert account_update_plan is not None - account_update_result = account_console.run_update_at_terminal( - account_update_plan, - progress=account_update_progress, - relaunch_fn=relaunch, - on_relaunch_failure=_hold_failed_relaunch, - ) - return "update run finished -- the results are held on screen" - - def _retry_account_update_relaunch() -> str: - """Enter on a relaunch-FAILED result: the execv again and NOTHING else -- - no re-check, no re-download, no re-install. A success replaces the process - (and never returns here); a second failure renders the manual `keel tui` - start into the held progress.""" - closure = account_update_relaunch - assert closure is not None - try: - closure() - except Exception as exc: - account_update_progress.append( - f"RELAUNCH FAILED again: {exc} -- run `keel tui` by hand (or your " - "deployment wrapper)." - ) - return "relaunch retried -- the results are held on screen" - - def _enter_help_entry(entry: Any) -> None: - """Where a Help sub-menu selection goes -- the same closed-mapping rule - `_enter_menu_entry` keeps, for the Help branch: every entry opens its own - view at the top (cursor and scroll reset with it).""" - nonlocal mode, glossary_offset, help_params_cursor, help_params_kind - nonlocal help_params_offset, help_screens_offset, help_offset - if entry.target == "glossary": - glossary_offset = 0 - mode = "help-glossary" - elif entry.target == "screens": - help_screens_offset = 0 - mode = "help-screens" - elif entry.target == "params": - help_params_cursor = 0 - help_params_kind = None - help_params_offset = 0 - mode = "help-params" - else: # "keys" -- the pre-C7 help screen, kept whole - help_offset = 0 - mode = "help" - - def _enter_menu_entry( - action: str, entry: console.MenuEntry - ) -> tuple[str, int, int, console.MenuEntry | None]: - """Where a menu selection goes: a closed mapping in one place, so the rendered - tree, the ordinals and the dispatch can never disagree about what an entry - does. Every destination opens at the top (cursor 0, offset 0) -- and that - includes every sub-menu's OWN cursor and scroll offset, reset HERE on every - (re)entry: a remembered row is a loaded one (leave Trading with the row on - kill, re-enter, and a replayed Enter would engage the halt with no - ceremony), so this is the ONE shared reset point every console sub-menu - passes through.""" - nonlocal compliance_cursor, compliance_menu_offset - nonlocal strategy_cursor, strategy_menu_offset - nonlocal research_cursor, research_menu_offset - nonlocal trading_cursor, trading_menu_offset - nonlocal data_cursor, data_menu_offset - nonlocal help_cursor - nonlocal account_cursor, account_menu_offset - if action == "compliance": - compliance_cursor = 0 - compliance_menu_offset = 0 - elif action == "strategy": - strategy_cursor = 0 - strategy_menu_offset = 0 - elif action == "research": - research_cursor = 0 - research_menu_offset = 0 - elif action == "trading": - trading_cursor = 0 - trading_menu_offset = 0 - elif action == "data": - data_cursor = 0 - data_menu_offset = 0 - elif action == "account": - account_cursor = 0 - account_menu_offset = 0 - elif action == "help": - help_cursor = 0 - if action == "dashboard": - return "normal", 0, 0, None - if action == "profile": - return "profile", 0, 0, None - if action == "help": - # C7 (issue #394): the Help entry is a real SUB-MENU now -- the glossary, - # the screens & actions catalog, the parameter help, and the keys/safety - # notes (the pre-C7 help screen, which remains as its fourth entry). - return "help-menu", 0, 0, None - if action == "compliance": - return "compliance", 0, 0, None - if action == "strategy": - return "strategy", 0, 0, None - if action == "research": - return "research", 0, 0, None - if action == "trading": - return "trading", 0, 0, None - if action == "data": - return "data", 0, 0, None - if action == "account": - return "account", 0, 0, None - return "placeholder", 0, 0, entry - - while True: - if mode == "menu": - if console_binding is None: # unreachable via 'm'; kept total anyway - mode = "normal" - continue - # The console menu (v6): the PRD §3 tree, one cursor-marked row, ordinals - # 1-9 as direct shortcuts. Not scrolled -- the nine entries plus the banner - # fit the terminals this dashboard has always targeted, and `_paint` clips - # a tiny window harmlessly. - profiles = console.discover_profiles() - menu_lines = console.build_menu_lines( - console.active_profile( - console_binding.config_path, console_binding.db_path, profiles - ), - cursor=menu_cursor, - profiles=profiles, - ) - _paint(stdscr, [*_console_banner(), *menu_lines]) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "normal" - elif ch in (curses.KEY_UP, ord("k")): - menu_cursor = max(0, menu_cursor - 1) - elif ch in (curses.KEY_DOWN, ord("j")): - menu_cursor = min(len(console.CONSOLE_MENU) - 1, menu_cursor + 1) - elif ord("1") <= ch <= ord("9"): - selected = console.menu_entry(ch - ord("0")) - if selected is not None: - mode, menu_cursor, help_offset, placeholder_entry = _enter_menu_entry( - selected.action, selected - ) - elif ch in (10, 13, ord(" "), curses.KEY_ENTER): - selected = console.CONSOLE_MENU[menu_cursor] - mode, menu_cursor, help_offset, placeholder_entry = _enter_menu_entry( - selected.action, selected - ) - elif ch == ord("?"): - context_help_for = "menu" - context_help_offset = 0 - mode = "context-help" - continue - - if mode == "placeholder": - if console_binding is None or placeholder_entry is None: # unreachable; total - mode = "normal" - continue - # A future slice's entry: the notice names the owning slice and says the - # shell renders navigation only. Any close key returns to the MENU (the - # parent screen), not the dashboard -- the shell is a hierarchy. - _paint( - stdscr, - [ - *_console_banner(), - *console.build_placeholder_lines(placeholder_entry), - ], - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "menu" - elif ch == ord("?"): - context_help_for = "placeholder" - context_help_offset = 0 - mode = "context-help" - continue - - if mode == "profile": - if console_binding is None: # unreachable via the menu; kept total anyway - mode = "normal" - continue - # The Profile menu (O4): every discovered deployment with its config+db - # pair visible, LIVE marked as guarded. Selecting rebinds through the same - # loaders the CLI uses (`console.switch_profile` -> `ConsoleBinding. - # open_state`), guarded by `_confirm_live_profile` for the LIVE pair only. - profiles = console.discover_profiles() - profile_lines = console.build_profile_menu_lines( - profiles, cursor=profile_cursor, binding_pair=console_binding.pair - ) - _paint(stdscr, [*_console_banner(), *profile_lines]) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("p"), ord("m")): - # `m` closes profile mode too -- the same q/Esc/ - # consistency the menu and placeholder modes keep, so the key that - # opened the shell can always step back one level out of it. - mode = "menu" - elif ch in (curses.KEY_UP, ord("k")): - profile_cursor = max(0, profile_cursor - 1) - elif ch in (curses.KEY_DOWN, ord("j")): - profile_cursor = profile_cursor + 1 - elif ch in (10, 13, ord(" "), curses.KEY_ENTER) and profile_cursor == ( - console.profile_menu_venues_at(profiles) - ): - # The Venues row (C7 / O7): open the installed-adapter browser. The - # payload is read ONCE per entry (an import scan plus one - # credential-less construction per adapter -- never per poll), and - # the ACTIVE deployment's binding facts are resolved with it so the - # browser's header describes the pair that is actually bound. - # `profile_menu_venues_at` is the ONE statement of where the Venues - # row sits -- the render and this dispatch cannot drift apart. - venues_infos = brokers.list_installed_brokers() - try: - _venues_repo, venues_config = open_state() - venues_selected = venues_config.broker.name - selected_info = next( - (i for i in venues_infos if i.name == venues_selected), None - ) - # endpoint/data feed render ONLY where the selected venue - # declares the knobs (Alpaca) -- an undeclared venue shows no - # binding row it would be meaningless for. - declares = selected_info is not None and bool( - selected_info.declared_endpoints - ) - venues_endpoint = venues_config.broker.endpoint if declares else None - venues_data_feed = venues_config.broker.data_feed if declares else None - except Exception: - venues_selected = None - venues_endpoint = None - venues_data_feed = None - venues_offset = 0 - mode = "venues" - elif ch in (10, 13, ord(" "), curses.KEY_ENTER) and 0 <= profile_cursor < ( - console.profile_menu_venues_at(profiles) - ): - selected_profile = profiles[profile_cursor] - before_pair = console_binding.pair - message = _guarded( - "profile switch", - lambda: console.switch_profile( - console_binding, - selected_profile, - confirm_fn=lambda: _confirm_live_profile(stdscr, selected_profile), - ), - ) - message_ts = now_fn() - if console_binding.pair != before_pair: - # The whole console now answers about the other deployment: back - # to the landing screen so the rebinding is visible everywhere, - # the live-balance line dropped (it described the previous - # venue's account) and re-read on the next poll. - mode = "normal" - profile_cursor = 0 - available = None - last_balance_ts = 0 - elif ch == ord("?"): - context_help_for = "profile" - context_help_offset = 0 - mode = "context-help" - # Clamp every poll: the discovered list can change under the cursor (a - # config file appearing/disappearing between polls) -- to the VENUES row, - # which is part of the same cursor range (same one statement of where it - # sits as the dispatch above). - profile_cursor = max( - 0, min(profile_cursor, console.profile_menu_venues_at(profiles)) - ) - continue - - if mode == "venues": - if console_binding is None: # unreachable via the Profile menu; total - mode = "profile" - continue - # The Venues browser (C7 / O7): every installed adapter's declared - # capabilities from the ONE brokers service payload, the SELECTED - # adapter (the bound config's venue) highlighted and the active - # deployment's binding shown. Scrolled, banner-aware, m-close per the - # established contract; the payload is HELD from entry (see the Profile - # branch) -- repainting never re-walks the registry. - venues_lines = console.build_venues_lines( - venues_infos, - selected_venue=venues_selected, - profile=console.active_profile( - console_binding.config_path, console_binding.db_path - ), - binding_pair=console_binding.pair, - endpoint=venues_endpoint, - data_feed=venues_data_feed, - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice([*banner, *venues_lines], venues_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("v"), ord("m")): - # Back to the Profile menu -- the browser's parent (the shell is a - # hierarchy), `m` included per the one-level-up contract. - mode = "profile" - venues_offset = 0 - elif ch == ord("?"): - context_help_for = "venues" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch: the - # clamp must land on the same last page the combined slice shows. - venues_offset = _scroll_offset( - ch, - venues_offset, - height, - len(banner) + len(venues_lines), - curses, - ) - continue - - if mode == "compliance": - if console_binding is None: # unreachable via the menu; kept total anyway - mode = "normal" - continue - # The Compliance sub-menu (issue #389 C3): PRD §3's Compliance branch, - # one cursor-marked row, the typed entries marked. A FORM entry runs at - # the terminal right here (curses suspended) and its confirmation line - # becomes the toast on this screen; views and the scout browser are - # separate modes that close back HERE -- the shell is a hierarchy. - # SCROLLED like the other cursor-driven lists (`_visible_slice` + - # `_follow_cursor`): the tree is 15 entries and no longer fits a small - # window one-row-per-entry, so the cursor's row must follow the cursor. - compliance_lines = compliance_console.build_compliance_menu_lines( - cursor=compliance_cursor, message=_toast_ttl() - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - cursor_row = len(banner) + _cursor_line_index(compliance_lines) - compliance_menu_offset = _follow_cursor( - compliance_menu_offset, cursor_row, height - ) - _paint( - stdscr, - _visible_slice( - [*banner, *compliance_lines], compliance_menu_offset, height - ), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "menu" - elif ch in (curses.KEY_UP, ord("k")): - compliance_cursor = max(0, compliance_cursor - 1) - elif ch in (curses.KEY_DOWN, ord("j")): - compliance_cursor = min( - len(compliance_console.COMPLIANCE_MENU) - 1, compliance_cursor + 1 - ) - elif ord("1") <= ch <= ord("9"): - compliance_selected = compliance_console.compliance_entry(ch - ord("0")) - if compliance_selected is not None: - _enter_compliance_entry(compliance_selected) - elif ch in (10, 13, ord(" "), curses.KEY_ENTER): - _enter_compliance_entry(compliance_console.COMPLIANCE_MENU[compliance_cursor]) - elif ch == ord("?"): - context_help_for = "compliance" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch above. - compliance_menu_offset = _scroll_offset( - ch, - compliance_menu_offset, - height, - len(banner) + len(compliance_lines), - curses, - ) - continue - - if mode == "compliance-view" and compliance_view_kind is not None: - # One service-report overlay, FAIL-SOFT like the screen/propose branches: - # the offline kinds rebuild each poll inside the try (a locked DB paints - # an alert line and keeps polling); the network kinds (holdings/discover) - # do NOT rebuild -- `compliance_result`/`compliance_error` are HELD from - # the last Enter (ARMED until one is pressed), the discover overlay's own - # gating story, so a poll can never fire a venue call. - if compliance_view_kind in ("holdings", "discover"): - view_lines = compliance_console.build_compliance_view_lines( - compliance_view_kind, compliance_result, error=compliance_error - ) - else: - try: - view_lines = compliance_console.build_compliance_view_lines( - compliance_view_kind, - _do_compliance_payload(open_state, now_fn, compliance_view_kind), - ) - except Exception as exc: - view_lines = compliance_console.build_compliance_view_lines( - compliance_view_kind, None, error=str(exc)[:200] - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - # The shariah browser's two standing honesty lines are a FIXED footer, - # reserved off the window BEFORE the body is sliced (`pinned_frame`) -- - # O10's "always visible" holds at EVERY scroll offset, where riding the - # body's tail left them one viewport below the fold on a real allowlist. - pinned = ( - compliance_console.shariah_honesty_lines() - if compliance_view_kind == "shariah" - else [] - ) - _paint( - stdscr, - compliance_console.pinned_frame( - [*banner, *view_lines], pinned, - offset=compliance_offset, height=height, - ), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("c")): - mode = "compliance" - compliance_view_kind = None - compliance_offset = 0 - # Closing discards a held network result -- reopening is armed again. - compliance_result = None - compliance_error = None - elif ( - compliance_view_kind in ("holdings", "discover") - and ch in (10, 13, curses.KEY_ENTER) - ): - _paint(stdscr, [ScreenLine("contacting venue... please wait", "normal")]) - try: - compliance_result = _do_compliance_network( - open_state, compliance_view_kind - ) - compliance_error = None - except Exception as exc: - compliance_result = None - compliance_error = str(exc)[:200] - compliance_offset = 0 - elif ch == ord("?"): - context_help_for = "compliance-view" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch above -- - # and the scroll math spends only the rows the pinned footer leaves. - compliance_offset = _scroll_offset( - ch, - compliance_offset, - max(height - len(pinned), 0), - len(banner) + len(view_lines), - curses, - ) - continue - - if mode == "scout-list": - if console_binding is None: # unreachable via the menu; kept total anyway - mode = "normal" - continue - # The scout-results browser's file list (O6): every shortlist in the - # CONFIG-named proposals directory, newest first, with a clear empty - # state. No network, no repo read -- a directory listing, re-read only on - # entry (the files do not change under a held screen the way a DB does). - # SCROLLED with the cursor-follow rule like the Compliance menu: older - # shortlists sit below the fold on any but a huge window, and O6's whole - # point is that they stay REACHABLE. - scout_lines = compliance_console.build_scout_list_lines( - scout_files, scout_dir, cursor=scout_cursor - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - cursor_row = len(banner) + _cursor_line_index(scout_lines) - scout_list_offset = _follow_cursor(scout_list_offset, cursor_row, height) - _paint( - stdscr, - _visible_slice([*banner, *scout_lines], scout_list_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - # `m` closes the list too -- the q/Esc/m consistency every other - # console mode keeps, so the key that opened the shell can always - # step back one level out of it. - mode = "compliance" - elif ch in (curses.KEY_UP, ord("k")): - scout_cursor = max(0, scout_cursor - 1) - elif ch in (curses.KEY_DOWN, ord("j")): - scout_cursor = scout_cursor + 1 - elif ch in (10, 13, ord(" "), curses.KEY_ENTER) and 0 <= scout_cursor < len( - scout_files - ): - scout_selected = scout_files[scout_cursor].path - scout_view_offset = 0 - scout_candidate_cursor = 0 - scout_view_cache = {} # a fresh selection starts uncached - mode = "scout-view" - elif ch == ord("?"): - context_help_for = "scout-list" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch above. - scout_list_offset = _scroll_offset( - ch, - scout_list_offset, - height, - len(banner) + len(scout_lines), - curses, - ) - scout_cursor = max(0, min(scout_cursor, max(0, len(scout_files) - 1))) - continue - - if mode == "scout-view" and scout_selected is not None: - # The selected shortlist, screened through THE admission services - # (fail-soft: `build_propose_view` itself never raises for file problems; - # this catches a locked DB), with a cursor over the candidate rows. The - # view is cached per (path, mtime) -- the screen repaints every poll, and - # an UNCHANGED file must not be re-read, re-parsed and re-screened each - # time; a changed file (new mtime) refreshes, and the `a` attest step - # clears the cache because its write changes what screening says. - # `a` offers the TYPED attest step for the selected candidate -- - # proposer-never-decider, so nothing attests without the human's phrase. - from keel.commands.assets import screen_product - - scout_repo, scout_config = open_state() - try: - scout_view = cached_scout_view( - scout_repo, scout_config, screen_product, scout_selected, - scout_view_cache, - ) - except Exception as exc: - scout_view = ProposeView( - source=scout_selected, - status="unreadable", - detail=str(exc)[:200], - report=None, - ) - scout_view_lines, scout_cursor_line, scout_candidates = ( - compliance_console.build_scout_file_lines( - scout_view, cursor=scout_candidate_cursor - ) - ) - scout_candidate_cursor = max( - 0, min(scout_candidate_cursor, max(0, scout_candidates - 1)) - ) - toast = _toast_ttl() - if toast is not None: - scout_view_lines = [ - *scout_view_lines, - _blank(), - ScreenLine(toast, _message_style(toast)), - ] - height, _width = stdscr.getmaxyx() - banner = _console_banner() - scout_view_offset = _follow_cursor( - scout_view_offset, scout_cursor_line + len(banner), height - ) - _paint( - stdscr, - _visible_slice([*banner, *scout_view_lines], scout_view_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - # `m` closes the view too -- the q/Esc/m consistency every other - # console mode keeps, so the key that opened the shell can always - # step back one level out of it. - mode = "scout-list" - scout_view_offset = 0 - elif ch == ord("a") and scout_candidates > 0: - # `scout_candidates > 0` implies an "ok" view with a report - # (`build_scout_file_lines` counts `report.screened`); the assert - # states that invariant for the type checker, not the runtime. - assert scout_view.report is not None - selected_candidate = scout_view.report.screened[ - scout_candidate_cursor - ].candidate.asset - message = _run_terminal_form( - stdscr, - lambda: compliance_console.run_attest_form( - scout_repo, - _form_prompt, - now_fn(), - asset=selected_candidate, - ), - ) - message_ts = now_fn() - # The write changes what the gate says (an attested candidate no - # longer reads REJECT) -- the cached view is stale the moment it - # lands, so the next poll re-screens. - scout_view_cache = {} - elif ch in (curses.KEY_UP, ord("k")): - scout_candidate_cursor = max(0, scout_candidate_cursor - 1) - elif ch in (curses.KEY_DOWN, ord("j")): - if scout_candidates: - scout_candidate_cursor = min( - scout_candidates - 1, scout_candidate_cursor + 1 - ) - elif ch == ord("?"): - context_help_for = "scout-view" - context_help_offset = 0 - mode = "context-help" - else: - scout_view_offset = _scroll_offset( - ch, - scout_view_offset, - height, - len(banner) + len(scout_view_lines), - curses, - ) - continue - - if mode == "strategy": - if console_binding is None: # unreachable via the menu; kept total anyway - mode = "normal" - continue - # The Rules sub-menu (issue #390 C4): the strategy console. A FORM entry - # runs at the terminal right here (curses suspended -- the retry flow's - # TYPED --force gate renders in-console) and its confirmation lines toast - # on this screen; the ledger, simulate and insights are separate modes - # that close back HERE. SCROLLED with the cursor-follow rule: eight - # entries with wrapped descriptions outgrow a small window. - strategy_lines = strategy_console.build_strategy_menu_lines( - cursor=strategy_cursor, message=_toast_ttl() - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - cursor_row = len(banner) + _cursor_line_index(strategy_lines) - strategy_menu_offset = _follow_cursor( - strategy_menu_offset, cursor_row, height - ) - _paint( - stdscr, - _visible_slice( - [*banner, *strategy_lines], strategy_menu_offset, height - ), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "menu" - elif ch in (curses.KEY_UP, ord("k")): - strategy_cursor = max(0, strategy_cursor - 1) - elif ch in (curses.KEY_DOWN, ord("j")): - strategy_cursor = min( - len(strategy_console.STRATEGY_MENU) - 1, strategy_cursor + 1 - ) - elif ord("1") <= ch <= ord("9"): - strategy_selected = strategy_console.strategy_entry(ch - ord("0")) - if strategy_selected is not None: - _enter_strategy_entry(strategy_selected) - elif ch in (10, 13, ord(" "), curses.KEY_ENTER): - _enter_strategy_entry( - strategy_console.STRATEGY_MENU[strategy_cursor] - ) - elif ch == ord("?"): - context_help_for = "strategy" - context_help_offset = 0 - mode = "context-help" - else: - strategy_menu_offset = _scroll_offset( - ch, - strategy_menu_offset, - height, - len(banner) + len(strategy_lines), - curses, - ) - continue - - if mode == "strategy-ledger": - if console_binding is None or not strategy_ledger_built: - mode = "strategy" - continue - # The tried-vs-used ledger (O11.2): every rule row with its RECORDED - # context, HELD from the entry-time read (a poll repaint must never - # re-read, and never backtests -- entry runs zero of them). Any verdicts - # the operator re-computed in the detail view render under their rows. - # A cursor over the rule rows; Enter opens the detail (whose own Enter - # is the explicit, warned re-compute). - ledger_lines = strategy_console.build_ledger_lines( - strategy_ledger, - cursor=strategy_ledger_cursor, - verdicts=strategy_verdicts, - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - cursor_row = len(banner) + _cursor_line_index(ledger_lines) - strategy_ledger_offset = _follow_cursor( - strategy_ledger_offset, cursor_row, height - ) - _paint( - stdscr, - _visible_slice( - [*banner, *ledger_lines], strategy_ledger_offset, height - ), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "strategy" - strategy_ledger_built = False - elif ch in (curses.KEY_UP, ord("k")): - strategy_ledger_cursor = max(0, strategy_ledger_cursor - 1) - elif ch in (curses.KEY_DOWN, ord("j")): - strategy_ledger_cursor = min( - max(0, len(strategy_ledger) - 1), strategy_ledger_cursor + 1 - ) - elif ch in (10, 13, ord(" "), curses.KEY_ENTER) and strategy_ledger: - strategy_rule_detail = strategy_ledger[strategy_ledger_cursor] - strategy_rule_offset = 0 - mode = "strategy-rule" - elif ch == ord("?"): - context_help_for = "strategy-ledger" - context_help_offset = 0 - mode = "context-help" - else: - strategy_ledger_offset = _scroll_offset( - ch, - strategy_ledger_offset, - height, - len(banner) + len(ledger_lines), - curses, - ) - continue - - if mode == "strategy-rule" and strategy_rule_detail is not None: - # One rule's detail: the params rendered through `describe_params` (the - # O8 per-field help), the recorded paper-gate distance, and the rule's - # backtest verdict -- ARMED until an explicit Enter. That Enter is the - # ONE place the strategy console runs a rule backtest: warned first (the - # detail screen says what it costs), honestly blocking while it runs - # (exactly like simulate/fetch), and held in `strategy_verdicts` when it - # ends -- repaints render the held verdict, they never recompute. - detail = strategy_rule_detail - rule_lines = strategy_console.build_ledger_detail_lines( - detail, verdict=strategy_verdicts.get(detail.rule_id) - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice([*banner, *rule_lines], strategy_rule_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "strategy-ledger" - strategy_rule_offset = 0 - elif ch in (10, 13, curses.KEY_ENTER): - _paint( - stdscr, - [ScreenLine( - f"re-computing rule {detail.rule_id}'s verdict (the " - "full-window backtest)... please wait -- this can take " - "minutes on long series; the screen is frozen like " - "simulate/fetch", - "normal", - )], - ) - try: - verdict_repo, verdict_config = open_state() - strategy_verdicts[detail.rule_id] = ( - strategy_console.compute_rule_verdict( - verdict_repo, verdict_config, detail - ) - ) - except Exception as exc: - strategy_verdicts[detail.rule_id] = ( - strategy_console.RuleVerdict( - stats_line=None, - reason_lines=( - f"the re-compute failed before the backtest could " - f"run: {str(exc)[:160]}", - ), - ) - ) - strategy_rule_offset = 0 - elif ch == ord("?"): - context_help_for = "strategy-rule" - context_help_offset = 0 - mode = "context-help" - else: - strategy_rule_offset = _scroll_offset( - ch, - strategy_rule_offset, - height, - len(banner) + len(rule_lines), - curses, - ) - continue - - if mode == "strategy-simulate" and strategy_simulate_plan is not None: - # The simulate view (O11.1): ARMED until an explicit Enter -- the confirm - # step; opening the screen and polling make NO call and touch NO file. - # Enter runs `run_simulation` (which fetches history when the cache does - # not cover the window and WRITES the report), blocking the loop exactly - # like `f` fetch does -- the CLI's own UX, mirrored honestly -- then the - # result is HELD and every poll repaints it until Enter re-runs or the - # view closes (which re-arms it). - if strategy_simulate_result is not None: - sim_lines = strategy_console.build_simulate_result_lines( - strategy_simulate_result, tuple(strategy_simulate_progress) - ) - elif strategy_simulate_error is not None: - # A failed run keeps the progress it streamed BEFORE failing, above - # the error (they head the results on success; dropping them here - # would hide exactly the lines that say how far it got). - sim_lines = [] - if strategy_simulate_progress: - sim_lines.append( - ScreenLine("run progress (what the CLI streamed):", "muted") - ) - for line in strategy_simulate_progress: - for wrapped in textwrap.wrap(line, width=78) or [""]: - sim_lines.append(ScreenLine(wrapped, "muted")) - sim_lines.append(_blank()) - sim_lines.extend( - [ - ScreenLine(f"simulate failed: {strategy_simulate_error}", "alert"), - _blank(), - ScreenLine("Press Enter to retry, or q/Esc to close.", "muted"), - ] - ) - else: - sim_lines = strategy_console.build_simulate_armed_lines( - strategy_simulate_plan - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - pinned = ( - strategy_console.simulate_verdict_footer(strategy_simulate_result) - if strategy_simulate_result is not None - else [] - ) - _paint( - stdscr, - compliance_console.pinned_frame( - [*banner, *sim_lines], pinned, - offset=strategy_simulate_offset, height=height, - ), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "strategy" - # Closing discards the held result -- reopening is ARMED again. - strategy_simulate_result = None - strategy_simulate_error = None - strategy_simulate_progress = [] - strategy_simulate_offset = 0 - elif ch in (10, 13, curses.KEY_ENTER): - _paint( - stdscr, - _run_notice_lines( - "simulating... please wait (this can take minutes; the " - "screen is frozen exactly like the CLI)" - ), - ) - progress: list[str] = [] - try: - sim_repo, sim_config = open_state() - from keel.commands._common import _build_broker - - strategy_simulate_result = strategy_console.run_simulate( - sim_repo, - sim_config, - strategy_simulate_plan, - now_ts=now_fn(), - # The CLI's own default: fetch history when the cache does - # not cover the window (a broker is constructed for it). - build_client=lambda: _build_broker(sim_config), - progress=progress, - ) - strategy_simulate_error = None - except Exception as exc: - strategy_simulate_result = None - strategy_simulate_error = str(exc)[:200] - strategy_simulate_progress = progress - strategy_simulate_offset = 0 - elif ch == ord("?"): - context_help_for = "strategy-simulate" - context_help_offset = 0 - mode = "context-help" - else: - strategy_simulate_offset = _scroll_offset( - ch, - strategy_simulate_offset, - max(height - len(pinned), 0), - len(banner) + len(sim_lines), - curses, - ) - continue - - if mode == "research": - if console_binding is None: # unreachable via the menu; kept total anyway - mode = "normal" - continue - # The Research sub-menu (issue #390 C4 / O5): the evidence corpora and - # the trials ledger, all read-only. - research_lines = research_console.build_research_menu_lines( - cursor=research_cursor, message=_toast_ttl() - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - cursor_row = len(banner) + _cursor_line_index(research_lines) - research_menu_offset = _follow_cursor( - research_menu_offset, cursor_row, height - ) - _paint( - stdscr, - _visible_slice( - [*banner, *research_lines], research_menu_offset, height - ), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "menu" - elif ch in (curses.KEY_UP, ord("k")): - research_cursor = max(0, research_cursor - 1) - elif ch in (curses.KEY_DOWN, ord("j")): - research_cursor = min( - len(research_console.RESEARCH_MENU) - 1, research_cursor + 1 - ) - elif ord("1") <= ch <= ord("9"): - research_selected = research_console.research_entry(ch - ord("0")) - if research_selected is not None: - _enter_research_entry(research_selected) - elif ch in (10, 13, ord(" "), curses.KEY_ENTER): - _enter_research_entry( - research_console.RESEARCH_MENU[research_cursor] - ) - elif ch == ord("?"): - context_help_for = "research" - context_help_offset = 0 - mode = "context-help" - else: - research_menu_offset = _scroll_offset( - ch, - research_menu_offset, - height, - len(banner) + len(research_lines), - curses, - ) - continue - - if mode == "research-list": - if console_binding is None: # unreachable via the menu; kept total anyway - mode = "normal" - continue - # A corpus's file list, read on ENTRY (documents do not change under a - # held screen the way a DB does) -- the scout browser's contract. - doc_list_lines = research_console.build_doc_list_lines( - research_title, research_files, research_dir, - cursor=research_list_cursor, - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - cursor_row = len(banner) + _cursor_line_index(doc_list_lines) - research_list_offset = _follow_cursor( - research_list_offset, cursor_row, height - ) - _paint( - stdscr, - _visible_slice( - [*banner, *doc_list_lines], research_list_offset, height - ), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "research" - elif ch in (curses.KEY_UP, ord("k")): - research_list_cursor = max(0, research_list_cursor - 1) - elif ch in (curses.KEY_DOWN, ord("j")): - research_list_cursor = min( - max(0, len(research_files) - 1), research_list_cursor + 1 - ) - elif ch in (10, 13, ord(" "), curses.KEY_ENTER) and research_files: - research_doc_path = research_files[research_list_cursor].path - research_doc_cache = {} # a fresh selection starts uncached - research_doc_offset = 0 - mode = "research-doc" - elif ch == ord("?"): - context_help_for = "research-list" - context_help_offset = 0 - mode = "context-help" - else: - research_list_offset = _scroll_offset( - ch, - research_list_offset, - height, - len(banner) + len(doc_list_lines), - curses, - ) - continue - - if mode == "research-doc" and research_doc_path is not None: - # The chosen document, read through the BOUNDED, mtime-cached reader -- - # repaints do not re-read an unchanged file (the scout-view lesson). - doc_lines = research_console.build_doc_lines( - research_title, - research_doc_path, - research_console.cached_document_lines( - research_doc_path, research_doc_cache - ), - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice([*banner, *doc_lines], research_doc_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "research-list" - research_doc_offset = 0 - elif ch == ord("?"): - context_help_for = "research-doc" - context_help_offset = 0 - mode = "context-help" - else: - research_doc_offset = _scroll_offset( - ch, - research_doc_offset, - height, - len(banner) + len(doc_lines), - curses, - ) - continue - - if mode == "research-trials": - # The trials ledger (O5): rebuilt per poll, fail-soft -- it is a small - # read-only file read, and a mid-view append (a simulate run in another - # terminal) then shows up on the next repaint. - try: - trials_lines = research_console.build_trials_lines() - except Exception as exc: - trials_lines = [ - ScreenLine(f"trials read failed: {exc} -- retrying...", "alert") - ] - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice([*banner, *trials_lines], research_trials_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "research" - research_trials_offset = 0 - elif ch == ord("?"): - context_help_for = "research-trials" - context_help_offset = 0 - mode = "context-help" - else: - research_trials_offset = _scroll_offset( - ch, - research_trials_offset, - height, - len(banner) + len(trials_lines), - curses, - ) - continue - - if mode == "trading": - if console_binding is None: # unreachable via the menu; kept total anyway - mode = "normal" - continue - # The Trading sub-menu (issue #391 C5): PRD §3's Trading branch, one - # cursor-marked row, the typed entries marked. A FORM entry runs at the - # terminal right here (curses suspended -- O3's typed gates render - # in-console) and its confirmation line toasts on this screen; kill - # dispatches IMMEDIATELY (its own CLI contract: one key, no ceremony); - # the cycle and the poll are ARMED modes that close back HERE. SCROLLED - # with the cursor-follow rule: eight entries with wrapped descriptions - # outgrow a small window. - trading_lines = trading_console.build_trading_menu_lines( - cursor=trading_cursor, message=_toast_ttl() - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - cursor_row = len(banner) + _cursor_line_index(trading_lines) - trading_menu_offset = _follow_cursor(trading_menu_offset, cursor_row, height) - _paint( - stdscr, - _visible_slice([*banner, *trading_lines], trading_menu_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "menu" - elif ch in (curses.KEY_UP, ord("k")): - trading_cursor = max(0, trading_cursor - 1) - elif ch in (curses.KEY_DOWN, ord("j")): - trading_cursor = min( - len(trading_console.TRADING_MENU) - 1, trading_cursor + 1 - ) - elif ord("1") <= ch <= ord("9"): - trading_selected = trading_console.trading_entry(ch - ord("0")) - if trading_selected is not None: - _enter_trading_entry(trading_selected) - elif ch in (10, 13, ord(" "), curses.KEY_ENTER): - _enter_trading_entry(trading_console.TRADING_MENU[trading_cursor]) - elif ch == ord("?"): - context_help_for = "trading" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch above. - trading_menu_offset = _scroll_offset( - ch, - trading_menu_offset, - height, - len(banner) + len(trading_lines), - curses, - ) - continue - - if mode == "trading-cycle" and trading_cycle_plan is not None: - # The agent cycle view (issue #391 C5): ARMED until an explicit Enter -- - # the CONFIRM step (the screen names the profile, the mode's semantics - # and the session honesty). Enter runs `agent.run_once` through - # `trading_console.run_agent_cycle` (the CLI's own confirm gate), which - # CAN PLACE ORDERS on the active profile -- the one entry in this - # console with that power, which is exactly why nothing fires before - # the Enter. The run blocks the loop like simulate/fetch; the cycle's - # own result lines are held and repainted until Enter re-runs or close - # re-arms. - if trading_cycle_result is not None: - cycle_view = trading_console.build_cycle_result_lines( - trading_cycle_result - ) - elif trading_cycle_error is not None: - cycle_view = [ - ScreenLine(f"agent cycle failed: {trading_cycle_error}", "alert"), - _blank(), - ScreenLine("Press Enter to retry, or q/Esc to close.", "muted"), - ] - else: - cycle_view = trading_console.build_cycle_armed_lines( - trading_cycle_plan - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice([*banner, *cycle_view], trading_cycle_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "trading" - # Closing discards the held result -- reopening is ARMED again. - trading_cycle_result = None - trading_cycle_error = None - trading_cycle_offset = 0 - elif ch in (10, 13, curses.KEY_ENTER): - _paint(stdscr, _run_notice_lines(_CYCLE_RUN_NOTICE)) - - def _run_cycle() -> str: - # Runs with curses SUSPENDED, through `_run_terminal_form` -- - # the ONE shared copy of the dance -- because on a - # confirm-mode profile the CLI's own `_interactive_confirm` - # gate asks MID-CYCLE: it echo's the order preview and reads - # the y at the terminal, and under curses that prompt garbles - # the screen while the answer is typed BLIND under noecho. - # The view's held result/error state is captured here (the - # seam's toast-string return has nothing to say on this - # screen, which renders the state itself); a Ctrl-C - # propagates THROUGH the dance's finally -- screen restored -- - # and exits the whole console gracefully, exactly as the - # frozen notice says. - nonlocal trading_cycle_result, trading_cycle_error - try: - cycle_repo, cycle_config = open_state() - from keel.commands._common import _build_broker - - trading_cycle_result = trading_console.run_agent_cycle( - cycle_repo, - cycle_config, - now_ts=now_fn(), - build_broker=lambda: _build_broker(cycle_config), - ) - trading_cycle_error = None - except Exception as exc: - trading_cycle_result = None - trading_cycle_error = str(exc)[:200] - return "" - - _run_terminal_form(stdscr, _run_cycle) - trading_cycle_offset = 0 - elif ch == ord("?"): - context_help_for = "trading-cycle" - context_help_offset = 0 - mode = "context-help" - else: - trading_cycle_offset = _scroll_offset( - ch, - trading_cycle_offset, - height, - len(banner) + len(cycle_view), - curses, - ) - continue - - if mode == "trading-monitor" and trading_monitor_plan is not None: - # The monitor poll view: the same ARMED story for ONE poll -- Enter - # runs `monitor_cycle` (record the session, skip while closed, else - # fetch fresh candles), blocking; the cycle's own line is held. - if trading_monitor_result is not None: - monitor_view = trading_console.build_monitor_result_lines( - trading_monitor_result - ) - elif trading_monitor_error is not None: - monitor_view = [ - ScreenLine( - f"monitor poll failed: {trading_monitor_error}", "alert" - ), - _blank(), - ScreenLine("Press Enter to retry, or q/Esc to close.", "muted"), - ] - else: - monitor_view = trading_console.build_monitor_armed_lines( - trading_monitor_plan - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice( - [*banner, *monitor_view], trading_monitor_offset, height - ), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "trading" - trading_monitor_result = None - trading_monitor_error = None - trading_monitor_offset = 0 - elif ch in (10, 13, curses.KEY_ENTER): - _paint(stdscr, _run_notice_lines(_MONITOR_RUN_NOTICE)) - try: - poll_repo, poll_config = open_state() - from keel.commands._common import _build_broker - - trading_monitor_result = trading_console.run_monitor_poll( - poll_repo, - poll_config, - now_ts=now_fn(), - build_broker=lambda: _build_broker(poll_config), - ) - trading_monitor_error = None - except Exception as exc: - trading_monitor_result = None - trading_monitor_error = str(exc)[:200] - trading_monitor_offset = 0 - elif ch == ord("?"): - context_help_for = "trading-monitor" - context_help_offset = 0 - mode = "context-help" - else: - trading_monitor_offset = _scroll_offset( - ch, - trading_monitor_offset, - height, - len(banner) + len(monitor_view), - curses, - ) - continue - - if mode == "data": - if console_binding is None: # unreachable via the menu; kept total anyway - mode = "normal" - continue - # The Data sub-menu (issue #391 C5): PRD §3's Data branch. The fetch - # variants open ARMED modes that close back HERE; the freshness - # overview is an offline view; db import runs its path form at the - # terminal. SCROLLED with the cursor-follow rule like the other - # sub-menus. - data_lines = data_console.build_data_menu_lines( - cursor=data_cursor, message=_toast_ttl() - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - cursor_row = len(banner) + _cursor_line_index(data_lines) - data_menu_offset = _follow_cursor(data_menu_offset, cursor_row, height) - _paint( - stdscr, - _visible_slice([*banner, *data_lines], data_menu_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "menu" - elif ch in (curses.KEY_UP, ord("k")): - data_cursor = max(0, data_cursor - 1) - elif ch in (curses.KEY_DOWN, ord("j")): - data_cursor = min(len(data_console.DATA_MENU) - 1, data_cursor + 1) - elif ord("1") <= ch <= ord("9"): - data_selected = data_console.data_entry(ch - ord("0")) - if data_selected is not None: - _enter_data_entry(data_selected) - elif ch in (10, 13, ord(" "), curses.KEY_ENTER): - _enter_data_entry(data_console.DATA_MENU[data_cursor]) - elif ch == ord("?"): - context_help_for = "data" - context_help_offset = 0 - mode = "context-help" - else: - data_menu_offset = _scroll_offset( - ch, - data_menu_offset, - height, - len(banner) + len(data_lines), - curses, - ) - continue - - if mode == "data-fetch" and data_fetch_plan is not None: - # A fetch variant (fetch / fetch --check / repair gaps): ARMED until an - # explicit Enter -- the confirm step, the plan on screen first. Enter - # runs `run_fetch` (the SAME flow `keel fetch` runs) through - # `data_console.run_console_fetch`, blocking the loop exactly like `f` - # fetch; the lines the CLI would have streamed are collected and HELD, - # with a `--check` run's verdict PINNED under the body so no scroll - # offset can hide what the dry-run concluded. - if data_fetch_result is not None or data_fetch_error is not None: - fetch_view = data_console.build_fetch_result_lines( - data_fetch_plan.target, - tuple(data_fetch_progress), - error=data_fetch_error, - verdict=( - data_fetch_result.error - if data_fetch_result is not None - else None - ), - ) - else: - fetch_view = data_console.build_fetch_armed_lines(data_fetch_plan) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - pinned = ( - data_console.check_verdict_footer( - data_fetch_result.error - if data_fetch_plan.check and data_fetch_result is not None - else None - ) - ) - _paint( - stdscr, - compliance_console.pinned_frame( - [*banner, *fetch_view], pinned, - offset=data_fetch_offset, height=height, - ), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "data" - # Closing discards the held run -- reopening is ARMED again. - data_fetch_result = None - data_fetch_error = None - data_fetch_progress = [] - data_fetch_offset = 0 - elif ch in (10, 13, curses.KEY_ENTER): - _paint(stdscr, _run_notice_lines(_FETCH_RUN_NOTICE)) - fetch_progress: list[str] = [] - try: - fetch_repo, fetch_config = open_state() - from keel.commands._common import _build_broker - - data_fetch_result = data_console.run_console_fetch( - fetch_repo, - fetch_config, - data_fetch_plan, - now_ts=now_fn(), - # The lazy factory the CLI's own wrapper hands the service: - # `--check` and the all-current skip never construct one. - build_client=lambda: _build_broker(fetch_config), - progress=fetch_progress, - ) - data_fetch_error = None - except Exception as exc: - data_fetch_result = None - data_fetch_error = str(exc)[:200] - data_fetch_progress = fetch_progress - data_fetch_offset = 0 - elif ch == ord("?"): - context_help_for = "data-fetch" - context_help_offset = 0 - mode = "context-help" - else: - data_fetch_offset = _scroll_offset( - ch, - data_fetch_offset, - max(height - len(pinned), 0), - len(banner) + len(fetch_view), - curses, - ) - continue - - if mode == "data-freshness": - if console_binding is None: # unreachable via the menu; kept total anyway - mode = "normal" - continue - # The freshness overview: OFFLINE and rebuilt per poll, fail-soft -- - # `run_fetch(check=True)`'s own sweep, the same lines `keel fetch - # --check` prints (a check run never opens a network connection, so no - # broker is ever constructed from this screen). - try: - freshness_repo, freshness_config = open_state() - rows = data_console.freshness_lines( - freshness_repo, - freshness_config, - console_binding.db_path, - now_fn(), - ) - freshness_view = data_console.build_freshness_lines(rows) - except Exception as exc: - freshness_view = [ - ScreenLine(f"freshness read failed: {exc} -- retrying...", "alert") - ] - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice( - [*banner, *freshness_view], data_freshness_offset, height - ), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "data" - data_freshness_offset = 0 - elif ch == ord("?"): - context_help_for = "data-freshness" - context_help_offset = 0 - mode = "context-help" - else: - data_freshness_offset = _scroll_offset( - ch, - data_freshness_offset, - height, - len(banner) + len(freshness_view), - curses, - ) - continue - - if mode == "account": - if console_binding is None: # unreachable via the menu; kept total anyway - mode = "normal" - continue - # The Account sub-menu (issue #392 C6): PRD §3's Account branch -- the - # tree's last placeholder turned real. Both entries are READ-ONLY views - # (pnl, versions); there is no form, no ARMED run and no immediate - # action in this branch at all. Cursor-driven like every sub-menu, - # scrolled with the cursor-follow rule for the same reason the others - # are: wrapped descriptions outgrow a small window. - account_lines = account_console.build_account_menu_lines( - cursor=account_cursor - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - cursor_row = len(banner) + _cursor_line_index(account_lines) - account_menu_offset = _follow_cursor( - account_menu_offset, cursor_row, height - ) - _paint( - stdscr, - _visible_slice( - [*banner, *account_lines], account_menu_offset, height - ), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "menu" - elif ch in (curses.KEY_UP, ord("k")): - account_cursor = max(0, account_cursor - 1) - elif ch in (curses.KEY_DOWN, ord("j")): - account_cursor = min( - len(account_console.ACCOUNT_MENU) - 1, account_cursor + 1 - ) - elif ord("1") <= ch <= ord("9"): - account_selected = account_console.account_entry(ch - ord("0")) - if account_selected is not None: - _enter_account_entry(account_selected) - elif ch in (10, 13, ord(" "), curses.KEY_ENTER): - _enter_account_entry( - account_console.ACCOUNT_MENU[account_cursor] - ) - elif ch == ord("?"): - context_help_for = "account" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch above. - account_menu_offset = _scroll_offset( - ch, - account_menu_offset, - height, - len(banner) + len(account_lines), - curses, - ) - continue - - if mode == "account-pnl": - if console_binding is None: # unreachable via the menu; kept total anyway - mode = "normal" - continue - # The pnl view: OFFLINE (a transactions read, no broker, no network) and - # rebuilt per poll, fail-soft -- the offline views' contract, so an - # import landing in another terminal shows up on the next repaint. - try: - pnl_repo, _pnl_config = open_state() - pnl_lines = account_console.build_pnl_lines( - pnl_repo.get_transactions() - ) - except Exception as exc: - pnl_lines = [ - ScreenLine(f"pnl read failed: {exc} -- retrying...", "alert") - ] - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice([*banner, *pnl_lines], account_pnl_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "account" - account_pnl_offset = 0 - elif ch == ord("?"): - context_help_for = "account-pnl" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch above. - account_pnl_offset = _scroll_offset( - ch, - account_pnl_offset, - height, - len(banner) + len(pnl_lines), - curses, - ) - continue - - if mode == "account-versions" and account_versions_rows is not None: - # The versions view: the HELD rows from the entry-time scan (see - # `_enter_account_entry`) -- a repaint never re-walks importlib.metadata, - # the Venues browser's contract. Closing drops the rows, so re-entering - # scans fresh (a deployment upgrade mid-session deserves a fresh read). - versions_lines = account_console.build_versions_lines( - account_versions_rows - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice( - [*banner, *versions_lines], account_versions_offset, height - ), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "account" - account_versions_rows = None - account_versions_offset = 0 - elif ch == ord("?"): - context_help_for = "account-versions" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch above. - account_versions_offset = _scroll_offset( - ch, - account_versions_offset, - height, - len(banner) + len(versions_lines), - curses, - ) - continue - - if mode == "account-update": - if console_binding is None: # unreachable via the menu; kept total - mode = "normal" - continue - # The update view (#415): the held plan (or the check's held error) - # renders -- repaints never re-check, the versions view's contract. - # The plan's ARMED screen says what Enter asks; the RUN itself - # happens only on Enter, at the terminal, behind the typed gate. - if account_update_error is not None: - update_lines = account_console.build_update_error_lines( - account_update_error - ) - elif account_update_result is not None: - update_lines = account_console.build_update_result_lines( - account_update_result, - list(account_update_progress), - relaunch_pending=account_update_relaunch is not None, - ) - else: - assert account_update_plan is not None - update_lines = account_console.build_update_lines( - account_update_plan - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice( - [*banner, *update_lines], account_update_offset, height - ), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "account" - account_update_plan = None - account_update_error = None - account_update_progress = [] - account_update_result = None - account_update_offset = 0 - account_update_relaunch = None - elif ch in (10, 13, ord(" "), curses.KEY_ENTER): - plan_offered = ( - account_update_plan is not None and account_update_plan.offered - ) - if plan_offered and account_update_result is None: - # ARMED -> the terminal run: the typed gate renders at the - # terminal (curses suspended around it), the service's lines - # stream, and a verified success execv's the new build with - # the terminal already restored -- the process is replaced. - _run_terminal_form(stdscr, _run_account_update_at_terminal) - elif account_update_relaunch is not None: - # a relaunch-FAILED result: Enter retries ONLY the execv -- - # the update is installed and verified, so nothing re-runs - _run_terminal_form(stdscr, _retry_account_update_relaunch) - else: - # A refusal, an up-to-date plan, a failed check, or a - # finished run: Enter re-checks the release (one public - # read, held again). - _recheck_account_update() - elif ch == ord("?"): - context_help_for = "account-update" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch above. - account_update_offset = _scroll_offset( - ch, - account_update_offset, - height, - len(banner) + len(update_lines), - curses, - ) - continue - - if mode == "help-menu": - if console_binding is None: # unreachable via the menu; kept total - mode = "normal" - continue - # The Help sub-menu (C7 / O8): the glossary, the screens & actions - # catalog, the parameter help, and the keys/safety notes -- the pre-C7 - # help screen, kept whole as the fourth entry. Cursor-driven like every - # other sub-menu; entries are 1-4 direct shortcuts. - help_lines = help_console.build_help_menu_lines(cursor=help_cursor) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, _visible_slice([*banner, *help_lines], help_menu_offset, height) - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "menu" - elif ch in (curses.KEY_UP, ord("k")): - help_cursor = max(0, help_cursor - 1) - elif ch in (curses.KEY_DOWN, ord("j")): - help_cursor = min(len(help_console.HELP_MENU) - 1, help_cursor + 1) - elif ord("1") <= ch <= ord("9"): - picked = help_console.help_entry(ch - ord("0")) - if picked is not None: - help_cursor = ch - ord("0") - 1 - _enter_help_entry(picked) - elif ch in (10, 13, ord(" "), curses.KEY_ENTER): - _enter_help_entry(help_console.HELP_MENU[help_cursor]) - elif ch == ord("?"): - context_help_for = "help-menu" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch below. - help_menu_offset = _scroll_offset( - ch, - help_menu_offset, - height, - len(banner) + len(help_lines), - curses, - ) - continue - - if mode == "help-glossary": - # The glossary (O8): docs/glossary.md -- the ONE hand-written home for - # console term definitions -- rendered whole, read BOUNDED and cached - # per mtime exactly like the research doc view, so a repaint never - # re-reads an unchanged file. An absent file (an installed deployment - # has no docs/ checkout) is the builder's calm empty state. - glossary_lines = help_console.build_glossary_lines( - help_console.cached_glossary(None, glossary_cache) - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice([*banner, *glossary_lines], glossary_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "help-menu" - glossary_offset = 0 - elif ch == ord("?"): - context_help_for = "help-glossary" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch below. - glossary_offset = _scroll_offset( - ch, - glossary_offset, - height, - len(banner) + len(glossary_lines), - curses, - ) - continue - - if mode == "help-params": - # The parameter help's kind list (O8): every RULE_REGISTRY kind; the - # detail renders describe_params -- the classes' own docstrings and - # defaults, by introspection -- never a duplicate table. - from keel import agent as agent_mod - - params_lines = help_console.build_params_kinds_lines( - cursor=help_params_cursor - ) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice([*banner, *params_lines], help_params_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "help-menu" - help_params_offset = 0 - elif ch in (curses.KEY_UP, ord("k")): - help_params_cursor = max(0, help_params_cursor - 1) - elif ch in (curses.KEY_DOWN, ord("j")): - help_params_cursor = help_params_cursor + 1 # clamped every poll - elif ch in (10, 13, ord(" "), curses.KEY_ENTER): - kinds = sorted(agent_mod.RULE_REGISTRY) - if 0 <= help_params_cursor < len(kinds): - help_params_kind = kinds[help_params_cursor] - help_params_offset = 0 - mode = "help-params-kind" - elif ch == ord("?"): - context_help_for = "help-params" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch below. - help_params_offset = _scroll_offset( - ch, - help_params_offset, - height, - len(banner) + len(params_lines), - curses, - ) - # Clamp every poll: the registry's kind list is fixed, but the cursor - # may arrive past its end from a stale paint. - help_params_cursor = max( - 0, min(help_params_cursor, max(0, len(agent_mod.RULE_REGISTRY) - 1)) - ) - continue - - if mode == "help-params-kind" and help_params_kind is not None: - # One kind's parameter help, DELEGATED whole to describe_params (the - # O8 single source): the doc strings, defaults, types and choices are - # the rule class's own, rendered fresh through the service each poll. - kind_lines = help_console.build_params_help_lines(help_params_kind) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice([*banner, *kind_lines], help_params_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "help-params" - help_params_offset = 0 - elif ch == ord("?"): - context_help_for = "help-params-kind" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch below. - help_params_offset = _scroll_offset( - ch, - help_params_offset, - height, - len(banner) + len(kind_lines), - curses, - ) - continue - - if mode == "help-screens": - # The consolidated catalog (O8): every console mode's contextual-help - # rows in one scroll -- the C7 consolidation/audit of the per-screen - # strings that landed with C2-C5. - catalog_lines = help_console.build_screens_catalog_lines() - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice([*banner, *catalog_lines], help_screens_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("m")): - mode = "help-menu" - help_screens_offset = 0 - elif ch == ord("?"): - context_help_for = "help-screens" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch below. - help_screens_offset = _scroll_offset( - ch, - help_screens_offset, - height, - len(banner) + len(catalog_lines), - curses, - ) - continue - - if mode == "context-help": - # The `?` overlay (O8): the CURRENT screen's own help rows -- the module - # that owns the mode contributed them (help_console.CONTEXT_HELP is the - # registry) -- rendered over a closed scroll; q/Esc/? returns to the - # screen it was opened from, exactly where it was. - overlay_lines = help_console.build_context_help_lines(context_help_for) - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice([*banner, *overlay_lines], context_help_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("?"), ord("m")): - # `m` closes the overlay too, like every console screen ([review - # #406]): the key that steps back one level must not be dead inside - # the help overlay -- it returns to the screen `?` was pressed in, - # unmoved, exactly like q/Esc/?. - mode = context_help_for - context_help_offset = 0 - else: - # Banner-aware total, for the same reason as help's branch below. - context_help_offset = _scroll_offset( - ch, - context_help_offset, - height, - len(banner) + len(overlay_lines), - curses, - ) - continue - - if mode == "help": - help_lines = build_help_screen() - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, _visible_slice([*banner, *help_lines], help_offset, height) - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("h")): - # The keys/safety screen is the Help MENU's fourth entry since C7, - # so it closes onto the menu -- the shell is a hierarchy. - mode = "help-menu" - help_offset = 0 - elif ch == ord("?"): - # [review #406] `?` opens THIS screen's own contextual rows, like - # every console screen -- it was previously in the close-set, which - # made the "everywhere" claim in the Help menu false here. - context_help_for = "help" - context_help_offset = 0 - mode = "context-help" - else: - # The banner is PART of the scrolled list (`_visible_slice` slices the - # combined `[banner, help]`), so the scroll math must count it too -- - # clamping against the banner-EXCLUDED length left `End` two lines short - # and the help tail permanently hidden whenever a console binding was - # supplied. No binding -> the banner is empty and the total is unchanged. - help_offset = _scroll_offset( - ch, help_offset, height, len(banner) + len(help_lines), curses - ) - continue - - if mode == "insights": - # Read-only + fail-soft, mirroring the normal-mode status read below: a transient - # error (e.g. `database is locked` from a concurrent `keel agent` writer) must - # never crash the loop -- it paints an alert line and keeps polling instead. Only - # read methods are touched (`gather_status` is broker-free, and - # `build_insights_report`/`build_journal_report` are pure views over it plus - # `Repository`'s existing read methods) -- this never places an order or writes. - from keel.commands.insights import build_insights_report, build_journal_report - - now_ts = now_fn() - try: - insights_repo, insights_config = open_state() - insights_status = gather_status(insights_repo, insights_config, now_ts) - insights_report = build_insights_report( - insights_repo, insights_config, insights_status, now_ts - ) - journal_report = build_journal_report( - insights_repo, - insights_status, - now_ts, - limit=_INSIGHTS_JOURNAL_TAIL, - ) - insights_lines = build_insights_screen(insights_report, journal_report) - except Exception as exc: - insights_lines = [ - ScreenLine(f"insights read failed: {exc} -- retrying...", "alert") - ] - - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice([*banner, *insights_lines], insights_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("i")): - # `insights_back`: the dashboard by default, the Rules menu when the - # strategy console's insights entry opened it (the shell is a - # hierarchy). - mode = insights_back - insights_back = "normal" - insights_offset = 0 - elif ch == ord("?"): - context_help_for = "insights" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch above: the - # clamp must land on the same last page the combined slice shows. - insights_offset = _scroll_offset( - ch, insights_offset, height, len(banner) + len(insights_lines), curses - ) - continue - - if mode == "screen": - # OFFLINE + fail-soft, mirroring the insights branch above: `_do_screen_report` - # only reads the DB (never a broker, never the network -- see its own docstring), - # and a transient read error paints an alert line and keeps polling instead of - # crashing the loop. - try: - screen_report = _do_screen_report(open_state) - screen_lines = build_admission_screen_overlay(screen_report) - except Exception as exc: - screen_lines = [ - ScreenLine(f"screen read failed: {exc} -- retrying...", "alert") - ] - - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice([*banner, *screen_lines], screen_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("s")): - mode = "normal" - screen_offset = 0 - elif ch == ord("?"): - context_help_for = "screen" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch above. - screen_offset = _scroll_offset( - ch, screen_offset, height, len(banner) + len(screen_lines), curses - ) - continue - - if mode == "propose": - # OFFLINE + fail-soft, same shape as screen above. `build_propose_view` turns - # every SHORTLIST-FILE problem into a calm `status`/`detail` pair rather than an - # exception, so this handler is not what renders "no shortlist yet" or "not valid - # JSON" -- the overlay does. It catches what is left: a locked DB, either from - # `open_state()` or from the per-candidate screening `build_propose_view` runs - # after parsing. It is also the net that caught a `UnicodeDecodeError` escaping - # the read for a non-UTF-8 shortlist -- as an unreadable `'utf-8' codec can't - # decode byte 0xff...` toast, repainted every poll, naming no file. That hole is - # fixed at the source now; this stays as the backstop, not as the explanation. - try: - propose_lines = build_propose_overlay(_do_propose_view(open_state)) - except Exception as exc: - propose_lines = [ - ScreenLine(f"propose read failed: {exc} -- retrying...", "alert") - ] - - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice([*banner, *propose_lines], propose_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("p")): - mode = "normal" - propose_offset = 0 - elif ch == ord("?"): - context_help_for = "propose" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch above. - propose_offset = _scroll_offset( - ch, propose_offset, height, len(banner) + len(propose_lines), curses - ) - continue - - if mode == "activity": - # OFFLINE + fail-soft, like screen/propose above -- but reading a FILE, not the - # DB (see the module docstring for why that is admissible under this dashboard's - # own iron rule). `build_activity_feed` is itself total: a missing, empty, - # unreadable or unparseable log already comes back as a status this overlay - # explains in plain words. This `try` therefore catches only what is left -- - # `open_state()` itself failing, e.g. a locked DB from a concurrent `keel agent` - # writer -- and turns it into the same kind of readable feed rather than a crash. - try: - _activity_repo, activity_config = open_state() - # `now_ts` comes from the SAME `now_fn` the dashboard clocks everything else - # with, rather than from a `time.time()` inside the feed builder: the day - # boundary is then a value this loop owns and a test can pin, and it can - # never disagree with the timestamps the rest of the screen is showing. - activity_feed = build_activity_feed( - activity_config, scope=activity_scope, now_ts=float(now_fn()) - ) - # Re-clamp every poll, not just on a keypress: the feed is rebuilt from a - # file another process is writing, so it can SHRINK between polls (a rotation - # empties it) and leave the cursor past the end. - activity_cursor = max( - 0, min(activity_cursor, max(0, len(activity_feed.cycles) - 1)) - ) - activity_lines, activity_cursor_line = _activity_lines( - activity_feed, cursor=activity_cursor, expanded=activity_expanded - ) - except Exception as exc: - # The RENDER is inside this `try`, not just the feed build, and that is - # load-bearing rather than tidy: `build_activity_feed` is total, but the - # renderers it feeds are handed values written by another process (a `ts` a - # bad clock put in the year 5,000,000, say). Guarding only the build would - # leave the one call that actually formats those values outside the net, and - # an exception there would escape `curses.wrapper` and kill the dashboard. - activity_feed = ActivityFeed( - status="unreadable", source="", detail=str(exc)[:200] - ) - activity_lines, activity_cursor_line = _activity_lines(activity_feed) - - height, _width = stdscr.getmaxyx() - banner = _console_banner() - activity_offset = _follow_cursor( - activity_offset, activity_cursor_line + len(banner), height - ) - _paint( - stdscr, _visible_slice([*banner, *activity_lines], activity_offset, height) - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("v")): - mode = "normal" - activity_offset = 0 - activity_cursor = 0 - activity_scope = DEFAULT_ACTIVITY_SCOPE - activity_expanded = frozenset() - elif ch in (10, 13, ord(" "), curses.KEY_ENTER): - if 0 <= activity_cursor < len(activity_feed.cycles): - activity_expanded = activity_expanded ^ { - activity_feed.cycles[activity_cursor].key - } - elif ch == ord("t"): - # Widen (or wrap back to today). The cursor and scroll go back to the top - # because every row under them is about to change: leaving the selection on - # row 40 of a scope that now holds one row would land it somewhere arbitrary. - activity_scope = next_activity_scope(activity_scope) - activity_cursor = 0 - activity_offset = 0 - elif ch == ord("?"): - context_help_for = "activity" - context_help_offset = 0 - mode = "context-help" - else: - activity_cursor = _activity_cursor( - ch, - activity_cursor, - height, - len(activity_feed.cycles), - curses, - banner_lines=len(banner), - ) - continue - - if mode == "discover": - # NETWORK-GATED, on purpose -- see the module docstring and `build_discover_ - # overlay`'s. Unlike every branch above, this one does NOT rebuild anything on an - # ordinary poll: `discover_result`/`discover_error` are HELD from the last Enter - # (both `None` if Enter has never been pressed since the overlay opened), and - # every poll just repaints whatever is currently held. The Enter-key check below - # is the ONLY place in this whole branch that calls `_do_discover_report`. - discover_lines = build_discover_overlay(discover_result, error=discover_error) - - height, _width = stdscr.getmaxyx() - banner = _console_banner() - _paint( - stdscr, - _visible_slice([*banner, *discover_lines], discover_offset, height), - ) - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), 27, ord("d")): - mode = "normal" - discover_offset = 0 - # Discard the held result -- reopening the overlay is armed-but-not-run again. - discover_result = None - discover_error = None - elif ch in (10, 13, curses.KEY_ENTER): - _paint(stdscr, [ScreenLine("contacting venue... please wait", "normal")]) - try: - discover_result = _do_discover_report(open_state) - discover_error = None - except Exception as exc: - discover_result = None - # Truncated for the same reason `_refresh_balance`'s error is: a stray - # huge or sensitive blob (an HTTP error body, say) must never be painted - # full-screen verbatim. - discover_error = str(exc)[:200] - discover_offset = 0 - elif ch == ord("?"): - # [review #406] `?` was the one dispatched mode without it: the - # overlay opens from the ARMED state too, reads nothing and runs - # nothing -- only Enter (above) ever touches the network. - context_help_for = "discover" - context_help_offset = 0 - mode = "context-help" - else: - # Banner-aware total, for the same reason as help's branch above. - discover_offset = _scroll_offset( - ch, discover_offset, height, len(banner) + len(discover_lines), curses - ) - continue - - # mode == "normal" - now_ts = now_fn() - try: - # `Repository` exposes no public connection handle or `close()` (only the - # private `_conn`), so there is nothing safe to close here each poll -- `repo` - # simply falls out of scope and is garbage-collected. - repo, config = open_state() - report = gather_status(repo, config, now_ts) - lines = build_screen(report, now_ts, available=available) - if message is not None and now_ts - message_ts > _MESSAGE_TTL_SEC: - message = None - if message is not None: - lines = [*lines, ScreenLine(message, _message_style(message))] - _paint(stdscr, [*_console_banner(), *lines]) - except Exception as exc: - # A transient read error (e.g. `sqlite3.OperationalError: database is locked` - # from a concurrent `keel agent` writer) must never kill the dashboard -- - # paint an alert line and keep polling. `KeyboardInterrupt` is not caught here - # (it isn't an `Exception`) so Ctrl-C still reaches the outer handler below. - _paint(stdscr, [ScreenLine(f"status read failed: {exc} -- retrying...", "alert")]) - - if now_ts - last_balance_ts >= _BALANCE_REFRESH_SEC: - # A live broker call (`get_accounts`) on its own SLOW cadence -- not every - # repaint, which would hammer the venue for no operator benefit. Deliberately - # AFTER the paint above: the first iteration paints the dashboard immediately - # (`available` is still `None`, so no balance line yet) rather than blocking the - # first frame on a network call; the balance line appears on the NEXT repaint, - # once this fetch completes. `_refresh_balance` is itself fail-soft, so a - # broker/network failure here becomes a warn line next repaint, never a crash of - # this loop. - available = _refresh_balance(open_state, now_fn, _balance_fn) - last_balance_ts = now_ts - - stdscr.timeout(int(interval * 1000)) - ch = stdscr.getch() - if ch in (ord("q"), ord("Q")): - break - if ch == ord("h"): - # The Help MENU since C7 (the keys/safety screen is its fourth entry). - mode = "help-menu" - help_cursor = 0 - continue - if ch == ord("?"): - # The O8 contextual overlay: the dashboard's own "what am I looking at", - # rendered from this module's CONTEXT_HELP rows. - context_help_for = "normal" - context_help_offset = 0 - mode = "context-help" - continue - if ch == ord("i"): - mode = "insights" - insights_back = "normal" - insights_offset = 0 - continue - if ch == ord("s"): - mode = "screen" - screen_offset = 0 - continue - if ch == ord("p"): - mode = "propose" - propose_offset = 0 - continue - if ch == ord("v"): - mode = "activity" - activity_offset = 0 - activity_cursor = 0 - # Opens scoped to TODAY every single time, whatever the last visit widened it to. - activity_scope = DEFAULT_ACTIVITY_SCOPE - # Opens fully collapsed: the feed's value is the shape of the WHOLE run, and an - # overlay that reopened with one cycle already exploded would bury it. - activity_expanded = frozenset() - continue - if console_binding is not None and ch == ord("m"): - # v6: the console menu over the whole dashboard -- bound only when a - # console binding was supplied (the pre-C2 dashboard has no `m`). - mode = "menu" - menu_cursor = 0 - continue - if ch == ord("d"): - mode = "discover" - discover_offset = 0 - # Always opens ARMED, not yet run -- even if a previous visit left a held result, - # a fresh 'd' press starts over rather than silently showing stale data. (Closing - # the overlay already clears these too; this is belt-and-braces.) - discover_result = None - discover_error = None - continue - if ch == ord("r"): - last_balance_ts = 0 # force the balance to re-fetch on the next iteration too - # Toast it. `r` always succeeds and usually changes nothing on screen (the DB - # rarely differs between two repaints), so without this the key is - # indistinguishable from a dead one -- see `_REFRESH_MESSAGE`. - message = _REFRESH_MESSAGE - message_ts = now_fn() - continue - if ch == ord("a"): - - def _do_toggle() -> str: - toggle_repo, cfg = open_state() - return toggle_autonomy( - toggle_repo, now_fn(), lambda: _confirm_arm_autonomy(stdscr, cfg) - ) - - message = _guarded("autonomy", _do_toggle) - message_ts = now_fn() - continue - if ch == ord("f"): - _paint(stdscr, [ScreenLine("fetching data... please wait", "normal")]) - message = _guarded("fetch", lambda: _do_fetch(open_state, now_fn)) - message_ts = now_fn() - # A fetch is a "refresh everything" gesture: re-read the live balance too, so a - # deposit/sell that landed alongside the new candles shows up immediately. - last_balance_ts = 0 - continue - - try: - curses.wrapper(_loop) - except KeyboardInterrupt: - pass - except curses.error as exc: - # `curses.wrapper` can raise before the loop even runs -- e.g. `cbreak() returned ERR` - # when stdin/stdout is not a real, controlling terminal (a captured pipe, a harness that - # only fakes a TTY). The `_stdio_is_interactive` pre-check in `tui_cmd` catches the common - # case up front; this is the belt-and-braces for a TTY that passes `isatty()` yet still - # can't be put into cbreak mode. Turn the raw traceback into a clean, actionable message. - raise click.ClickException( - f"keel tui could not start a terminal UI ({exc}). It needs a real interactive " - "terminal; run it directly in one, or use `keel tui --once` for a one-shot snapshot." - ) from exc - - -def _stdio_is_interactive() -> bool: - """True only when BOTH stdin and stdout are real TTYs -- curses needs to read keypresses AND - own the screen, so either one being a pipe/redirect means the full-screen loop cannot run. - Kept as its own function so tests can patch it (and so the check reads as one intent).""" - return sys.stdin.isatty() and sys.stdout.isatty() - - -# -- the command ---------------------------------------------------------------------------- - - -@click.command("tui") -@click.option( - "--interval", - type=float, - default=5.0, - show_default=True, - help="Seconds between refreshes.", -) -@click.option( - "--once", - is_flag=True, - default=False, - help="Render a single frame to stdout and exit (no curses; for pipes/CI).", -) -@click.pass_context -def tui_cmd(ctx: click.Context, interval: float, once: bool) -> None: - """Live, full-screen operator dashboard, with a browsable help menu and a few gated actions. - - A view over the same `gather_status` report `keel status` prints once: mode, kill-switch, - autonomy, Rail 11 drawdown/equity state, open positions, rule counts, per-product data - freshness, and subscriptions, auto-refreshing on an interval. Never places an order. - - Network touches are the exception, not the rule, and there are exactly three: an automatic - read of the real account's spendable balance every ~30s (`get_accounts`, the same read rail - 13 funds a buy against -- see `run_live`'s docstring), `f` fetch (pulls candle history, no - orders), and `d`+Enter inside the discover overlay (pulls the venue's product list, no - orders). Everything else -- including the whole `s` screen / `p` propose / `d` discover - admission workflow up until that one Enter keypress -- is DB/filesystem reads only. - - Press `h`/`?` for the in-app help (every keybinding and the safety notes); `i` opens a - browsable, READ-ONLY insights overlay (per-rule track record, promotion-gate distance, - account summary, and a recent-trades tail -- reusing `keel insights`' own pure - builders/renderers verbatim); `s` opens a READ-ONLY screen overlay (the allowlist's current - admission verdicts, reusing `keel assets screen`'s own gate); `p` opens a READ-ONLY propose - overlay (screens the newest shortlist file in `config.proposals_dir`); `d` opens the discover - overlay ARMED but not yet run -- it explains itself and waits for Enter before making its one - live venue call, then holds that result until Enter is pressed again or the overlay closes; - `v` opens the READ-ONLY activity feed -- a chronological, newest-first, one-row-per-cycle - account of what the agent has actually been doing, read (offline, and boundedly) from the - structured engine log rather than the DB, and expandable to the individual events inside any - cycle. It is the answer to "keel has not traded -- is it even alive?", which the state-only - dashboard cannot give: a quiet cycle still gets a row, and the run of them is the answer. It - opens scoped to TODAY (the local calendar day) and `t` inside it widens to 7 days or to all - the history the bounded read covers; a day with no cycle yet says when keel last ran and when - the next run is due rather than showing an empty panel. - None of `screen`/`propose`/`discover` attests, admits, or trades -- they only PROPOSE or - REPORT. `attest` is invokable from the console's Compliance menu (and the scout browser's - `a` step) but never on a keypress alone: the form ends in a typed confirmation -- type the - asset code back; withdrawals attest types its own CLI phrase -- so the phrase, not - CLI-only-ness, is the safety. `a` - toggles autonomy (turning it OFF is instant, turning it ON needs a typed "yes" at the - terminal, exactly like `keel autonomy on`); `f` fetches fresh candle history for every - configured product (money-safe: no orders); `r` refreshes immediately. Quit with `q`. - - `m` opens the CONSOLE MENU (issue #388 C2): the menu tree over this dashboard -- the - console shell. Profile switches the deployment (the config+db pair -- paper-forward, - paper-hourly, paper-equities, live) in one action, rebinding everything the console - reads; selecting LIVE asks an explicit y/N at the terminal first and is marked - unmistakably in the header once active. Rules (issue #390 C4) opens the STRATEGY - CONSOLE: the tried-vs-used ledger (every rule with its recorded lifecycle context -- - status, stamps, and the insights gate distance for paper rules; NO backtest runs on - entry: each rule's verdict is an explicit, warned, per-rule re-compute on Enter), - simulate + results (ARMED; Enter confirms a run that fetches and writes the report - exactly as the CLI - does, then holds the verdict and the report verbatim), add-a-strategy (per-field - parameter help from the rule classes themselves, landing as candidate), retry - (re-backtest + re-attempt promote with its y/N confirm and the TYPED `--force` - phrase), enable/disable/demote, and insights. Research (C4) opens the evidence - READERS: the experiments and research-docs corpora, the promotion reports (including - a just-run simulation's, newest first), and the trials ledger with its chain verdict - -- all read-only, all bounded reads. Compliance (issue #389 C3) opens the - Compliance sub-menu: screen/propose/holdings/discover/subscription-show/purification - as browsable service reports (holdings and discover ARMED -- one live venue read each, - only on Enter), the record-writes (attest [typed: type the asset code back], - attest-instrument, exempt/unexempt, subscription attest/set, withdrawals attest [typed - 'yes' when enabling -- the CLI's own gate, in-console]) as terminal forms that call the - same services the CLI calls, the Scout results browser (the proposals directory from - config, screened through the admission services, attest offered but never auto-run), - and the read-only "Shariah in force" browser (the attestations, exemptions and - fiqh-derived rails in force for the active profile, quoted and cited from - docs/fiqh-basis.md, honesty lines always visible). Trading/Data/Account are - placeholders for later console slices and say which one they land in. Every screen - carries the session banner: the active deployment, then the venue's market session and - clock (24/7, or OPEN/CLOSED with the next open/close, or CLOCK UNAVAILABLE fail-loud) - -- rendered from the agent cycle's own recorded session state, never a clock call of - the TUI's own. The shell itself adds no network touch. - - `--once` renders a single, static frame to stdout and exits without touching curses, for - pipes/CI, matching `status`'s scripting-friendliness (and prints the disclaimer footer after - the frame) -- none of the interactive actions (including `screen`/`propose`/`discover`) are - available there, but the session banner still heads the frame so a piped snapshot names its - deployment and market session. The default, interactive path owns the whole screen via - `curses.wrapper` and re-opens the repo every poll so it reflects writes committed by a - separate `keel agent` process. - """ - if interval <= 0: - raise click.ClickException("--interval must be > 0") - - # v6: the console binding IS the open_state -- the same `_load_cfg`/`_open_repo` pair - # every CLI command uses, held mutable so the Profile menu can rebind it in one action. - from keel.commands.console import ConsoleBinding, console_banner_lines - - console_binding = ConsoleBinding( - ctx, config_path=ctx.obj["config_path"], db_path=ctx.obj["db_path"] - ) - open_state: OpenState = console_binding.open_state - - now_fn: NowFn = lambda: int(time.time()) # noqa: E731 - - if once: - run_once( - open_state, - now_fn, - click.echo, - banner_fn=lambda repo, config, now_ts: console_banner_lines( - console_binding, repo, config, now_ts - ), - ) - click.echo("") - click.echo(DISCLAIMER) - return - - if not _stdio_is_interactive(): - raise click.ClickException( - "keel tui needs an interactive terminal (a real TTY on both stdin and stdout). " - "Run it directly in a terminal, or use `keel tui --once` for a one-shot snapshot." - ) - - run_live(open_state, now_fn, interval, console_binding=console_binding) diff --git a/keel/commands/update.py b/keel/commands/update.py index 4c879925..11d685d5 100644 --- a/keel/commands/update.py +++ b/keel/commands/update.py @@ -74,7 +74,6 @@ from collections.abc import Callable, Collection, Sequence from dataclasses import dataclass from pathlib import Path -from typing import NoReturn import click @@ -932,65 +931,22 @@ def say(line: str) -> None: ) -# -- the relaunch: execv the new build's console --------------------------------------------------- - - -def build_relaunch_argv(venv_python: Path, original_argv: Sequence[str]) -> list[str]: - """The argv a relaunch execv's: the NEW venv's `keel` console entry carrying the - original invocation's arguments. PURE. - - `original_argv` is the running process's `sys.argv`. argv[0] -- the OLD binary's - path, possibly a wrapper -- is REPLACED by the new entry, and EVERY argument after - it is kept VERBATIM: no reordering, no prepending, ever. Deployment wrappers exec - `keel --config X --db Y tui`, so argv[0] is the wrapper and the subcommand sits - AFTER the flags -- click parses that identically on the relaunched process, and - any reconstruction of our own (the old fallback prepended `tui`, producing a - duplicate subcommand and a usage error) breaks exactly those wrappers. Only when - the original argv is genuinely unavailable -- the bare-wrapper case, an argv with - NO arguments at all -- is `[keel, 'tui']` constructed, so a relaunch always opens - the operator console, never a bare interpreter.""" - new_keel = console_entry(venv_python) - args = [str(arg) for arg in original_argv[1:]] - if not args: - return [str(new_keel), "tui"] - return [str(new_keel), *args] - - -def relaunch_tui( - venv_python: Path, - original_argv: Sequence[str], - *, - execv: Callable[[str, list[str]], NoReturn] | None = None, -) -> Callable[[], NoReturn]: - """Build (do not run) the relaunch closure: execv the new build's `keel` entry - with the reconstructed TUI argv. PURE -- it returns the closure; the CLI/TUI - decide to call it (only the TUI does; the CLI prints the command instead). - - The CALLER must have restored the terminal first -- the TUI runs the closure from - inside its curses suspend dance, after `endwin`. `execv` replaces the process, so - the closure does not return; an `OSError` from it (permissions, a missing - interpreter) is wrapped into the same honest `UpdateError` as a returning execv -- - naming the manual `keel tui` start -- so no front-end ever crashes on a bare - OSError, and a failed relaunch can be rendered instead of losing the run's state.""" - new_keel = console_entry(venv_python) - argv = build_relaunch_argv(venv_python, original_argv) - - def _relaunch() -> NoReturn: - try: - if execv is not None: - execv(str(new_keel), argv) - else: - os.execv(str(new_keel), argv) - except OSError as exc: - raise UpdateError( - f"relaunch failed ({exc}): execv could not start the new build -- " - f"start the console by hand: `{new_keel} tui`" - ) from exc - raise UpdateError( - f"relaunch failed: execv returned -- start the console by hand: `{new_keel} tui`" - ) - - return _relaunch +# -- the relaunch, and why there is no longer one -------------------------------------------------- +# +# `build_relaunch_argv` and `relaunch_tui` stood here until #541. They existed for ONE caller -- +# the console's Account-menu update view, which had to replace its own process because a +# long-running curses front-end keeps the build it started with. The console is deleted, the +# dashboard with it, and nothing else ever called them: an `execv` reachable from no code path is +# a loaded gun in a drawer. +# +# Their fallback was also, by this commit, wrong: with no argv naming a subcommand they built +# `[keel, "tui"]`, which is now a command that does not exist. A relaunch that execs into +# `Error: No such command 'tui'` is worse than no relaunch, because it happens after the new build +# is already installed. +# +# `keel serve` needs no equivalent. It is a server an operator stops and starts, and the browser +# tab reconnects to whatever is listening; the CLI path always printed the command rather than +# relaunching anything, and that is now the only path. # -- the typed gate: ONE wording, both front-ends -------------------------------------------------- @@ -1187,9 +1143,9 @@ def update_cmd(ctx: click.Context, check: bool) -> None: click.echo(line) if result.ok: click.echo( - "the TUI keeps the build it started with until you relaunch it: run " - "`keel tui` (or your deployment wrapper) to start the new build -- this " - "command does not relaunch it for you." + "a long-running process keeps the build it started with: stop and restart " + "`keel serve` (or your deployment wrapper) to pick up the new build -- this " + "command does not restart anything for you." ) return ctx.exit(1) diff --git a/keel/corpus.py b/keel/corpus.py new file mode 100644 index 00000000..47594c1f --- /dev/null +++ b/keel/corpus.py @@ -0,0 +1,116 @@ +"""The research corpora on disk: where they are, what is in them, and bounded reads (#541). + +**Why this module exists.** These four things lived in `keel/commands/research_console.py` and +were the only part of the console layer anything outside it used -- `keel/mcp/tools.py` imports +them to answer an assistant's questions about the corpora. #541 deleted the TUI and, with it, the +console layer that was reachable only from inside it; this is what had to survive, moved rather +than re-implemented so the MCP server keeps reading exactly what the console read. + +Top level rather than under `keel/commands/`, for the same reason `keel/capabilities.py` and +`keel/install.py` are: it is not a command, it has no console of its own, and the directory it +came from is the one #525 is about. + +**Everything here is total.** A missing directory is `()`, an unreadable file is a one-line +notice, and a file past the bound is truncated with a note saying so -- never an exception and +never a silent partial view. That contract came from a reader that repainted on a timer, where a +raised exception meant a dead screen; it is kept because the caller is now an assistant, where a +raised exception means an answer that never arrives. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +#: The research-docs corpus (`docs/research/`). +RESEARCH_DOCS_DIR = Path("docs/research") + +#: The most bytes of one document this module will ever read (1 MiB). +#: +#: A reader must never have its cost grow with whatever a runaway writer put in the file. A +#: document past the bound reads its first `MAX_DOC_BYTES` with a loud truncation note, never an +#: unbounded read and never a silent partial view. +MAX_DOC_BYTES = 1024 * 1024 + + +def corpus_path(target: str) -> Path: + """The corpus directory for a `target` ("experiments"/"research"/"reports"). + + Single-sourced from the code that WRITES into it and resolved at call time: the experiments + corpus is the trials ledger's own directory (`DEFAULT_LEDGER_PATH.parent` -- read when asked, + so a test-isolated or relocated ledger relocates the reader with it), the reports corpus is + where `run_simulation` writes (`default_report_path`), and the research docs are the repo's + own `docs/research/`. Never a path table of its own that could drift from the writer. + """ + from keel.commands.simulate import default_report_path + from keel.research import ledger as trials_ledger + + if target == "experiments": + return trials_ledger.DEFAULT_LEDGER_PATH.parent + if target == "reports": + return default_report_path(0).parent + return RESEARCH_DOCS_DIR + + +@dataclass(frozen=True) +class DocFile: + """One corpus document with its display facts: WHEN it was written (mtime) and how big it is + -- carried rather than re-`stat`-ed per render.""" + + path: Path + mtime_ts: float + size_bytes: int + + +def list_documents(directory: Path, *suffixes: str) -> tuple[DocFile, ...]: + """Every document under `directory` (filtered by `suffixes` when given, else every file), + NEWEST FIRST by (mtime, name). + + An absent or unreadable directory is `()` rather than an exception -- a caller must be able + to render a calm empty state -- and per-file stat failures cost that row, never the whole + list.""" + try: + if not directory.is_dir(): + return () + except OSError: + return () + found: list[DocFile] = [] + for candidate in directory.iterdir(): + if not candidate.is_file(): + continue + if suffixes and candidate.suffix not in suffixes: + continue + try: + stat = candidate.stat() + except OSError: + continue + found.append(DocFile(path=candidate, mtime_ts=stat.st_mtime, size_bytes=stat.st_size)) + found.sort(key=lambda f: (f.mtime_ts, f.path.name), reverse=True) + return tuple(found) + + +def read_document_lines(path: Path, *, max_bytes: int = MAX_DOC_BYTES) -> list[str]: + """The document's own lines, BOUNDED: at most the first `max_bytes` bytes are read, decoded + UTF-8 (errors replaced -- a stray byte must not kill the read), with a loud truncation note at + the head when the file is past the bound. An unreadable file is a calm one-line notice, never + a traceback.""" + try: + with path.open("rb") as handle: + blob = handle.read(max_bytes + 1) + except OSError as exc: + return [f"(unreadable: {exc})"] + lines: list[str] = [] + if len(blob) > max_bytes: + try: + total = path.stat().st_size + except OSError: + total = -1 + total_note = f"{total} bytes" if total >= 0 else "an unknown size" + lines.append( + f"(truncated: the file is {total_note}; showing the first " + f"{max_bytes // 1024} KiB of it)" + ) + blob = blob[:max_bytes] + text = blob.decode("utf-8", errors="replace") + lines.extend(text.splitlines()) + return lines diff --git a/keel/mcp/tools.py b/keel/mcp/tools.py index 3952e8a8..b2d8763b 100644 --- a/keel/mcp/tools.py +++ b/keel/mcp/tools.py @@ -54,7 +54,7 @@ #: damage onward -- a tool response is not the place to restate every one of them. TRIALS_MAX_CHAIN_ERRORS = 20 -#: The corpora `keel.commands.research_console.corpus_path` resolves -- repeated here only so +#: The corpora `keel.corpus.corpus_path` resolves -- repeated here only so #: the schema an MCP client sees can enumerate them. CORPORA = ("research", "experiments", "reports") @@ -310,7 +310,7 @@ def _trials(args: dict[str, Any]) -> dict[str, Any]: } def _reports(args: dict[str, Any]) -> dict[str, Any]: - from keel.commands.research_console import corpus_path, list_documents, read_document_lines + from keel.corpus import corpus_path, list_documents, read_document_lines corpus = str(args.get("corpus") or "research") if corpus not in CORPORA: diff --git a/tests/commands/test_account_console.py b/tests/commands/test_account_console.py deleted file mode 100644 index 00f7b348..00000000 --- a/tests/commands/test_account_console.py +++ /dev/null @@ -1,963 +0,0 @@ -"""Tests for `keel.commands.account_console` -- the Account menu (issue #392 C6; PRD §3's -Account branch: pnl + versions), the console tree's LAST placeholder turned real. - -Four surfaces, all pinned here: - -* **The sub-menu** -- PRD §3's Account branch: `pnl` and `versions`, both READ-ONLY views - (the one console area with no write path at all -- its ceremony row in the C6 audit - table says exactly that). -* **pnl** -- the SAME report `keel pnl` prints (`commands.pnl.build_pnl_report` + - `render_pnl_report`, the C1 services), rendered verbatim from the ACTIVE deployment's - imported transactions; an empty transactions table renders its honest empty state - naming the import path, never a confident `total realized P&L: 0`. -* **versions** -- the SAME lines `keel versions` prints, through ONE shared renderer - extracted to `keel.commands.versions` (the C1 "one implementation, two front-ends" - rule), disagreement styled loud; the environment scan runs ONCE per entry, never per - poll (the venues browser's contract). -* **The live loop** -- the tree's Account entry opens this sub-menu (no more "lands in - C6" notice anywhere in the tree), the views are banner-aware, offline and m-close. - -Mirrors `tests/commands/test_data_console.py`'s fixture style. -""" - -from __future__ import annotations - -import sys -from typing import Any - -import pytest - -from keel.commands import account_console as ac -from keel.commands.versions import render_versions_lines -from keel.data.db import connect, migrate -from keel.data.repository import Repository -from keel.version import BuildInfo, InstallReport - -NOW_TS = 1_800_000_000 - -_RELEASE = BuildInfo(version="0.6.0", commit="deadbeef", dirty=False, source="release") - - -@pytest.fixture -def repo() -> Repository: - conn = connect(":memory:") - migrate(conn) - return Repository(conn) - - -def _buy(repo: Repository, asset: str, qty: str, price: str, ts: int) -> None: - """One imported-style BUY row -- the CSV-import shape `keel db import` writes.""" - from decimal import Decimal - - repo.upsert_transaction( - dict( - coinbase_id=f"tx-{asset}-{ts}", - source="csv_import", - type="Buy", - asset=asset, - ts=ts, - qty=Decimal(qty), - price=Decimal(price), - subtotal=None, - total=None, - fees=Decimal("0.10"), - notes=None, - rule_id=None, - order_id=None, - ) - ) - - -# -- the sub-menu (PRD §3's Account branch) -------------------------------------------------------- - - -def test_the_account_menu_is_the_prd_account_branch() -> None: - """PRD §3's Account line is pnl and versions (both views); #415 appends the - branch's ONE write path, the typed update entry.""" - assert [(e.label, e.kind, e.target) for e in ac.ACCOUNT_MENU] == [ - ("pnl", "view", "pnl"), - ("versions", "view", "versions"), - ("update", "armed", "update"), - ] - - -def test_the_account_menu_entries_are_reachable_by_their_displayed_ordinals() -> None: - for entry in ac.ACCOUNT_MENU: - assert ac.account_entry(entry.ordinal) is entry - assert ac.account_entry(0) is None - assert ac.account_entry(4) is None - - -def test_the_menu_screen_renders_every_entry_and_the_keys() -> None: - lines = ac.build_account_menu_lines(cursor=1) - texts = [line.text for line in lines] - for entry in ac.ACCOUNT_MENU: - assert any(entry.label in t for t in texts), entry.label - # the cursor marks exactly one row (versions, index 1), like every console sub-menu - marked = [t for t in texts if t.lstrip().startswith(">")] - assert len(marked) == 1 and "versions" in marked[0] - # the keys footer states the console contracts: ordinals, select, and m-close - joined = "\n".join(texts) - assert "Enter/Space select" in joined - assert "1-3 jump" in joined - assert "q/Esc/m" in joined - assert all(len(line.text) <= 80 for line in lines) - - -def test_the_account_screens_fit_the_80_column_clip(repo: Repository) -> None: - """`_paint` clips at the window width -- every line this module renders must fit.""" - _buy(repo, "BTC", "0.5", "60000", NOW_TS - 86_400) - _buy(repo, "ETH", "2", "3000", NOW_TS - 43_200) - for lines in ( - ac.build_account_menu_lines(), - ac.build_pnl_lines(repo.get_transactions()), - ac.build_pnl_lines([]), - ac.build_versions_lines( - render_versions_lines( - _RELEASE, - InstallReport( - distributions={ - "keel-trader": "0.6.0", - "keel-core": "0.5.5", - }, - source="release", - ), - ) - ), - ): - assert all(len(line.text) <= 80 for line in lines), lines - - -# -- pnl: the service's own report, verbatim, over the ACTIVE deployment --------------------------- - - -def test_pnl_renders_the_services_own_report_verbatim(repo: Repository) -> None: - _buy(repo, "BTC", "0.5", "60000", NOW_TS - 86_400) - _buy(repo, "BTC", "0.5", "62000", NOW_TS - 43_200) - lines = ac.build_pnl_lines(repo.get_transactions()) - texts = [line.text for line in lines] - # the report's own lines render EXACTLY as `keel pnl` prints them (the C1 parity) - from keel.commands.pnl import build_pnl_report, render_pnl_report - - for report_line in render_pnl_report(build_pnl_report(repo.get_transactions(), None, {})): - assert report_line.strip() in [t.strip() for t in texts], report_line - # and the screen says what it is: the overall report, no marks supplied - joined = "\n".join(texts) - assert "no marks supplied" in joined - - -def test_pnl_renders_its_honest_empty_state_when_nothing_is_imported(repo: Repository) -> None: - """No CSV imports, no report: an empty transactions table must NOT render a confident - `total realized P&L: 0` -- it names the import path that would fill it instead.""" - lines = ac.build_pnl_lines(repo.get_transactions()) - joined = "\n".join(line.text for line in lines) - assert "total realized P&L" not in joined - assert "no imported transactions" in joined - assert "db import" in joined - - -# -- versions: ONE renderer, two front-ends -------------------------------------------------------- - - -def _install(monkeypatch: pytest.MonkeyPatch, dists: dict[str, str]) -> None: - monkeypatch.setattr(ac, "build_info", lambda: _RELEASE) - monkeypatch.setattr( - ac, - "check_install", - lambda source=None: InstallReport(distributions=dict(dists), source="release"), - ) - - -def test_versions_view_renders_the_shared_renderers_exact_lines( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The console shows the SAME lines `keel versions` prints -- through the ONE renderer - both front-ends share (`versions.render_versions_lines`), so the deploy check cannot - drift between terminal and console.""" - _install(monkeypatch, {"keel-trader": "0.6.0", "keel-core": "0.6.0"}) - rows = ac.versions_rows() - lines = ac.build_versions_lines(rows) - texts = [line.text for line in lines] - for text, _to_stderr in render_versions_lines( - _RELEASE, InstallReport(distributions={"keel-trader": "0.6.0", "keel-core": "0.6.0"}, - source="release") - ): - assert text in texts, text - - -def test_versions_disagreement_is_loud_and_the_scan_runs_once_per_read( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A partial install renders its error lines in the alert style (the CLI exits - non-zero; the console's loud equivalent), and ONE `versions_rows()` read is ONE - environment scan -- the read the live loop holds, so repaints never re-scan (the - hold itself is pinned by the run_live test below).""" - scans: list[int] = [] - - def _scan(source: str | None = None) -> InstallReport: - scans.append(1) - return InstallReport( - distributions={"keel-trader": "0.6.0", "keel-core": "0.5.5"}, source="release" - ) - - monkeypatch.setattr(ac, "build_info", lambda: _RELEASE) - monkeypatch.setattr(ac, "check_install", _scan) - - rows = ac.versions_rows() - - assert len(scans) == 1 - lines = ac.build_versions_lines(rows) - alert_texts = [line.text for line in lines if line.style == "alert"] - assert any("PARTIAL INSTALL" in t for t in alert_texts) - - -def test_versions_reports_a_checkout_with_nothing_installed_calmly( - monkeypatch: pytest.MonkeyPatch, -) -> None: - checkout = BuildInfo(version="0.6.0", commit="deadbeef", dirty=False, source="checkout") - monkeypatch.setattr(ac, "build_info", lambda: checkout) - monkeypatch.setattr( - ac, - "check_install", - lambda source=None: InstallReport(distributions={}, source="checkout"), - ) - lines = ac.build_versions_lines(ac.versions_rows()) - joined = "\n".join(line.text for line in lines) - assert "nothing to compare" in joined - - -def test_a_non_reproducible_build_warns_on_the_console_too( - monkeypatch: pytest.MonkeyPatch, -) -> None: - dirty = BuildInfo(version="0.6.0", commit="deadbeef", dirty=True, source="checkout") - monkeypatch.setattr(ac, "build_info", lambda: dirty) - monkeypatch.setattr( - ac, - "check_install", - lambda source=None: InstallReport(distributions={"keel-trader": "0.6.0"}, - source="checkout"), - ) - lines = ac.build_versions_lines(ac.versions_rows()) - assert any("NOT reproducible" in line.text for line in lines) - - -def test_the_context_help_covers_all_four_modes() -> None: - for mode in ("account", "account-pnl", "account-versions", "account-update"): - assert ac.CONTEXT_HELP[mode], mode - for subject, description in ac.CONTEXT_HELP[mode]: - assert subject.strip() and description.strip(), mode - - -# -- the live loop: the tree's last placeholder is real -------------------------------------------- - - -def _drive( - repo: Repository, - config: Any, - keys: list[int], - monkeypatch: pytest.MonkeyPatch, - tmp_path: Any, - *, - height: int = 30, - width: int = 100, -) -> Any: - """Run `run_live` under the fake curses module with a REAL console binding over the - temp deployment dir, replaying `keys`; returns the stdscr (its `calls` hold every - painted line). The balance refresh's broker construction is stubbed (it fires on the - first poll by design) so no test touches the network.""" - import click - - from keel.commands import console as console_mod - from keel.commands.tui import run_live - - monkeypatch.chdir(tmp_path) - ctx = click.Context(click.Command("tui"), obj={}) - ctx.obj["config_path"] = "config.paperforward.yaml" - ctx.obj["db_path"] = "keel.db" - binding = console_mod.ConsoleBinding( - ctx, config_path="config.paperforward.yaml", db_path="keel.db" - ) - - stdscr = _KeySequenceStdscr(height=height, width=width, keys=keys) - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - class _FakeBroker: - def get_accounts(self) -> list[Any]: - return [] - - monkeypatch.setattr( - "keel.commands._common._build_broker", - lambda cfg, timeout=None: _FakeBroker(), - ) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01, console_binding=binding) - return stdscr - - -class _FakeCursesError(Exception): - pass - - -def _fake_curses() -> Any: - from types import SimpleNamespace - - calls: list[str] = [] - return SimpleNamespace( - A_BOLD=1, - A_DIM=2, - A_UNDERLINE=4, - A_REVERSE=8, - A_NORMAL=0, - COLOR_RED=1, - COLOR_YELLOW=2, - COLOR_GREEN=3, - KEY_UP=1001, - KEY_DOWN=1002, - KEY_PPAGE=1003, - KEY_NPAGE=1004, - KEY_HOME=1005, - KEY_END=1006, - KEY_ENTER=1007, - error=_FakeCursesError, - has_colors=lambda: False, - start_color=lambda: calls.append("start_color"), - use_default_colors=lambda: calls.append("use_default_colors"), - init_pair=lambda n, fg, bg: calls.append(f"init_pair:{n}"), - color_pair=lambda n: 1 << (10 + n), - curs_set=lambda visibility: None, - def_prog_mode=lambda: calls.append("def_prog_mode"), - endwin=lambda: calls.append("endwin"), - reset_prog_mode=lambda: calls.append("reset_prog_mode"), - wrapper=None, - calls=calls, - ) - - -class _FakeStdscr: - def __init__(self, height: int, width: int) -> None: - self._height = height - self._width = width - self.calls: list[tuple[int, int, str, int]] = [] - - def getmaxyx(self) -> tuple[int, int]: - return (self._height, self._width) - - def addstr(self, y: int, x: int, text: str, attr: int = 0) -> None: - self.calls.append((y, x, text, attr)) - - def erase(self) -> None: - pass - - def refresh(self) -> None: - pass - - def timeout(self, ms: int) -> None: - pass - - -class _KeySequenceStdscr(_FakeStdscr): - def __init__(self, height: int, width: int, keys: list[int]) -> None: - super().__init__(height, width) - self._keys = list(keys) - - def getch(self) -> int: - if self._keys: - return self._keys.pop(0) - return ord("q") - - -def _config() -> Any: - from decimal import Decimal - - from keel.config import ( - AutoTradeConfig, - Caps, - Config, - DcaConfig, - MarketDataConfig, - MoneyMgmtConfig, - ) - from keel.types import Granularity - - return Config( - allowlist=["BTC"], - target_weights={}, - risk_pct=Decimal("0.01"), - caps=Caps( - max_per_order_usd=Decimal("100000"), - max_per_day_usd=Decimal("300000"), - max_exposure_usd=Decimal("1000000"), - max_per_asset_pct=Decimal("1"), - ), - market_data=MarketDataConfig(granularities=[Granularity.ONE_HOUR], history_days=365), - auto_trade=AutoTradeConfig(mode="paper", interval_sec=900), - money_mgmt=MoneyMgmtConfig( - max_total_dd_pct=Decimal("0.20"), max_weekly_dd_pct=Decimal("0.08") - ), - dca=DcaConfig(budget_usd=Decimal("50"), cadence_days=7), - ) - - -def test_run_live_the_account_entry_opens_the_sub_menu_and_the_views_close_on_m( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - """8 (or the cursor) opens the Account sub-menu -- no placeholder notice anywhere; - Enter opens pnl; m steps back one level to the sub-menu, then to the menu.""" - _buy(repo, "BTC", "0.5", "60000", NOW_TS - 86_400) - # m -> menu; '8' -> account; poll; Enter -> account-pnl; poll; 'm' -> account; 'q' - keys = [ord("m"), ord("8"), -1, ord("\r"), -1, ord("m"), -1] - stdscr = _drive(repo, _config(), keys, monkeypatch, tmp_path) - painted = [call[2] for call in stdscr.calls] - account_idx = next(i for i, t in enumerate(painted) if "keel console -- account" in t) - assert not any("lands in C6" in t for t in painted) - pnl_idx = next(i for i, t in enumerate(painted) if "account / pnl" in t) - assert pnl_idx > account_idx - # m from the pnl view returns to the ACCOUNT menu (the shell is a hierarchy) - assert any("keel console -- account" in t for i, t in enumerate(painted) if i > pnl_idx) - - -def test_run_live_the_pnl_view_renders_the_empty_state_and_the_banner( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - keys = [ord("m"), ord("8"), -1, ord("\r"), -1] - stdscr = _drive(repo, _config(), keys, monkeypatch, tmp_path) - painted = [call[2] for call in stdscr.calls] - assert any("no imported transactions" in t for t in painted) - # the banner rides every console screen, this one included - assert any(t.startswith("console:") for t in painted) - - -def test_run_live_the_versions_entry_holds_its_rows_across_repaints( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - scans: list[int] = [] - monkeypatch.setattr(ac, "build_info", lambda: _RELEASE) - monkeypatch.setattr( - ac, - "check_install", - lambda source=None: ( - scans.append(1), - InstallReport(distributions={"keel-trader": "0.6.0"}, source="release"), - )[1], - ) - # m -> menu; '8' -> account; poll; down; Enter -> versions; two repaints; q - keys = [ord("m"), ord("8"), -1, ord("j"), ord("\r"), -1, -1] - stdscr = _drive(repo, _config(), keys, monkeypatch, tmp_path) - painted = [call[2] for call in stdscr.calls] - assert any("keel console -- account / versions" in t for t in painted) - # ONE scan served the entry and every repaint after it (the venues contract) - assert len(scans) == 1 - - -def test_the_console_tree_has_no_placeholders_left() -> None: - """C6 landed Account: every PRD §3 entry is a live destination now -- the placeholder - MECHANISM stays for future slices, but no current entry points at one.""" - from keel.commands import console as console_mod - - assert all(entry.lands_in is None for entry in console_mod.CONSOLE_MENU) - assert console_mod.menu_entry(8).action == "account" - - -# -- the update entry (issue #415): the Account branch's ONE gated write path ---------------------- -# -# The branch was read-only top to bottom through C6; the self-update slice adds its -# single mutating action -- check for a newer release, show the whole plan, and deploy -# it only behind the CLI's OWN typed gate (`keel update`'s wording, one gate, both -# front-ends). Everything below drives the service seams with fakes: no network, no -# uv, no execv. - - -def _fake_plan(tmp_path: Any, *, offered: bool = True) -> Any: - import json - from pathlib import Path - - from keel.commands import update as up - - current = "0.6.0" - latest = "0.7.0" if offered else "0.6.0" - (tmp_path / "keel.db").write_bytes(b"paper db") - (tmp_path / "keel-live.db").write_bytes(b"live db") - # the runbook's deployment layout: the running package resolves from the launch - # folder's OWN .venv site-packages, installed from a wheel -- so the plan is OFFERED - site = tmp_path / ".venv/lib/python3.12/site-packages" - (site / "keel").mkdir(parents=True, exist_ok=True) - (site / "keel" / "__init__.py").write_text("") - dist_info = site / "keel_trader-0.6.0.dist-info" - dist_info.mkdir(exist_ok=True) - (dist_info / "direct_url.json").write_text( - json.dumps({"url": "file:///Release/keel_trader-0.6.0-py3-none-any.whl"}) - ) - return up.plan_update( - up.parse_release( - b'{"tag_name": "v%s", "assets": [%s]}' - % ( - latest.encode(), - b",".join( - b'{"name": "%s-%s-py3-none-any.whl", "browser_download_url": "https://e/%s"}' - % (prefix.encode(), latest.encode(), prefix.encode()) - for prefix in up.PRODUCTION_WHEEL_PREFIXES - ), - ) - ), - build=BuildInfo(version=current, commit="deadbeef", dirty=False, source="release"), - installed={ - "keel-trader": current, - "keel-core": current, - "keel-broker-api": current, - "keel-broker-coinbase": current, - }, - launch_dir=tmp_path, - venv_python=tmp_path / ".venv/bin/python", - package_file=Path(site / "keel" / "__init__.py"), - ) - - -def test_the_account_menu_gains_the_typed_update_entry() -> None: - """The Account branch's third entry: update -- the ONE mutating action in the - branch, an ARMED view whose Enter demands the CLI's own typed gate (its ceremony - row). pnl and versions stay views.""" - assert [(e.label, e.kind, e.target) for e in ac.ACCOUNT_MENU] == [ - ("pnl", "view", "pnl"), - ("versions", "view", "versions"), - ("update", "armed", "update"), - ] - assert ac.account_entry(3) is not None - assert ac.account_entry(4) is None - - -def test_update_check_reads_the_release_and_builds_the_plan( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """The entry-time read: ONE public-API call + the plan -- the versions view's - contract (read once, hold), never per poll.""" - from keel.commands import update as up - - plan = _fake_plan(tmp_path) # built BEFORE the seam is patched - release = up.parse_release(b'{"tag_name": "v0.7.0", "assets": []}') - calls: list[str] = [] - monkeypatch.setattr(up, "latest_release", lambda fetch=None: calls.append("fetch") or release) - monkeypatch.setattr( - up, - "plan_update", - lambda rel, **kwargs: calls.append("plan") or plan, - ) - checked = ac.update_check(launch_dir=tmp_path, venv_python=tmp_path / ".venv/bin/python") - assert checked is plan - assert checked.offered is True - assert calls == ["fetch", "plan"] - - -def test_update_check_propagates_a_network_failure_honestly( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """A failed check is an ERROR the view holds (with Enter as the retry), not a - confident 'up to date' -- the same honesty `fetch --check` keeps.""" - from keel.commands import update as up - - def _offline(url: str) -> Any: - raise up.UpdateError("could not reach the GitHub releases API") - - with pytest.raises(up.UpdateError, match="GitHub"): - ac.update_check( - launch_dir=tmp_path, venv_python=tmp_path / ".venv/bin/python", fetch=_offline - ) - - -def test_build_update_error_lines_render_honestly_with_the_retry( - tmp_path: Any, -) -> None: - lines = ac.build_update_error_lines("could not reach the GitHub releases API (offline)") - joined = "\n".join(line.text for line in lines) - assert "could not reach" in joined - assert "Enter" in joined and "re-check" in joined.lower() - assert all(len(line.text) <= 80 for line in lines) - - -def test_build_update_lines_renders_the_armed_plan(tmp_path: Any) -> None: - plan = _fake_plan(tmp_path) - lines = ac.build_update_lines(plan) - texts = [line.text for line in lines] - joined = "\n".join(texts) - assert any("ARMED -- nothing has run yet." == t.strip() for t in texts) - # current vs latest lead - assert "0.6.0" in joined and "0.7.0" in joined - # the full plan: the production wheels, the Release dir, the DB backups NAMED, the venv - for prefix in ("keel_core", "keel_trader"): - assert prefix in joined - assert "download to:" in joined and "Release" in joined - assert "keel.db" in joined and ".bak-before-0.7.0-" in joined - assert "RUNNING venv" in joined - # the typed gate is named as the confirm step -- Enter alone is not enough - assert "typed" in joined.lower() - assert "replac" in joined.lower() # the running binary is replaced -- stated - assert all(len(t) <= 80 for t in texts) - - -def test_build_update_lines_renders_refusals_and_says_nothing_runs(tmp_path: Any) -> None: - plan = _fake_plan(tmp_path, offered=False) - lines = ac.build_update_lines(plan) - joined = "\n".join(line.text for line in lines) - assert "latest" in joined.lower() - assert any(line.style == "muted" for line in lines) - assert all(len(line.text) <= 80 for line in lines) - # an up-to-date plan renders calm, and offers no Enter-run - assert "Enter" not in joined or "re-check" in joined.lower() - - -def test_build_update_result_lines_hold_the_progress_and_the_recovery( - tmp_path: Any, -) -> None: - from keel.commands import update as up - - result = up.UpdateResult( - ok=False, - steps=(), - error="verify failed: PARTIAL INSTALL -- manual recovery: the runbook", - rolled_back=True, - backups=(), - ) - lines = ac.build_update_result_lines( - result, progress=("backing up keel.db", "installing the production wheels") - ) - joined = "\n".join(line.text for line in lines) - assert "backing up keel.db" in joined # the streamed lines are HELD, verbatim - assert "verify failed" in joined - assert all(len(line.text) <= 80 for line in lines) - - -def test_run_update_at_terminal_gates_then_runs_then_relaunches( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """The terminal runner: the CLI's OWN typed gate inside the service's confirm - seam, the service's lines collected as progress, and -- only on a verified - success -- the relaunch closure (execv is the caller's; here it is faked).""" - from keel.commands import update as up - - plan = _fake_plan(tmp_path) - gates: list[bool] = [] - gate_answers: list[bool] = [] - relaunched: list[bool] = [] - - def _gate(a_plan: up.UpdatePlan) -> bool: - gates.append(a_plan is plan) - return True - - def _relaunch() -> None: - relaunched.append(True) - - def _fake_run(a_plan: Any, *, echo: Any, confirm_gate: Any) -> up.UpdateResult: - echo("the service streamed a line") - assert confirm_gate() is True # the gate rides the service's own confirm seam - return up.UpdateResult(ok=True, steps=("verified",), error=None, - rolled_back=False, backups=()) - - monkeypatch.setattr(up, "run_update", _fake_run) - progress: list[str] = [] - result = ac.run_update_at_terminal( - plan, progress=progress, gate_fn=_gate, relaunch_fn=_relaunch - ) - assert result.ok is True - assert gates == [True] - assert relaunched == [True] - assert progress # the service's own streamed lines were collected - assert any("updated to 0.7.0" in line for line in progress) - - # a refused gate writes nothing and never relaunches: the gate rides the service's - # confirm seam (fail-safe by construction), so the SERVICE is entered but its - # mutation seams never fire -- a spy mirroring that contract proves the flow - gates.clear() - relaunched.clear() - progress.clear() - - def _refused_run(a_plan: Any, *, echo: Any, confirm_gate: Any) -> up.UpdateResult: - gate_answers.append(confirm_gate()) # the seam the service calls first - return up.UpdateResult( - ok=False, - steps=(), - error="confirmation not given -- nothing was changed.", - rolled_back=False, - backups=(), - ) - - message = ac.run_update_at_terminal( - plan, - progress=progress, - gate_fn=lambda _plan: False, - relaunch_fn=_relaunch, - run_fn=_refused_run, - ) - assert message.ok is False - assert gate_answers == [False] - assert "not given" in (message.error or "").lower() - assert relaunched == [] - - -def test_run_update_at_terminal_a_failed_relaunch_is_rendered_not_lost( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """An execv that RAISES (the relaunch closure wraps the OSError into an - `UpdateError` naming the manual `keel tui`) must not escape the runner: the update - itself SUCCEEDED and is verified, so the held result stays ok, the failure and the - manual start render into the held progress, and the `on_relaunch_failure` seam - fires so the live loop can hold the retry.""" - from keel.commands import update as up - - plan = _fake_plan(tmp_path) - result = up.UpdateResult(ok=True, steps=("verified",), error=None, - rolled_back=False, backups=()) - - def _fake_run(a_plan: Any, *, echo: Any, confirm_gate: Any) -> up.UpdateResult: - echo("the service streamed a line") - return result - - monkeypatch.setattr(up, "run_update", _fake_run) - - def _raising_relaunch() -> None: - raise up.UpdateError( - "relaunch failed ([Errno 13] permission denied): execv could not start the " - "new build -- start the console by hand: `/x/.venv/bin/keel tui`" - ) - - failures: list[BaseException] = [] - progress: list[str] = [] - held = ac.run_update_at_terminal( - plan, - progress=progress, - gate_fn=lambda _plan: True, - relaunch_fn=_raising_relaunch, - on_relaunch_failure=failures.append, - ) - assert held.ok is True # the update completed and verified; only the execv failed - joined = "\n".join(progress) - assert "RELAUNCH FAILED" in joined - assert "keel tui" in joined # the manual start, named - assert len(failures) == 1 - - -def test_build_update_result_lines_footer_distinguishes_a_pending_relaunch( - tmp_path: Any, -) -> None: - """The result footer says what Enter DOES: re-run the check normally, but retry - ONLY the relaunch when one is pending -- the state that distinguishes them.""" - from keel.commands import update as up - - result = up.UpdateResult(ok=True, steps=("verified",), error=None, - rolled_back=False, backups=()) - normal = "\n".join( - line.text for line in ac.build_update_result_lines(result, progress=[]) - ) - assert "Enter re-runs" in normal - pending = "\n".join( - line.text - for line in ac.build_update_result_lines(result, progress=[], relaunch_pending=True) - ) - assert "Enter retries the relaunch" in pending - assert "re-installs nothing" in pending - - -def _drive_update( - keys: list[int], - monkeypatch: pytest.MonkeyPatch, - tmp_path: Any, - *, - plan: Any, - run_result: Any = None, - relaunch_calls: list[Any] | None = None, -) -> Any: - """`_drive` with the update seams faked: the entry-time check returns `plan`, the - service returns `run_result` (a refused gate by default), and `relaunch_tui` is - recorded into `relaunch_calls` instead of execv'ing.""" - from keel.commands import update as up - - monkeypatch.setattr(ac, "update_check", lambda **_kwargs: plan) - if run_result is None: - run_result = up.UpdateResult( - ok=False, - steps=(), - error="confirmation not given -- nothing was changed.", - rolled_back=False, - backups=(), - ) - monkeypatch.setattr( - up, - "run_update", - lambda plan, *, echo, confirm_gate: echo(f"gate: {confirm_gate()}") or run_result, - ) - if relaunch_calls is None: - relaunch_calls = [] - - def _fake_relaunch(venv_python: Any, original_argv: Any) -> Any: - def _closure() -> None: - relaunch_calls.append((venv_python, list(original_argv))) - - return _closure - - monkeypatch.setattr(up, "relaunch_tui", _fake_relaunch) - return _drive(_repo(), _config(), keys, monkeypatch, tmp_path) - - -def _repo() -> Any: - conn = connect(":memory:") - migrate(conn) - return Repository(conn) - - -def test_run_live_update_entry_opens_the_armed_view_and_never_runs_the_service( - monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - """Opening the update view checks the release ONCE (a read-only public-API GET, - the versions view's hold contract) and renders ARMED -- repaints re-check nothing, - and the mutating service is never invoked without Enter + the typed gate.""" - plan = _fake_plan(tmp_path) - checks: list[int] = [] - - def _counting_check(**_kwargs: Any) -> Any: - checks.append(1) - return plan - - monkeypatch.setattr(ac, "update_check", _counting_check) - from keel.commands import update as up - - def _must_not_run(*_args: Any, **_kwargs: Any) -> Any: - raise AssertionError("the update service must not run on open or poll") - - monkeypatch.setattr(up, "run_update", _must_not_run) - # m -> menu; '8' -> account; '3' -> update; two repaints; m back; q - keys = [ord("m"), ord("8"), -1, ord("3"), -1, -1, ord("m"), -1] - stdscr = _drive(_repo(), _config(), keys, monkeypatch, tmp_path) - painted = [call[2] for call in stdscr.calls] - assert any("account / update" in t for t in painted) - assert any("ARMED" in t for t in painted) - assert len(checks) == 1 # ONE check served the entry and every repaint - - -def test_run_live_update_enter_runs_the_gate_at_the_terminal_and_a_refusal_writes_nothing( - monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - """Enter dispatches through the suspend/restore seam (curses suspended around the - typed gate and the streamed run); with no TTY the gate fails closed, the service - is handed a refusing confirm_gate, NOTHING moves, and the honest result is held.""" - plan = _fake_plan(tmp_path) - from keel.commands import update as up - - gate_answers: list[bool] = [] - monkeypatch.setattr("keel.commands._common._is_interactive", lambda: False) - - def _spy_run(plan_arg: Any, *, echo: Any, confirm_gate: Any) -> Any: - gate_answers.append(confirm_gate()) - return up.UpdateResult( - ok=False, - steps=("gate asked and refused",), - error="confirmation not given -- nothing was changed.", - rolled_back=False, - backups=(), - ) - - monkeypatch.setattr(up, "run_update", _spy_run) - monkeypatch.setattr(ac, "update_check", lambda **_kwargs: plan) - relaunched: list[Any] = [] - - def _no_relaunch(venv_python: Any, original_argv: Any) -> Any: - def _closure() -> None: - relaunched.append((venv_python, original_argv)) - - return _closure - - monkeypatch.setattr(up, "relaunch_tui", _no_relaunch) - # m -> menu; '8' -> account; '3' -> update; poll; Enter -> the terminal run; q - keys = [ord("m"), ord("8"), -1, ord("3"), -1, ord("\r"), -1] - stdscr = _drive(_repo(), _config(), keys, monkeypatch, tmp_path) - painted = [call[2] for call in stdscr.calls] - # the gate was asked THROUGH the service's confirm seam and failed closed - assert gate_answers == [False] - assert any("nothing was changed" in t for t in painted) - assert relaunched == [] # a refused gate never relaunches - - -def test_run_live_update_success_relaunches_the_console_on_the_new_build( - monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - """A verified success relaunches: the execv closure is built from the plan's venv - and the ORIGINAL TUI argv, and is called once -- the console replaces itself - rather than leaving the operator on a replaced binary.""" - plan = _fake_plan(tmp_path) - from keel.commands import update as up - - monkeypatch.setattr("keel.commands._common._is_interactive", lambda: True) - monkeypatch.setattr( - "keel.commands._common._require_interactive_confirmation", - lambda action, detail: None, - ) - result = up.UpdateResult(ok=True, steps=("verified",), error=None, - rolled_back=False, backups=()) - relaunched: list[Any] = [] - - def _recording_relaunch(venv_python: Any, original_argv: Any) -> Any: - def _closure() -> None: - relaunched.append((venv_python, list(original_argv))) - - return _closure - - monkeypatch.setattr(up, "relaunch_tui", _recording_relaunch) - stdscr = _drive_update( - [ord("m"), ord("8"), -1, ord("3"), -1, ord("\r"), -1], - monkeypatch, - tmp_path, - plan=plan, - run_result=result, - relaunch_calls=relaunched, - ) - painted = [call[2] for call in stdscr.calls] - assert relaunched and relaunched[0][0] == tmp_path / ".venv/bin/python" - assert relaunched[0][1] and relaunched[0][1][0] # the original argv is carried - assert any("updated" in t.lower() or "0.7.0" in t for t in painted) - - -def test_run_live_a_failed_relaunch_is_held_and_enter_retries_only_the_relaunch( - monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - """A verified update whose execv RAISES: the result is HELD (never a silent re-ARM - of the whole update), the manual `keel tui` start is on screen, and a second Enter - retries ONLY the relaunch -- the service is not re-entered, nothing re-installs.""" - plan = _fake_plan(tmp_path) - from keel.commands import update as up - - result = up.UpdateResult(ok=True, steps=("verified",), error=None, - rolled_back=False, backups=()) - service_runs: list[int] = [] - - def _fake_run(a_plan: Any, *, echo: Any, confirm_gate: Any) -> up.UpdateResult: - service_runs.append(1) - echo("the service streamed a line") - return result - - monkeypatch.setattr(up, "run_update", _fake_run) - monkeypatch.setattr(ac, "update_check", lambda **_kwargs: plan) - relaunch_attempts: list[int] = [] - - def _failing_relaunch(venv_python: Any, original_argv: Any) -> Any: - def _closure() -> None: - relaunch_attempts.append(1) - raise up.UpdateError( - "relaunch failed ([Errno 13] permission denied): execv could not start " - "the new build -- start the console by hand: `keel tui`" - ) - - return _closure - - monkeypatch.setattr(up, "relaunch_tui", _failing_relaunch) - # m -> menu; '8' -> account; '3' -> update; poll; Enter -> the run (relaunch - # FAILS, result held); poll; Enter -> the relaunch RETRY (fails again, held) - keys = [ord("m"), ord("8"), -1, ord("3"), -1, ord("\r"), -1, ord("\r"), -1] - stdscr = _drive(_repo(), _config(), keys, monkeypatch, tmp_path) - painted = [call[2] for call in stdscr.calls] - assert len(service_runs) == 1 # the update ran ONCE -- Enter did not re-run it - assert len(relaunch_attempts) == 2 # failed once, retried once by the second Enter - assert any("RELAUNCH FAILED" in t for t in painted) - assert any("keel tui" in t for t in painted) - assert any("re-installs nothing" in t for t in painted) diff --git a/tests/commands/test_brokers.py b/tests/commands/test_brokers.py index f4d23faa..e5feef81 100644 --- a/tests/commands/test_brokers.py +++ b/tests/commands/test_brokers.py @@ -13,7 +13,6 @@ import json import re from dataclasses import asdict -from pathlib import Path from types import SimpleNamespace from typing import Any @@ -21,8 +20,6 @@ from keel.cli import cli from keel.commands import brokers -from keel.commands import console as console_mod -from keel.config import load_config #: Every field the payload may carry -- the closed capability vocabulary. A field outside #: this set (an env read, a key-presence probe, a config content) is a scope violation, @@ -246,52 +243,9 @@ def test_a_poisoned_registry_entry_renders_an_error_row_and_keeps_the_rest( assert "healthy" in text and "ok-venue" in text -def test_a_poisoned_registry_entry_does_not_crash_the_cli_nor_the_venues_browser( - monkeypatch: Any, -) -> None: - """Both front-ends render the SAME resilient payload: the CLI exits 0 with the error - row and the healthy row, and the console's Venues browser (the TUI's rendering of - the service) renders both within the 80-column clip -- a broken adapter is a row on - a screen, never a dead console.""" - _poison_the_registry(monkeypatch) - result = CliRunner().invoke(cli, ["brokers", "list"]) - assert result.exit_code == 0, result.output - assert "broken" in result.output and "adapter metadata unreadable" in result.output - assert "healthy" in result.output - - infos = brokers.list_installed_brokers() - lines = console_mod.build_venues_lines( - infos, - selected_venue="healthy", - profile=None, - binding_pair=None, - ) - texts = [line.text for line in lines] - joined = "\n".join(texts) - assert "broken" in joined and "adapter metadata unreadable" in joined - assert "healthy" in joined and "ok-venue" in joined - assert all(len(text) <= 80 for text in texts) - - # -- the wired/optional classification is pinned to the tracked configs ---------------------------- -def test_the_wired_set_is_what_the_tracked_configs_actually_select() -> None: - """[review #406] Drift guard: `WIRED_FOR_DEPLOYMENT` is a hand-maintained constant, - so it is derived HERE from the tracked config files' own `broker.name` selections -- - loaded the way the profile convention loads them (`load_config`, whose absent - `broker:` section means coinbase) and unioned. A newly wired adapter with a tracked - config fails this test until the constant (and its reasoning comment) is updated.""" - root = Path(__file__).resolve().parents[2] - configs = sorted(root.glob("config*.yaml")) - found = {path.name for path in configs} - assert found >= { - profile.config_path for profile in console_mod.KNOWN_PROFILES - } | {"config.yaml"}, found - selected = {load_config(path).broker.name for path in configs} - assert brokers.WIRED_FOR_DEPLOYMENT == frozenset(selected) - - # -- the CLI (O7): `keel brokers list` -------------------------------------------------------- @@ -324,103 +278,12 @@ def test_brokers_list_help_carries_the_readme_trademark_line() -> None: ) -# -- the TUI Venues browser (O7): under Profile, over the same service --------------------------- - - -def _venues_lines(selected: str = "alpaca") -> list[str]: - infos = brokers.list_installed_brokers() - lines = console_mod.build_venues_lines( - infos, - selected_venue=selected, - profile=console_mod.active_profile( - "config.paper-equities.yaml", "keel-equities.db" - ), - binding_pair=("config.paper-equities.yaml", "keel-equities.db"), - endpoint="paper", - data_feed="iex", - ) - return [line.text for line in lines] - - -def test_the_venues_browser_renders_the_service_rows_with_one_selected_mark() -> None: - texts = _venues_lines() - joined = "\n".join(texts) - for info in brokers.list_installed_brokers(): - assert info.name in joined, info.name - selected_rows = [t for t in texts if "[selected]" in t] - assert len(selected_rows) == 1 and "alpaca" in selected_rows[0] - - -def test_the_venues_browser_shows_the_active_deployments_binding() -> None: - joined = "\n".join(_venues_lines()) - assert "config.paper-equities.yaml + keel-equities.db" in joined - assert "endpoint paper" in joined - assert "data feed iex" in joined - - -def test_the_venues_browser_and_the_cli_render_one_service_payload() -> None: - """THE O7 acceptance pin: both front-ends over one payload. The browser's rows are - built from the service's return, and the CLI's `--json` is the same return asdict-ed - -- asserted in one place so a front-end that starts sourcing its own data fails here.""" - infos = brokers.list_installed_brokers() - lines = console_mod.build_venues_lines( - infos, - selected_venue="coinbase", - profile=console_mod.active_profile("config.paperforward.yaml", "keel.db"), - binding_pair=("config.paperforward.yaml", "keel.db"), - endpoint=None, - data_feed=None, - ) - joined = "\n".join(line.text for line in lines) - for info in infos: - assert info.name in joined - # the capability FACTS the CLI json carries appear in the browser's rows - assert info.deployment in joined - for quote in info.quote_currencies: - assert quote in joined - if info.preview != "none": - assert info.preview in joined - result = CliRunner().invoke(cli, ["brokers", "list", "--json"]) - assert json.loads(result.output) == _service_payload() - +# The Venues-browser section stood here: `_venues_lines` and five tests over +# `console.build_venues_lines`, the TUI's rendering of the same service `keel brokers list` +# renders. #541 deleted the console layer, so the renderer is gone and its tests with it. +# +# The SERVICE is untouched and still covered above -- `list_installed_brokers` is what both +# front-ends always called, and `keel brokers list` is the surface that remains. What is no +# longer asserted is a second rendering of it, because there is no second rendering. -def test_the_venues_browser_renders_no_secret_material() -> None: - """The browser's ROWS carry no secret vocabulary -- the one line that may say the - word "secret" is the posture note itself (`NO_KEY_INFERENCE_LINE`), which is the - honesty statement, not material.""" - import textwrap - posture = set(textwrap.wrap(brokers.NO_KEY_INFERENCE_LINE, width=78)) - posture |= set( - textwrap.wrap( - brokers.NO_KEY_INFERENCE_LINE, width=78, initial_indent="", subsequent_indent="" - ) - ) - texts = [t for t in _venues_lines() if t not in posture] - joined = "\n".join(texts).lower() - for needle in ("api_key", "apikey", "secret", "password", "credential", "passphrase"): - assert needle not in joined, needle - - -def test_the_venues_browser_fits_the_80_column_clip() -> None: - for text in _venues_lines(selected="coinbase") + _venues_lines(selected="alpaca"): - assert len(text) <= 80, text - - -def test_the_profile_menu_lists_the_venues_entry_under_the_deployments() -> None: - profiles = [p for p in console_mod.KNOWN_PROFILES if p.key != "live"] - lines = console_mod.build_profile_menu_lines(profiles, cursor=0, binding_pair=None) - texts = [line.text for line in lines] - assert any("Venues" in t for t in texts), texts - # the Venues entry rides BELOW the deployments (the PRD tree: Profile -> Venues) - venues_at = next(i for i, t in enumerate(texts) if "Venues" in t) - last_profile_at = max(i for i, t in enumerate(texts) if "config.paperforward.yaml" in t) - assert venues_at > last_profile_at - - -def test_the_venues_browser_states_its_no_inference_posture() -> None: - """Capability display, not key-presence inference: the screen SAYS so, so an operator - does not read 'wired' as 'my keys are set up'.""" - joined = "\n".join(_venues_lines()) - assert "capabilit" in joined.lower() - assert "no key" in joined.lower() diff --git a/tests/commands/test_compliance_console.py b/tests/commands/test_compliance_console.py deleted file mode 100644 index 3cb4e24a..00000000 --- a/tests/commands/test_compliance_console.py +++ /dev/null @@ -1,1035 +0,0 @@ -"""Tests for `keel.commands.compliance_console` -- the Compliance menu, the scout-results -handler and the "Shariah in force" browser (issue #389 C3; PRD O6/O10 and §3's tree). - -Four surfaces, all pinned here: - -* **The Compliance sub-menu** -- PRD §3's tree under Compliance, every entry a navigation - target whose behavior is a DISPATCH (a view over a C1 service report, or a form that - collects fields and calls the same repository/service function the CLI command calls), - never a behavior of the TUI's own. -* **The forms** -- the record-writes (`assets attest` [typed], `attest-instrument`, - `exempt`/`unexempt`, `subscription attest`/`set`, `withdrawals attest` [typed]): each - collects its fields through an injected prompt function and dispatches to the exact - service/repository call the CLI makes, with the deliberately-typed actions refusing to - proceed without their typed phrase (O3 -- never pre-filled, never piped). -* **The scout-results handler (O6)** -- the operator-local proposals directory listed, - a shortlist selected and rendered through the EXISTING admission services, and the - attest step offered but never auto-run. -* **The "Shariah in force" browser (O10)** -- the attestations/exemptions in force over - the ACTIVE allowlist rendered from repository data through a service read, the - fiqh-derived constraints each carrying a verbatim quote from and citation into - `docs/fiqh-basis.md`, and the standing honesty lines always visible. - -Mirrors `tests/commands/test_console.py`'s fixture style (in-memory `Repository`, -`_config`, `NOW_TS`). -""" - -from __future__ import annotations - -import os -import time -from decimal import Decimal -from pathlib import Path -from typing import Any - -import pytest - -from keel.commands import compliance_console as cc -from keel.commands.admission import list_shortlists -from keel.commands.assets import gather_attestations_in_force -from keel.config import ( - AutoTradeConfig, - Caps, - Config, - DcaConfig, - MarketDataConfig, - MoneyMgmtConfig, -) -from keel.data.db import connect, migrate -from keel.data.repository import Repository - -NOW_TS = 1_800_000_000 - -_ROOT = Path(__file__).resolve().parents[2] -_FIQH_BASIS = (_ROOT / "docs" / "fiqh-basis.md").read_text() - -#: The two standing honesty states, pinned against the document that states them (the -#: two-sided pattern from `tests/test_fiqh_basis.py`/`test_scholarly_review.py`): the -#: browser may not soften either line while fiqh-basis keeps it blunt, and fiqh-basis -#: cannot lose either while the browser keeps rendering it. -NOT_A_FATWA_ENGINE = "keel is not a fatwa engine" -NO_SCHOLARLY_REVIEW = "No scholarly review of keel's fiqh basis has occurred" - - -@pytest.fixture -def repo() -> Repository: - conn = connect(":memory:") - migrate(conn) - r = Repository(conn) - r.set_state("kill_switch", False) - return r - - -def _config(**overrides: Any) -> Config: - base: dict[str, Any] = dict( - allowlist=["BTC", "ETH"], - target_weights={}, - risk_pct=Decimal("0.01"), - caps=Caps( - max_per_order_usd=Decimal("100000"), - max_per_day_usd=Decimal("300000"), - max_exposure_usd=Decimal("1000000"), - max_per_asset_pct=Decimal("1"), - ), - market_data=MarketDataConfig(granularities=[], history_days=365), - auto_trade=AutoTradeConfig(mode="paper", interval_sec=900), - money_mgmt=MoneyMgmtConfig( - max_total_dd_pct=Decimal("0.20"), max_weekly_dd_pct=Decimal("0.08") - ), - dca=DcaConfig(budget_usd=Decimal("50"), cadence_days=7), - ) - base.update(overrides) - return Config(**base) - - -class _RecordingRepo: - """A real `Repository` wrapped so every WRITE the console form makes is recorded with - its exact keyword arguments -- the spy the "calls the same function the CLI calls" - assertions read. Reads fall through to the real repo.""" - - def __init__(self, inner: Repository) -> None: - self._inner = inner - self.calls: list[tuple[str, dict[str, Any]]] = [] - - def __getattr__(self, name: str) -> Any: - return getattr(self._inner, name) - - def upsert_asset_attestation(self, **kwargs: Any) -> None: - self.calls.append(("upsert_asset_attestation", kwargs)) - - def upsert_instrument_attestation(self, **kwargs: Any) -> None: - self.calls.append(("upsert_instrument_attestation", kwargs)) - - def upsert_screen_exception(self, **kwargs: Any) -> None: - self.calls.append(("upsert_screen_exception", kwargs)) - - def delete_screen_exception(self, asset: str, criterion: str) -> int: - self.calls.append(("delete_screen_exception", {"asset": asset, "criterion": criterion})) - return self._inner.delete_screen_exception(asset, criterion) - - def set_state(self, key: str, value: Any) -> None: - self.calls.append(("set_state", {"key": key, "value": value})) - self._inner.set_state(key, value) - - -def _prompt(answers: list[str]) -> Any: - """A scripted prompt function: records every question asked, answers in order.""" - queue = iter(answers) - asked: list[str] = [] - - def fn(text: str) -> str: - asked.append(text) - return next(queue) - - fn.asked = asked - return fn - - -def _attest_answers( - *, asset: str = "BTC", - backing: str = "ayn", - pays_yield: str = "n", - typed: str = "BTC", - seeded: bool = False, -) -> list[str]: - # `seeded` mirrors the scout flow: the chosen candidate's asset arrives pre-seeded, so - # the form asks one fewer question (the judgment fields are ALWAYS asked). - head = [] if seeded else [asset] - return [ - *head, "payments", backing, pays_yield, "https://example.com/btc", "operator", - typed, - ] - - -# -- the Compliance sub-menu (PRD §3) ----------------------------------------------------------- - - -def test_the_compliance_menu_is_the_prd_tree() -> None: - """PRD §3's Compliance branch, in tree order: screen, propose, attest [typed], - attest-instrument, exempt/unexempt, holdings, discover, [Scout results...], - [Shariah in force...], subscription (show/attest), withdrawals attest [typed], - purification.""" - assert [entry.label for entry in cc.COMPLIANCE_MENU] == [ - "screen", - "propose", - "attest", - "attest-instrument", - "exempt", - "unexempt", - "holdings", - "discover", - "Scout results", - "Shariah in force", - "subscription show", - "subscription attest", - "subscription set", - "withdrawals attest", - "purification", - ] - - -def test_every_compliance_entry_is_a_dispatch_not_a_behavior() -> None: - """The closed vocabulary: every entry is a VIEW over a service report, a FORM that - collects fields and calls a CLI-called service/repository function, or the scout - browser -- the TUI renders and dispatches, nothing more (O2).""" - kinds = {entry.kind for entry in cc.COMPLIANCE_MENU} - assert kinds <= {"view", "form", "scout"} - assert any(entry.kind == "scout" for entry in cc.COMPLIANCE_MENU) - views = {e.target for e in cc.COMPLIANCE_MENU if e.kind == "view"} - assert views == { - "screen", "propose", "holdings", "discover", "shariah", "subscription", "purification", - } - forms = {e.target for e in cc.COMPLIANCE_MENU if e.kind == "form"} - assert forms == { - "attest", "attest-instrument", "exempt", "unexempt", - "subscription-attest", "subscription-set", "withdrawals-attest", - } - - -def test_the_prd_marks_attest_and_withdrawals_attest_as_typed_and_the_menu_says_so() -> None: - """O3 made visible in the tree itself: the two entries the PRD marks "(typed)" carry - the marker, and the rendered menu shows it inline so the ceremony is never a - surprise.""" - typed = {e.label for e in cc.COMPLIANCE_MENU if e.typed} - assert typed == {"attest", "withdrawals attest"} - lines = cc.build_compliance_menu_lines() - joined = "\n".join(line.text for line in lines) - assert "typed" in joined - - -def test_the_compliance_menu_screen_renders_every_entry_with_one_cursor() -> None: - lines = cc.build_compliance_menu_lines(cursor=3) - texts = [line.text for line in lines] - for entry in cc.COMPLIANCE_MENU: - assert any(entry.label in t for t in texts), entry.label - marked = [t for t in texts if t.lstrip().startswith(">")] - assert len(marked) == 1 and "attest-instrument" in marked[0] - assert any("q/Esc/m" in t and "menu" in t for t in texts) - - -def test_the_compliance_menu_renders_a_result_toast() -> None: - """Every write shows a confirmation line -- on the menu itself, where the form ran.""" - lines = cc.build_compliance_menu_lines(message="attested BTC: sector=payments backing=ayn") - assert any("attested BTC" in line.text for line in lines) - - -def test_the_menu_toast_style_follows_the_message_semantics() -> None: - """The toast is not always "ok": a line that reports a FAILURE renders in the alert - style and a cancelled/unchanged one in the warn style -- `_message_style`'s own - semantics, the same rule every other console toast keeps (a green "failed" toast is - the colour saying all is well about something that is not).""" - failed = cc.build_compliance_menu_lines(message="profile switch failed: boom") - assert any( - line.style == "alert" and "failed" in line.text for line in failed - ) - cancelled = cc.build_compliance_menu_lines(message="attest cancelled -- nothing recorded") - assert any( - line.style == "warn" and "cancelled" in line.text for line in cancelled - ) - - -def test_the_compliance_menu_lines_fit_the_80_column_clip() -> None: - """Same budget as every other console screen: `_paint` clips at the window width and - this dashboard targets 80 columns -- an entry's description must wrap to its own row - rather than lose its tail there (the profile menu's guarded-note style).""" - lines = cc.build_compliance_menu_lines(cursor=7, message="attested BTC") - assert len(lines) > 10 # the whole tree, not a truncated proxy - for line in lines: - assert len(line.text) <= 80, line.text - - -# -- the forms: dispatch to the same calls the CLI makes --------------------------------------- - - -def test_attest_form_collects_fields_and_records_exactly_what_the_cli_records( - repo: Repository, -) -> None: - """`keel assets attest` is thin over `Repository.upsert_asset_attestation` -- the form - calls the SAME function with the SAME argument names, and echoes the SAME line.""" - spy = _RecordingRepo(repo) - result = cc.run_attest_form(spy, _prompt(_attest_answers()), NOW_TS) - - assert spy.calls == [ - ( - "upsert_asset_attestation", - { - "asset": "BTC", - "sector": "payments", - "backing": "ayn", - "pays_yield": False, - "source": "https://example.com/btc", - "attested_by": "operator", - "attested_at": NOW_TS, - }, - ) - ] - assert result == "attested BTC: sector=payments backing=ayn pays_yield=False" - - -def test_attest_form_refuses_to_proceed_without_the_typed_asset_code(repo: Repository) -> None: - """The PRD marks attest "(typed)": the form's final gate demands the operator type the - ASSET CODE back -- never pre-filled, never bypassable -- and a wrong phrase leaves the - repository untouched.""" - spy = _RecordingRepo(repo) - answers = _attest_answers(typed="wrong phrase") - - result = cc.run_attest_form(spy, _prompt(answers), NOW_TS) - - assert spy.calls == [] # nothing recorded - assert "cancelled" in result.lower() - assert "BTC" in result # the refusal names what was NOT attested - - -def test_attest_form_is_cancelled_by_an_empty_asset(repo: Repository) -> None: - spy = _RecordingRepo(repo) - result = cc.run_attest_form(spy, _prompt(["", "payments"]), NOW_TS) - assert spy.calls == [] - assert "cancelled" in result.lower() - - -def test_attest_form_rejects_an_unknown_backing_without_writing(repo: Repository) -> None: - """`--backing` is a Choice on the CLI; the form enforces the same vocabulary - (`KNOWN_BACKINGS`) and writes nothing on a bad one.""" - spy = _RecordingRepo(repo) - result = cc.run_attest_form(spy, _prompt(_attest_answers(backing="diamond")), NOW_TS) - assert spy.calls == [] - assert "backing" in result - - -def test_the_attest_typed_gate_never_leaks_the_phrase_into_the_question( - repo: Repository, -) -> None: - """The gate must ASK without pre-filling: the question names the asset, and the answer - is whatever the human types -- so the recorded question carries the asset code but the - function cannot supply the answer. The answer must BE the code, EXACTLY: the CLI's own - typed gate (`_require_interactive_confirmation`) accepts exactly `yes` -- not `YES`, - not ` yes ` -- and the console's asset gate is exactly as strict. Only the prompt's - trailing newline is discarded.""" - prompt = _prompt(["BTC"]) - assert cc.typed_asset_confirmation("BTC", prompt) is True - assert any("BTC" in q for q in prompt.asked) - # case-sensitive, whitespace-intolerant: none of these are the code - for wrong in ("btc", "btc ", " BTC", "BTC ", "BT"): - assert cc.typed_asset_confirmation("BTC", _prompt([wrong])) is False, wrong - - -def test_instrument_attest_form_uppercases_the_product_like_the_cli( - repo: Repository, -) -> None: - """`keel assets attest-instrument` uppercases the product id before the upsert (the - lookup key is uppercase); the form does the same, through the same repository call.""" - spy = _RecordingRepo(repo) - answers = ["coinbase", "btc-usd", "spot", "https://example.com/spec", "operator", "btc-usd"] - result = cc.run_instrument_attest_form(spy, _prompt(answers), NOW_TS) - - assert spy.calls == [ - ( - "upsert_instrument_attestation", - { - "venue": "coinbase", - "product_id": "BTC-USD", - "wrapper": "spot", - "source": "https://example.com/spec", - "attested_by": "operator", - "attested_at": NOW_TS, - }, - ) - ] - assert result == "attested BTC-USD on coinbase: wrapper=spot" - - -def test_instrument_attest_validation_raises_form_input_error_and_the_form_renders_it( - repo: Repository, -) -> None: - """One error convention for every form: field validation raises `FormInputError` from - the COLLECT step (`collect_instrument_attest`, the same shape `collect_attest` keeps) - and the `run_*` wrapper renders the `Error: ...` line -- never a second, inline - error-string convention that can drift from the first.""" - bad_wrapper = ["coinbase", "btc-usd", "swap", "", "operator"] - with pytest.raises(cc.FormInputError, match="wrapper must be one of"): - cc.collect_instrument_attest(_prompt(bad_wrapper)) - - spy = _RecordingRepo(repo) - result = cc.run_instrument_attest_form(spy, _prompt(bad_wrapper), NOW_TS) - assert spy.calls == [] # refused before any write - assert result.startswith("Error:") and "wrapper" in result - - blank_source = ["coinbase", "btc-usd", "spot", "", "operator"] - with pytest.raises(cc.FormInputError, match="source is required"): - cc.collect_instrument_attest(_prompt(blank_source)) - - blank_attestor = ["coinbase", "btc-usd", "spot", "https://example.com/spec", ""] - with pytest.raises(cc.FormInputError, match="attested-by is required"): - cc.collect_instrument_attest(_prompt(blank_attestor)) - - -def test_exempt_form_records_a_documented_exception_and_refuses_a_blank_rationale( - repo: Repository, -) -> None: - """`keel assets exempt` uppercases the asset and refuses an empty rationale (an - "undocumented documented exception"); the form enforces both, criterion restricted to - WAIVABLE_CRITERIA's vocabulary.""" - spy = _RecordingRepo(repo) - result = cc.run_exempt_form( - spy, _prompt(["paxg", "history", "PAXG has 3y of history at the venue", "operator"]), NOW_TS - ) - assert spy.calls == [ - ( - "upsert_screen_exception", - { - "asset": "PAXG", - "criterion": "history", - "rationale": "PAXG has 3y of history at the venue", - "granted_by": "operator", - "granted_at": NOW_TS, - }, - ) - ] - assert "recorded exception" in result - - spy2 = _RecordingRepo(repo) - refused = cc.run_exempt_form(spy2, _prompt(["PAXG", "history", " ", "operator"]), NOW_TS) - assert spy2.calls == [] - assert "rationale" in refused - - -def test_unexempt_form_reports_a_revoke_and_an_absence(repo: Repository) -> None: - repo.upsert_screen_exception( - asset="PAXG", criterion="history", rationale="r", granted_by="o", granted_at=NOW_TS - ) - spy = _RecordingRepo(repo) - revoked = cc.run_unexempt_form(spy, _prompt(["paxg", "history"])) - assert ("delete_screen_exception", {"asset": "PAXG", "criterion": "history"}) in spy.calls - assert "revoked" in revoked - - absent = cc.run_unexempt_form(spy, _prompt(["PAXG", "history"])) - assert "no such exception" in absent - - -def test_subscription_attest_form_dispatches_to_the_subscription_service( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """The form holds NO tier-resolution logic of its own: it collects the fields and - hands them to the service function the CLI command calls (C1's one-implementation - rule), which resolves the tier, the venue and the pacing.""" - recorded: dict[str, Any] = {} - - def fake_apply( - r: Any, config: Any, *, venue: str | None, tier_name: str, pacing: str | None, now_ts: int - ) -> str: - recorded.update( - repo=r, venue=venue, tier_name=tier_name, pacing=pacing, now_ts=now_ts - ) - return "attested coinbase: tier=starter free_volume_usd=500 status=active due in 365 days" - - monkeypatch.setattr(cc, "apply_subscription_attest", fake_apply) - config = _config() - - result = cc.run_subscription_attest_form( - repo, config, _prompt(["", "starter", ""]), NOW_TS - ) - - assert recorded == { - "repo": repo, "venue": None, "tier_name": "starter", "pacing": None, "now_ts": NOW_TS, - } - assert result.startswith("attested coinbase") - - -def test_subscription_attest_form_renders_a_service_error_calmly( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - def fake_apply(*args: Any, **kwargs: Any) -> str: - raise ValueError("unknown tier 'nope'. Configured tiers: starter") - - monkeypatch.setattr(cc, "apply_subscription_attest", fake_apply) - result = cc.run_subscription_attest_form(repo, _config(), _prompt(["", "nope", ""]), NOW_TS) - assert result.startswith("Error:") - assert "unknown tier" in result - - -def test_subscription_set_form_dispatches_to_the_subscription_service( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - recorded: dict[str, Any] = {} - - def fake_apply( - r: Any, config: Any, *, venue: str | None, free_volume_raw: str, pacing: str | None, - now_ts: int, - ) -> str: - recorded.update(venue=venue, free_volume_raw=free_volume_raw, pacing=pacing, now_ts=now_ts) - return "set coinbase: free_volume_usd=500 tier=unknown" - - monkeypatch.setattr(cc, "apply_subscription_set", fake_apply) - result = cc.run_subscription_set_form(repo, _config(), _prompt(["", "500", ""]), NOW_TS) - - assert recorded == { - "venue": None, "free_volume_raw": "500", "pacing": None, "now_ts": NOW_TS, - } - assert result.startswith("set coinbase") - - -def test_withdrawals_enabled_requires_the_typed_gate_before_any_write( - repo: Repository, -) -> None: - """O3's sacred case: `withdrawals attest --enabled` RELEASES a rail-17 halt, so it - keeps its typed confirmation. The gate is the ONLY thing between the form and the - repository -- a declined gate means not a single state row is written.""" - spy = _RecordingRepo(repo) - declined: list[bool] = [] - - def decline() -> bool: - declined.append(True) - return False - - result = cc.run_withdrawals_form(spy, _prompt(["enabled"]), NOW_TS, confirm_enabled_fn=decline) - - assert declined == [True] - assert spy.calls == [] - assert "cancelled" in result.lower() or "unchanged" in result.lower() - - def approve() -> bool: - return True - - approved = cc.run_withdrawals_form( - _RecordingRepo(repo), _prompt(["enabled"]), NOW_TS, confirm_enabled_fn=approve - ) - assert "ENABLED" in approved - - -def test_withdrawals_enabled_writes_exactly_what_the_cli_writes(repo: Repository) -> None: - spy = _RecordingRepo(repo) - result = cc.run_withdrawals_form( - spy, _prompt(["enabled"]), NOW_TS, confirm_enabled_fn=lambda: True - ) - assert spy.calls == [ - ("set_state", {"key": "withdrawals_enabled", "value": True}), - ("set_state", {"key": "withdrawals_attested_at", "value": NOW_TS}), - ] - assert "withdrawals attested ENABLED" in result - assert "expires in 7 days" in result - - -def test_withdrawals_suspended_is_ungated_like_the_cli(repo: Repository) -> None: - """`--suspended` only ever REDUCES capability: no typed gate, immediate write -- the - CLI's own asymmetry, kept byte-for-byte.""" - spy = _RecordingRepo(repo) - asked: list[bool] = [] - - def gate() -> bool: - asked.append(True) - return True - - result = cc.run_withdrawals_form(spy, _prompt(["suspended"]), NOW_TS, confirm_enabled_fn=gate) - - assert asked == [] - assert spy.calls == [ - ("set_state", {"key": "withdrawals_enabled", "value": False}), - ("set_state", {"key": "withdrawals_attested_at", "value": NOW_TS}), - ] - assert "SUSPENDED" in result - assert "rail 17" in result - - -def test_withdrawals_form_rejects_an_unrecognized_answer_without_writing( - repo: Repository, -) -> None: - spy = _RecordingRepo(repo) - result = cc.run_withdrawals_form( - spy, _prompt(["maybe"]), NOW_TS, confirm_enabled_fn=lambda: True - ) - assert spy.calls == [] - assert "cancelled" in result.lower() - - -def test_the_withdrawals_typed_gate_is_the_clis_own( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The enabled-direction gate must be the CLI's `_require_interactive_confirmation` - with the CLI's own action wording -- not a TUI-invented second gate -- and it fails - CLOSED (a refusal, a Ctrl-C, any exception) so the halt is never released silently. - The wording is pinned against the SHARED constants (`withdrawals.py`, their one home) - and the CLI's own call site is pinned to reference the same constants, so the console - and the CLI can never drift into two wordings for the same gate.""" - import inspect - - import click as click_mod - - import keel.commands._common as common - from keel.commands import withdrawals - from keel.commands.withdrawals import ( - WITHDRAWALS_ATTEST_ACTION, - WITHDRAWALS_ATTEST_DETAIL, - ) - - asked: list[tuple[str, str]] = [] - - def refusing_gate(action: str, detail: str) -> None: - asked.append((action, detail)) - raise click_mod.ClickException("aborted (confirmation not given).") - - monkeypatch.setattr(common, "_require_interactive_confirmation", refusing_gate) - assert cc.clis_typed_withdrawals_gate() is False - - def accepting_gate(action: str, detail: str) -> None: - asked.append((action, detail)) - - monkeypatch.setattr(common, "_require_interactive_confirmation", accepting_gate) - assert cc.clis_typed_withdrawals_gate() is True - - # the console's prompt words ARE the CLI's: identity against the shared constants - assert asked[0] == (WITHDRAWALS_ATTEST_ACTION, WITHDRAWALS_ATTEST_DETAIL) - assert asked[0][0] == "attest withdrawals as ENABLED" - assert "rail 17" in asked[0][1].lower() - # and the CLI command itself runs the gate on those same constants -- one home, two - # front-ends, no second wording - cli_source = inspect.getsource(withdrawals.withdrawals_attest.callback) - assert "WITHDRAWALS_ATTEST_ACTION" in cli_source - assert "WITHDRAWALS_ATTEST_DETAIL" in cli_source - - -def test_run_form_dispatches_by_name_to_every_registered_form( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """The menu's dispatch seam: every form entry's target resolves to exactly one - runner, and the runner receives the repo/config/prompt/now the loop holds.""" - for entry in cc.COMPLIANCE_MENU: - if entry.kind != "form": - continue - assert entry.target in cc.FORM_RUNNERS, entry.target - - -# -- the scout-results handler (O6) ---------------------------------------------------------------- - - -def test_list_shortlists_is_newest_first_and_never_raises(tmp_path: Path) -> None: - """The service read behind the browser: every `*shortlist.json` in the proposals - directory, newest mtime first (name desc on ties -- `latest_shortlist`'s own - tiebreak, reversed for a list), never creating the directory and never raising on - absent/strangled paths.""" - old = tmp_path / "2026-08-01-shortlist.json" - old.write_text("{}") - two_days_ago = time.time() - 172_800 - os.utime(old, (two_days_ago, two_days_ago)) - (tmp_path / "2026-08-15-shortlist.json").write_text("{}") - (tmp_path / "2026-08-15-param-proposals.json").write_text("{}") # sibling, not a shortlist - - files = list_shortlists(tmp_path) - - assert [f.path.name for f in files] == [ - "2026-08-15-shortlist.json", - "2026-08-01-shortlist.json", - ] - assert list_shortlists(tmp_path / "nonexistent") == () - stray = tmp_path / "stray" - stray.write_text("not a dir") - assert list_shortlists(stray) == () - - -def test_scout_listing_reads_the_configured_proposals_dir(tmp_path: Path) -> None: - """The path comes from CONFIG (`proposals_dir`, default `~/keel/proposals` -- the key - already exists), never a TUI-side guess.""" - (tmp_path / "a-shortlist.json").write_text("{}") - config = _config(proposals_dir=str(tmp_path)) - - files, directory = cc.scout_listing(config) - - assert directory == tmp_path - assert [f.path.name for f in files] == ["a-shortlist.json"] - assert cc.scout_listing(_config(proposals_dir=str(tmp_path / "missing")))[0] == () - - -def test_scout_list_renders_every_file_and_a_clear_empty_state(tmp_path: Path) -> None: - old = tmp_path / "2026-08-01-shortlist.json" - old.write_text("{}") - two_days_ago = time.time() - 172_800 - os.utime(old, (two_days_ago, two_days_ago)) - (tmp_path / "2026-08-15-shortlist.json").write_text("{}") - files = list_shortlists(tmp_path) - - lines = cc.build_scout_list_lines(files, tmp_path, cursor=1) - texts = [line.text for line in lines] - assert all(f.path.name in "\n".join(texts) for f in files) - marked = [t for t in texts if t.lstrip().startswith(">")] - assert len(marked) == 1 and "2026-08-01-shortlist.json" in marked[0] - # the header names the directory and its config key (wrapped, so joined -- not one line) - joined_header = " ".join(t for t in texts if "newest first" in t or "shortlists in" in t) - assert "proposals" in joined_header.lower() and "shortlist" in joined_header.lower() - - empty = cc.build_scout_list_lines((), tmp_path / "missing", cursor=0) - joined = "\n".join(line.text for line in empty) - assert "no proposals" in joined.lower() - assert "missing" in joined # names where it looked - assert "shortlist" in joined # names the convention, so one rename fixes it - - -def test_the_scout_list_lines_fit_the_80_column_clip( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Same budget as every other console screen (`_paint` clips at the window width; - 80-column terminals are the target): the directory-bearing header, the empty state - and the footer hint all WRAP rather than losing their tail to the clip. A path's - tail is exactly the part that identifies it. (The directory is passed SHORT, the - way a deployment-relative proposals_dir renders: wrapping splits on spaces, and a - single path token longer than the budget is a `_paint` clip, not a wrap miss -- - this test pins the builder's own words, which is what it controls.)""" - monkeypatch.chdir(tmp_path) - populated = Path("proposals") - populated.mkdir() - (populated / "2026-08-15-shortlist.json").write_text("{}") - files = list_shortlists(populated) - - for lines in ( - cc.build_scout_list_lines(files, populated, cursor=0), - cc.build_scout_list_lines((), Path("missing"), cursor=0), - ): - assert len(lines) > 3 - for line in lines: - assert len(line.text) <= 80, line.text - - -def test_scout_view_screens_the_chosen_file_through_the_admission_services( - repo: Repository, tmp_path: Path -) -> None: - """Selecting a file renders `assets propose`'s own report for THAT file: parse + - `build_proposal_report` through THE gate (`screen_product`), the existing services - end to end -- an unattested candidate on an empty cache reads REJECT, proving the - gate really ran rather than the file being pretty-printed.""" - from keel.commands.admission import build_propose_view - from keel.commands.assets import screen_product - - shortlist = tmp_path / "2026-08-15-shortlist.json" - shortlist.write_text( - '{"candidates": [{"asset": "FET", "rationale": "ai compute", ' - '"sources": ["https://example.com/fet"]}]}' - ) - config = _config(proposals_dir=str(tmp_path)) - - view = build_propose_view(repo, config, screen_product, path=shortlist) - lines, cursor_line, candidates = cc.build_scout_file_lines(view, cursor=0) - - joined = "\n".join(line.text for line in lines) - assert "REJECT" in joined # unattested fails closed -- the gate ran - assert "FET" in joined - assert str(shortlist) in joined - assert candidates == 1 - # exactly one cursor-marked candidate row, on a verdict line - assert lines[cursor_line].text.strip().startswith(">") - assert "FET" in lines[cursor_line].text - - -def test_scout_attest_step_uses_the_same_typed_attest_form(repo: Repository) -> None: - """The admission flow's attest step IS the Compliance attest form (typed), seeded - with the chosen candidate's asset -- and it never runs without the phrase.""" - spy = _RecordingRepo(repo) - answers = _attest_answers(asset="FET", typed="FET", seeded=True) - result = cc.run_attest_form(spy, _prompt(answers), NOW_TS, asset="FET") - - assert spy.calls[0][1]["asset"] == "FET" - assert "attested FET" in result - - spy2 = _RecordingRepo(repo) - refused = cc.run_attest_form( - spy2, _prompt(_attest_answers(asset="FET", typed="no", seeded=True)), NOW_TS, asset="FET" - ) - assert spy2.calls == [] - assert "cancelled" in refused.lower() - - -# -- the "Shariah in force" browser (O10) ------------------------------------------------------ - - -def _inventory_repo(repo: Repository) -> Repository: - repo.upsert_asset_attestation( - asset="BTC", sector="payments", backing="ayn", pays_yield=False, - source="https://example.com/btc", attested_by="operator", attested_at=NOW_TS - 500, - ) - repo.upsert_instrument_attestation( - venue="coinbase", product_id="BTC-USD", wrapper="spot", - source="https://example.com/spec", attested_by="operator", attested_at=NOW_TS - 400, - ) - repo.upsert_screen_exception( - asset="BTC", criterion="history", rationale="venue history is deep enough", - granted_by="operator", granted_at=NOW_TS - 300, - ) - # An attestation for an asset NOT on the active allowlist -- must not appear. - repo.upsert_asset_attestation( - asset="DOGE", sector="gambling", backing="native", pays_yield=False, - source="https://example.com/doge", attested_by="operator", attested_at=NOW_TS - 200, - ) - return repo - - -def test_gather_attestations_in_force_scopes_to_the_active_allowlist(repo: Repository) -> None: - """The service read (in `keel.commands.assets`, not the TUI): the attestations IN - FORCE over the ACTIVE allowlist -- asset rows, instrument rows keyed by the venue - pair the screen looks up, exemptions in effect, and the allowlisted assets with NO - attestation named as such (fail-closed is a fact worth showing).""" - repo = _inventory_repo(repo) - config = _config() # allowlist [BTC, ETH] - - inventory = gather_attestations_in_force(repo, config) - - assert inventory.allowlist == ("BTC", "ETH") - assert [row["asset"] for row in inventory.asset_rows] == ["BTC"] - assert inventory.asset_rows[0]["source"] == "https://example.com/btc" - instrument_keys = [ - (row["venue"], row["product_id"], row["wrapper"]) - for row in inventory.instrument_rows - ] - assert instrument_keys == [("coinbase", "BTC-USD", "spot")] - assert [(row["asset"], row["criterion"]) for row in inventory.exceptions] == [ - ("BTC", "history") - ] - assert inventory.unattested == ("ETH",) - - -def test_shariah_lines_render_the_attestations_in_force_from_repo_data(repo: Repository) -> None: - """Each attestation carries its attributed source, its ruling and its recorded date - (O10's own demand) -- rendered from the service read, never re-derived by the TUI.""" - repo = _inventory_repo(repo) - inventory = gather_attestations_in_force(repo, _config()) - - lines = cc.build_shariah_lines(inventory, withdrawals_enabled=True, now_ts=NOW_TS) - joined = "\n".join(line.text for line in lines) - - assert "BTC" in joined - assert "https://example.com/btc" in joined # attributed source - assert "ayn" in joined and "payments" in joined # the ruling - assert "BTC-USD" in joined and "spot" in joined # the instrument in force - assert "coinbase" in joined - # the recorded date, human-readable (not a raw int) - expected_day = time.strftime("%Y-%m-%d", time.localtime(NOW_TS - 400)) - assert expected_day in joined - assert str(NOW_TS - 400) not in joined - - -def test_shariah_lines_render_exemptions_and_unattested_assets(repo: Repository) -> None: - repo = _inventory_repo(repo) - inventory = gather_attestations_in_force(repo, _config()) - - lines = cc.build_shariah_lines(inventory, withdrawals_enabled=None, now_ts=NOW_TS) - joined = "\n".join(line.text for line in lines) - - assert "history" in joined and "venue history is deep enough" in joined - assert "ETH" in joined - assert "no attestation" in joined.lower() # the fail-closed gap, named - - -def test_shariah_lines_render_the_live_rail17_state(repo: Repository) -> None: - inventory = gather_attestations_in_force(repo, _config()) - for state, expected in ((True, "ENABLED"), (False, "SUSPENDED"), (None, "UNKNOWN")): - lines = cc.build_shariah_lines( - inventory, withdrawals_enabled=state, now_ts=NOW_TS - ) - joined = "\n".join(line.text for line in lines) - assert expected in joined, expected - - -def test_the_honesty_lines_are_always_visible_and_sourced_from_fiqh_basis( - repo: Repository, -) -> None: - """The two standing honesty states render on EVERY shariah screen as a PINNED footer - (`shariah_honesty_lines`, painted outside the scroll) -- never buried in the - scrollable body -- and each is pinned two-sided against `docs/fiqh-basis.md`, so - neither the browser nor the document can drift while the other stays honest.""" - assert NOT_A_FATWA_ENGINE in _FIQH_BASIS - assert NO_SCHOLARLY_REVIEW in _FIQH_BASIS - - footer = cc.shariah_honesty_lines() - footer_text = "\n".join(line.text for line in footer) - assert NOT_A_FATWA_ENGINE in footer_text - assert NO_SCHOLARLY_REVIEW in footer_text - assert all(line.style == "alert" for line in footer if line.text) - - # the BODY no longer carries them: they ride the pinned footer instead, so they cannot - # end up a viewport below the fold on a long allowlist - for withdrawals in (True, False, None): - body = cc.build_shariah_lines( - gather_attestations_in_force(repo, _config()), - withdrawals_enabled=withdrawals, - now_ts=NOW_TS, - ) - joined = "\n".join(line.text for line in body) - assert NOT_A_FATWA_ENGINE not in joined - assert NO_SCHOLARLY_REVIEW not in joined - - -def test_the_shariah_frame_pins_the_honesty_lines_at_every_scroll_offset( - repo: Repository, -) -> None: - """O10 made structural: with a body longer than the viewport, the built frame contains - the honesty lines at offset 0 AND at max offset -- asserted on the FRAME (what gets - painted), not the line list, because the bug was precisely that the lines existed but - sat below the fold.""" - body = cc.build_compliance_view_lines( - "shariah", - cc.build_shariah_lines( - gather_attestations_in_force(repo, _config()), - withdrawals_enabled=None, - now_ts=NOW_TS, - ), - ) - footer = cc.shariah_honesty_lines() - height = 12 - assert len(body) > height # the premise: a body longer than the viewport - - max_offset = len(body) - (height - len(footer)) - for offset in (0, max_offset): - frame = cc.pinned_frame(body, footer, offset=offset, height=height) - joined = "\n".join(line.text for line in frame) - assert NOT_A_FATWA_ENGINE in joined, offset - assert NO_SCHOLARLY_REVIEW in joined, offset - assert len(frame) <= height, offset - # and the pinned lines are the frame's LAST rows -- a footer, not a header - assert [line.text for line in frame[len(frame) - len(footer) :]] == [ - line.text for line in footer - ] - - -def test_every_fiqh_constraint_quotes_fiqh_basis_verbatim_and_cites_a_real_section() -> None: - """The fiqh-derived rails are sourced from the fiqh basis's OWN structure: each - constraint's plain-English line is a VERBATIM quote from `docs/fiqh-basis.md`, and - its citation resolves to a heading that exists in that document -- nothing on this - screen is a TUI-authored fiqh summary.""" - def _squash(text: str) -> str: - return " ".join(text.split()) - - headings = [ - line.rstrip() - for line in _FIQH_BASIS.splitlines() - if line.startswith("#") - ] - squashed_doc = _squash(_FIQH_BASIS) - cited_keys = set() - for constraint in cc.FIQH_CONSTRAINTS: - # verbatim modulo the document's own line wrapping (markdown reflows) - assert _squash(constraint.quote) in squashed_doc, constraint.key - assert constraint.citation in headings, constraint.citation - cited_keys.add(constraint.key) - # The PRD's own list: the screen's attested axes, the allowlist rail, qabd, the - # spot charter, and purification. - assert cited_keys >= {"attested-vs-computed", "rail-1-allowlist", "rail-17-qabd", - "rails-18-19-spot-charter", "purification"} - - -def test_the_vocabulary_is_anchored_to_fiqh_basis_not_invented(repo: Repository) -> None: - """O10's vocabulary (qabd, riba, maisir, attestation, exemption, purification) is - defined ONLY by quoting what `docs/fiqh-basis.md` itself states; a term the document - does not state (gharar) is rendered as not-stated-there rather than defined by the - TUI -- the document's own honesty rule, inherited.""" - terms = {term.term for term in cc.VOCABULARY} - assert {"qabd", "riba", "maisir", "attestation", "exemption", "purification"} <= terms - squashed_doc = " ".join(_FIQH_BASIS.split()) - for term in cc.VOCABULARY: - assert term.citation in _FIQH_BASIS, term.term - if term.stated: - assert " ".join(term.definition.split()) in squashed_doc, term.term - else: - assert "not stated" in term.definition.lower(), term.term - - lines = cc.build_shariah_lines( - gather_attestations_in_force(repo, _config()), withdrawals_enabled=None, now_ts=NOW_TS - ) - joined = "\n".join(line.text for line in lines) - for term in ("qabd", "riba", "maisir", "attestation", "exemption", "purification"): - assert term in joined, term - - -def test_shariah_lines_name_the_active_profile_and_read_only_posture(repo: Repository) -> None: - lines = cc.build_shariah_lines( - gather_attestations_in_force(repo, _config(allowlist=["BTC", "ETH"])), - withdrawals_enabled=None, - now_ts=NOW_TS, - ) - joined = "\n".join(line.text for line in lines) - assert "read-only" in joined.lower() - assert "BTC" in joined and "ETH" in joined # the active allowlist, visible - - -def test_shariah_lines_fit_the_80_column_clip(repo: Repository) -> None: - """Same budget as every other console screen: `_paint` clips at the window width and - this dashboard targets 80 columns -- a citation must not lose its tail there.""" - lines = cc.build_shariah_lines( - gather_attestations_in_force(_inventory_repo(repo), _config()), - withdrawals_enabled=True, - now_ts=NOW_TS, - ) - for line in lines: - assert len(line.text) <= 80, line.text - - -# -- the view overlay ------------------------------------------------------------------------------ - - -def test_the_view_overlay_reuses_the_services_own_renderers() -> None: - """Every offline view is the SERVICE's own report, styled -- the screen view renders - `render_screen_report`'s exact verdict lines, the purification view renders the - purification report's exact lines: one implementation, two front-ends.""" - from keel.commands.admission import ScreenedProduct, ScreenReport - from keel.compliance.screen import MarketFacts, ScreenResult - - facts = MarketFacts( - asset="BTC", daily_bars=2000, median_daily_volume=Decimal("1000"), - quotable_in_settlement_currency=True, product_id="BTC-USD", venue="coinbase", - ) - report = ScreenReport( - quote="USD", - screened=[ - ScreenedProduct( - product="BTC-USD", asset="BTC", facts=facts, - result=ScreenResult(asset="BTC", admitted=True, failures=[], warnings=[]), - on_allowlist=True, attested=True, - ) - ], - ) - lines = cc.build_compliance_view_lines("screen", report) - joined = "\n".join(line.text for line in lines) - assert "ADMIT" in joined - assert "1/1 admitted" in joined - assert any("Compliance menu" in line.text for line in lines) - - pur = cc.build_compliance_view_lines("purification", ["no non-compliant credits found"]) - assert any("no non-compliant credits found" in line.text for line in pur) - - missing = cc.build_compliance_view_lines("purification", None, error="database is locked") - assert any("database is locked" in line.text for line in missing) - assert any(line.style == "alert" for line in missing) - # honest about what happens next: the failure names the RETRY KEY (Enter makes the - # read again), never a "retrying..." nothing retried on its own -- the held-error - # views (holdings/discover) repaint this exact line until an Enter re-runs the read. - error_text = "\n".join(line.text for line in missing) - assert "retrying" not in error_text - assert "press Enter to retry" in error_text - - -def test_the_network_views_render_armed_until_run() -> None: - """Holdings and discover make a live call -- so they open ARMED (nothing fetched), - exactly the discover overlay's own gating story.""" - armed = cc.build_compliance_view_lines("holdings", None) - joined = "\n".join(line.text for line in armed) - assert "ARMED" in joined - assert "Enter" in joined - - from keel.commands.assets import DiscoverSweep - from keel.compliance.screen import DiscoveryExclusions - - sweep = DiscoverSweep( - quote="USD", venue_product_count=9, candidates=(), survivor_count=1, - min_quote_24h_volume=Decimal("100000"), - excluded=DiscoveryExclusions(wrong_quote_currency=8), - rows=(), - probe_history=False, probe_liquidity=False, - min_median_daily_volume=Decimal("1000000"), - ) - run = cc.build_compliance_view_lines("discover", sweep) - assert any("candidates" in line.text for line in run) diff --git a/tests/commands/test_console.py b/tests/commands/test_console.py deleted file mode 100644 index 95da6564..00000000 --- a/tests/commands/test_console.py +++ /dev/null @@ -1,716 +0,0 @@ -"""Tests for `keel.commands.console` -- the console shell (issue #388 C2, PRD O4/O9). - -Three surfaces, all pinned here: - -* **The deployment convention (O4)** -- the four known config+db pairs, discovered from the - tracked config files that exist in the deployment directory, exactly the pairs the - `keel-paper`/`keel-live`/`keel-paperhourly`/`keel-equities` wrappers pin. A switch only - ever binds a whole pair; the LIVE pair is guarded by an explicit confirm. -* **The menu shell (PRD §3)** -- the top-level tree's entries with future slices as - placeholders that render a "lands in Cx" notice. Navigation only: no entry beyond - Dashboard/Profile/Help does anything in this slice. -* **The session banner (O9)** -- profile + recorded market session + the venue market - clock, composed from `keel.agent`'s recorded state alone: 24/7, open/closed with next - open/close, or CLOCK UNAVAILABLE fail-loud when the record is absent or stale. - -Mirrors `tests/commands/test_tui.py`'s fixture style (in-memory `Repository`, `_config`, -`NOW_TS`). -""" - -from __future__ import annotations - -from decimal import Decimal -from typing import Any - -import click -import pytest -from keel_broker_api.results import MarketSchedule, SessionState - -from keel import agent -from keel.commands import console -from keel.commands.console import _short_dt, build_banner_lines -from keel.config import ( - AutoTradeConfig, - BrokerConfig, - Caps, - Config, - DcaConfig, - MarketDataConfig, - MoneyMgmtConfig, -) -from keel.data.db import connect, migrate -from keel.data.repository import Repository - -NOW_TS = 1_800_000_000 - -#: The minimal config every profile fixture in these tests is built from -- `load_config`'s -#: own minimal shape (`tests/test_config.py`): allowlist + caps are required; everything -#: else defaults. Two entries so a second profile can be told apart by its allowlist. -_MINIMAL_CONFIG = "allowlist: [BTC]\ncaps: {max_exposure_usd: 100, max_per_asset_pct: 0.5}\n" -_MINIMAL_CONFIG_ALT = "allowlist: [ETH]\ncaps: {max_exposure_usd: 100, max_per_asset_pct: 0.5}\n" - - -@pytest.fixture -def repo() -> Repository: - conn = connect(":memory:") - migrate(conn) - r = Repository(conn) - r.set_state("kill_switch", False) - return r - - -def _config(**overrides: Any) -> Config: - base: dict[str, Any] = dict( - allowlist=["BTC", "ETH"], - target_weights={}, - risk_pct=Decimal("0.01"), - caps=Caps( - max_per_order_usd=Decimal("100000"), - max_per_day_usd=Decimal("300000"), - max_exposure_usd=Decimal("1000000"), - max_per_asset_pct=Decimal("1"), - ), - market_data=MarketDataConfig( - granularities=[], history_days=365 - ), - auto_trade=AutoTradeConfig(mode="paper", interval_sec=900), - money_mgmt=MoneyMgmtConfig( - max_total_dd_pct=Decimal("0.20"), max_weekly_dd_pct=Decimal("0.08") - ), - dca=DcaConfig(budget_usd=Decimal("50"), cadence_days=7), - ) - base.update(overrides) - return Config(**base) - - -def _write_all_profiles(deployment_dir: Any) -> None: - """Materialize every known deployment's config in `deployment_dir` (dbs are created by - `_open_repo` exactly as the CLI would create them on first use).""" - for profile in console.KNOWN_PROFILES: - path = deployment_dir / profile.config_path - path.write_text( - _MINIMAL_CONFIG_ALT if profile.key == "paper-hourly" else _MINIMAL_CONFIG - ) - - -# -- the deployment convention (O4) --------------------------------------------------------------- - - -def test_the_known_profiles_are_exactly_the_wrapper_pairs() -> None: - """The convention, stated as a pin: the four deployments the wrappers (`keel-paper`, - `keel-live`, `keel-paperhourly`, `keel-equities`) and the runbook's deployment table - define -- each config travelling with its OWN database, and LIVE the only guarded one.""" - assert [(p.key, p.config_path, p.db_path) for p in console.KNOWN_PROFILES] == [ - ("paper-forward", "config.paperforward.yaml", "keel.db"), - ("live", "config.live-sandbox.yaml", "keel-live.db"), - ("paper-hourly", "config.paper-hourly.yaml", "keel-paperhourly.db"), - ("paper-equities", "config.paper-equities.yaml", "keel-equities.db"), - ] - assert [p.requires_confirmation for p in console.KNOWN_PROFILES] == [False, True, False, False] - - -def test_discovery_lists_only_deployments_whose_config_exists(tmp_path: Any) -> None: - """The profile list is discovered from the tracked config files present in the - deployment directory -- no registry file, no hard-coded "all four always": a checkout - without `config.live-sandbox.yaml` must not offer a live entry that cannot load.""" - (tmp_path / "config.paperforward.yaml").write_text(_MINIMAL_CONFIG) - (tmp_path / "config.paper-hourly.yaml").write_text(_MINIMAL_CONFIG) - - found = console.discover_profiles(base_dir=tmp_path) - - assert [p.key for p in found] == ["paper-forward", "paper-hourly"] - - -def test_discovery_from_the_working_directory_matches_an_explicit_dir( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """`discover_profiles()` with no argument reads the working directory -- the same - directory the CLI's relative `--config`/`--db` paths resolve against.""" - _write_all_profiles(tmp_path) - monkeypatch.chdir(tmp_path) - - assert [p.key for p in console.discover_profiles()] == [ - p.key for p in console.KNOWN_PROFILES - ] - - -def test_active_profile_matches_the_exact_pair() -> None: - """The active profile is resolved from the exact config+db PAIR the console was started - with -- both halves, because the wrappers' whole reason to exist is that the pair is - what names a deployment.""" - profiles = list(console.KNOWN_PROFILES) - assert console.active_profile("config.live-sandbox.yaml", "keel-live.db", profiles).key == ( - "live" - ) - assert console.active_profile("config.paperforward.yaml", "keel.db", profiles).key == ( - "paper-forward" - ) - - -def test_active_profile_refuses_a_mismatched_pair() -> None: - """A config from one deployment opened against another deployment's db -- the exact - footgun the wrappers exist to remove -- is NOT any known profile, and the header must - not pretend it is.""" - assert console.active_profile("config.live-sandbox.yaml", "keel.db") is None - assert console.active_profile("config.yaml", "keel.db") is None - assert console.active_profile("config.paperforward.yaml", "keel-live.db") is None - - -# -- switching (O4): one action rebinds config+db everywhere -------------------------------------- - - -def _binding(config_path: str = "config.paperforward.yaml", db_path: str = "keel.db" - ) -> console.ConsoleBinding: - ctx = click.Context(click.Command("tui"), obj={"config_path": config_path, "db_path": db_path}) - return console.ConsoleBinding(ctx, config_path=config_path, db_path=db_path) - - -def test_switching_rebinds_the_pair_in_one_action(tmp_path: Any) -> None: - """The pinned acceptance: switching is ONE action that rebinds config AND db together - (the wrappers' rule) -- never a config without its database.""" - _write_all_profiles(tmp_path) - monkeypatch = pytest.MonkeyPatch() - monkeypatch.chdir(tmp_path) - try: - binding = _binding() - profiles = console.discover_profiles(base_dir=tmp_path) - target = next(p for p in profiles if p.key == "paper-hourly") - - toast = console.switch_profile(binding, target, confirm_fn=lambda: True) - - assert binding.config_path == "config.paper-hourly.yaml" - assert binding.db_path == "keel-paperhourly.db" - assert "paper-hourly" in toast - assert "config.paper-hourly.yaml" in toast and "keel-paperhourly.db" in toast - finally: - monkeypatch.undo() - - -def test_switching_to_paper_never_asks_for_confirmation(tmp_path: Any) -> None: - """Only LIVE is guarded: switching between paper deployments is as ungated as running - the other wrapper would be, and a confirmation gate here would be ceremony.""" - (tmp_path / "config.paperforward.yaml").write_text(_MINIMAL_CONFIG) - (tmp_path / "config.paper-hourly.yaml").write_text(_MINIMAL_CONFIG_ALT) - binding = _binding() - profiles = console.discover_profiles(base_dir=tmp_path) - target = next(p for p in profiles if p.key == "paper-hourly") - asked: list[bool] = [] - - console.switch_profile(binding, target, confirm_fn=lambda: (asked.append(True) or True)) - - assert asked == [] # paper switching is immediate - - -def test_live_requires_an_explicit_confirmation(tmp_path: Any) -> None: - """O3's guard, at the profile seam: pointing the console at the LIVE deployment adds - real-money answers, so it happens only after an explicit confirm step -- declined - leaves the binding exactly where it was.""" - (tmp_path / "config.paperforward.yaml").write_text(_MINIMAL_CONFIG) - (tmp_path / "config.live-sandbox.yaml").write_text(_MINIMAL_CONFIG_ALT) - binding = _binding() - profiles = console.discover_profiles(base_dir=tmp_path) - live = next(p for p in profiles if p.key == "live") - - declined = console.switch_profile(binding, live, confirm_fn=lambda: False) - assert binding.config_path == "config.paperforward.yaml" - assert binding.db_path == "keel.db" - assert "unchanged" in declined.lower() - - accepted = console.switch_profile(binding, live, confirm_fn=lambda: True) - assert binding.config_path == "config.live-sandbox.yaml" - assert binding.db_path == "keel-live.db" - assert "live" in accepted.lower() - - -def test_switching_to_the_active_profile_is_a_no_op_not_a_confirmation(tmp_path: Any) -> None: - (tmp_path / "config.paperforward.yaml").write_text(_MINIMAL_CONFIG) - binding = _binding() - profiles = console.discover_profiles(base_dir=tmp_path) - active = next(p for p in profiles if p.key == "paper-forward") - asked: list[bool] = [] - - toast = console.switch_profile(binding, active, confirm_fn=lambda: (asked.append(True) or True)) - - assert asked == [] - assert "already" in toast.lower() - - -def test_a_wrong_pair_is_refused(tmp_path: Any) -> None: - """The pair rule enforced, not just assumed: a profile whose config+db combination is - not one of the known pairs -- live's config on the paper database, the exact - wrong-ledger footgun -- is refused outright, even though each half individually exists.""" - (tmp_path / "config.live-sandbox.yaml").write_text(_MINIMAL_CONFIG) - binding = _binding() - wrong_pair = console.DeploymentProfile( - key="live", label="LIVE", config_path="config.live-sandbox.yaml", db_path="keel.db" - ) - - with pytest.raises(ValueError, match="refus"): - console.switch_profile(binding, wrong_pair, confirm_fn=lambda: True) - assert binding.db_path == "keel.db" # untouched - - -def test_the_binding_opens_state_through_the_cli_loaders(tmp_path: Any) -> None: - """The binding's `open_state` IS the CLI path: `_load_cfg`/`_open_repo` over the ctx - pair -- so a switched console reads exactly what `keel --config X --db Y status` would, - and rebinding changes what those loaders see.""" - _write_all_profiles(tmp_path) - monkeypatch = pytest.MonkeyPatch() - monkeypatch.chdir(tmp_path) - try: - binding = _binding() - repo_a, config_a = binding.open_state() - assert config_a.allowlist == ["BTC"] - - binding.rebind(next(p for p in console.KNOWN_PROFILES if p.key == "paper-hourly")) - repo_b, config_b = binding.open_state() - - assert config_b.allowlist == ["ETH"] # the other config actually loaded - # ...and the other database: a state row written through the first repo is not - # visible through the second. - repo_a.set_state("kill_switch", True) - assert repo_b.get_state("kill_switch") is None - finally: - monkeypatch.undo() - - -# -- the menu shell (PRD §3) --------------------------------------------------------------------- - - -def test_the_menu_is_the_prd_tree_in_order() -> None: - """The nine top-level entries of PRD §3's tree, in tree order -- the shell is - navigation, and every future slice's entry is already in its place.""" - assert [entry.label for entry in console.CONSOLE_MENU] == [ - "Dashboard", - "Profile", - "Trading", - "Rules", - "Compliance", - "Data", - "Research", - "Account", - "Help", - ] - - -def test_only_the_landed_slices_do_anything() -> None: - """C2 shipped the SHELL; C3 (issue #389) landed Compliance; C4 (issue #390) landed - Rules and Research; C5 (issue #391) landed Trading and Data; C6 (issue #392) landed - Account -- the PRD §3 tree is whole, every entry is a live destination, and the - placeholder MECHANISM (`lands_in`, the placeholder mode) stays for a future slice's - entry, unused today.""" - available = [e.label for e in console.CONSOLE_MENU if e.lands_in is None] - assert available == [ - "Dashboard", - "Profile", - "Trading", - "Rules", - "Compliance", - "Data", - "Research", - "Account", - "Help", - ] - assert [e for e in console.CONSOLE_MENU if e.lands_in is not None] == [] - - -def test_the_account_entry_opens_the_account_menu() -> None: - """C6's dispatch: the tree's Account entry is no longer a placeholder -- selecting - it opens the Account sub-menu (`keel.commands.account_console`), the branch's - read-only pnl and versions views.""" - account = console.menu_entry(8) - assert account is not None - assert account.label == "Account" - assert account.action == "account" - assert account.available - - -def test_the_trading_entry_opens_the_trading_menu() -> None: - """C5's dispatch: the tree's Trading entry opens the Trading sub-menu - (`keel.commands.trading_console`) -- the cycle/poll confirmations and the typed - halt-release contracts of O3.""" - trading = console.menu_entry(3) - assert trading is not None - assert trading.label == "Trading" - assert trading.action == "trading" - assert trading.available - - -def test_the_data_entry_opens_the_data_menu() -> None: - """C5's dispatch: the tree's Data entry opens the Data sub-menu - (`keel.commands.data_console`) -- fetch and its variants over the fetch service.""" - data = console.menu_entry(6) - assert data is not None - assert data.label == "Data" - assert data.action == "data" - assert data.available - - -def test_the_compliance_entry_opens_the_compliance_menu() -> None: - """C3's dispatch: the tree's Compliance entry is no longer a placeholder -- selecting - it opens the Compliance sub-menu (`keel.commands.compliance_console`).""" - compliance = console.menu_entry(5) - assert compliance is not None - assert compliance.label == "Compliance" - assert compliance.action == "compliance" - assert compliance.available - - -def test_the_rules_entry_opens_the_strategy_console() -> None: - """C4's dispatch: the tree's Rules entry opens the strategy console - (`keel.commands.strategy_console`) -- the ledger, simulate, add and retry of O11.""" - rules = console.menu_entry(4) - assert rules is not None - assert rules.label == "Rules" - assert rules.action == "strategy" - assert rules.available - - -def test_the_research_entry_opens_the_research_readers() -> None: - """C4's dispatch: the tree's Research entry opens the O5 evidence readers - (`keel.commands.research_console`).""" - research = console.menu_entry(7) - assert research is not None - assert research.label == "Research" - assert research.action == "research" - assert research.available - - -def test_the_menu_is_reachable_by_its_displayed_ordinals() -> None: - """1-9 select the entries in displayed order -- the shortcut keys and the rendered - ordinals are one function, so they cannot drift.""" - for entry in console.CONSOLE_MENU: - assert console.menu_entry(entry.ordinal) is entry - - -def test_an_unknown_ordinal_has_no_entry() -> None: - assert console.menu_entry(0) is None - assert console.menu_entry(10) is None - - -def test_the_menu_screen_renders_every_entry_and_the_lands_in_notices(tmp_path: Any) -> None: - """The menu screen: every PRD entry visible, placeholders carrying their 'lands in Cx' - notice inline, and the cursor marking one row.""" - _write_all_profiles(tmp_path) - lines = console.build_menu_lines( - console.KNOWN_PROFILES[0], cursor=2, profiles=console.discover_profiles(base_dir=tmp_path) - ) - texts = [line.text for line in lines] - for entry in console.CONSOLE_MENU: - assert any(entry.label in t for t in texts), entry.label - joined = "\n".join(texts) - # C6 landed (issue #392): Account is a live entry now, so NO "lands in Cx" notice - # remains anywhere in the tree -- every PRD §3 entry dispatches. - assert "lands in" not in joined - # The cursor marks exactly one row (Trading, index 2 of the entries). - marked = [t for t in texts if t.lstrip().startswith(">")] - assert len(marked) == 1 and "Trading" in marked[0] - - -def test_the_placeholder_screen_names_its_slice_and_says_navigation_only() -> None: - """Selecting a FUTURE slice's entry lands on a notice, not a blank screen: which - slice owns the behavior, and that the shell renders navigation only -- nothing is - invokable from it. No current entry is a placeholder (C6 landed the last one), so - the MECHANISM is pinned over a synthetic entry of the same shape the tree would - carry -- the shell keeps the mode for the next slice, tested dead today.""" - future = console.MenuEntry( - ordinal=10, label="Future", lands_in="C8", description="what C8 will own" - ) - lines = console.build_placeholder_lines(future) - joined = "\n".join(line.text for line in lines) - assert "C8" in joined - assert "Future" in joined - assert "navigation" in joined.lower() - - -def test_the_profile_menu_lists_every_discovered_deployment_with_its_pair(tmp_path: Any) -> None: - """The Profile menu shows each deployment's config+db pair -- the O4 rule that the - active pair is VISIBLE before any action -- marks the active one, and marks LIVE as - the guarded row.""" - _write_all_profiles(tmp_path) - profiles = console.discover_profiles(base_dir=tmp_path) - lines = console.build_profile_menu_lines( - profiles, - cursor=1, - binding_pair=(profiles[0].config_path, profiles[0].db_path), - ) - texts = [line.text for line in lines] - for profile in profiles: - assert any(profile.config_path in t and profile.db_path in t for t in texts), profile.key - assert any("active" in t.lower() and "paper-forward" in t for t in texts) - live_rows = [line for line in lines if "live-sandbox" in line.text] - assert live_rows and all(line.style == "alert" for line in live_rows) - assert any("confirm" in t.lower() for t in texts) # the guard is stated on the row - - -# -- the session banner (O9) ---------------------------------------------------------------------- - - -def _recorded( - state: str, - *, - recorded_ts: int = NOW_TS - 60, - next_open_ts: int | None = None, - next_close_ts: int | None = None, - fresh: bool = True, -) -> agent.RecordedSession: - return agent.RecordedSession( - venue="alpaca", - state=state, - recorded_ts=recorded_ts, - interval_sec=900, - next_open_ts=next_open_ts, - next_close_ts=next_close_ts, - fresh=fresh, - ) - - -def _banner( - profile: console.DeploymentProfile | None = None, - session_bound: bool = True, - record: agent.RecordedSession | None = None, -) -> list[Any]: - if profile is None: - profile = console.KNOWN_PROFILES[0] - return build_banner_lines(profile, session_bound, record, NOW_TS) - - -def test_banner_first_line_names_the_profile_and_its_config_db_pair() -> None: - lines = _banner() - assert lines[0].text == ( - f"console: paper-forward · {console.KNOWN_PROFILES[0].config_path} " - f"+ {console.KNOWN_PROFILES[0].db_path}" - ) - assert lines[0].style == "heading" - - -def test_banner_marks_the_live_profile_unmistakably() -> None: - """The live deployment must be impossible to miss: the word LIVE and the strongest - style the dashboard has -- the same weight a live autonomy line gets.""" - lines = _banner(profile=console.KNOWN_PROFILES[1]) - assert "LIVE" in lines[0].text - assert lines[0].style == "alert" - - -def test_banner_names_an_unrecognized_deployment_honestly() -> None: - """Started with a pair that is not one of the four (the raw `keel tui` default, - `config.yaml` + `keel.db`), the header shows the pair and says it is no known - deployment -- it never guesses a label.""" - lines = build_banner_lines( - None, False, None, NOW_TS, binding_pair=("config.yaml", "keel.db") - ) - assert "config.yaml" in lines[0].text and "keel.db" in lines[0].text - assert "unrecognized" in lines[0].text - assert lines[0].style == "warn" - - -def test_banner_renders_24_7_for_an_always_open_venue() -> None: - """A 24/7 venue (every crypto deployment) says so explicitly -- muted, the expected - quiet -- no matter what any record claims.""" - lines = _banner(session_bound=False) - session = lines[1] - assert "24/7" in session.text - assert session.style == "muted" - - -def test_banner_renders_open_with_next_open_and_close() -> None: - lines = _banner( - record=_recorded( - "open", next_open_ts=NOW_TS + 43_200, next_close_ts=NOW_TS + 7_200 - ) - ) - session = lines[1] - assert "OPEN" in session.text - # Both schedule stamps on one row, at minute precision: two full `_human_dt` stamps - # plus their labels run past the 80-column budget `_paint` clips at. - assert session.text == ( - f"market: OPEN (venue clock) · closes {_short_dt(NOW_TS + 7_200)} " - f"· opens {_short_dt(NOW_TS + 43_200)}" - ) - assert session.style == "ok" - - -def test_banner_renders_closed_with_the_next_open() -> None: - lines = _banner(record=_recorded("closed", next_open_ts=NOW_TS + 172_800)) - session = lines[1] - assert "CLOSED" in session.text - assert session.text == ( - f"market: CLOSED (venue clock) -- cycles skip · opens {_short_dt(NOW_TS + 172_800)}" - ) - assert session.style == "muted" # an expected weekend, per B1's own convention - - -def test_banner_renders_clock_unavailable_when_nothing_is_recorded() -> None: - """A session-bound venue with no record at all: CLOCK UNAVAILABLE, fail-loud -- the - same posture `fetch --check` keeps, never a guessed-open.""" - lines = _banner(record=None) - session = lines[1] - assert "CLOCK UNAVAILABLE" in session.text - assert session.style == "warn" - - -def test_banner_renders_clock_unavailable_when_the_record_is_stale() -> None: - """A record outside its trust window no longer vouches for anything: stale renders as - CLOCK UNAVAILABLE, not as the state it happened to freeze on -- leading with the - fail-loud headline, then the shortened when-recorded stamp (the full prose form ran - past column 80 and the timestamp was exactly the part that clipped).""" - lines = _banner(record=_recorded("open", fresh=False)) - session = lines[1] - assert session.text.startswith("market: CLOCK UNAVAILABLE") - assert _short_dt(NOW_TS - 60) in session.text - assert session.style == "warn" - - -def test_banner_renders_a_recorded_unreadable_clock_fail_loud() -> None: - lines = _banner(record=_recorded("clock_unavailable")) - session = lines[1] - assert "CLOCK UNAVAILABLE" in session.text - assert session.style == "warn" - - -def test_banner_without_schedule_times_renders_the_state_alone() -> None: - """A session-bound venue whose adapter carries no schedule (the port default, or a - pre-#388 third-party adapter): the state renders without invented times.""" - lines = _banner(record=_recorded("open")) - session = lines[1] - assert "OPEN" in session.text - assert "next open" not in session.text.lower() and "next close" not in session.text.lower() - - -# -- the banner as one recorded-state read (the TUI's entry point) --------------------------------- - - -def test_console_banner_lines_composes_from_the_repo_and_config_alone(repo: Repository) -> None: - """The TUI's one-call banner: recorded session + the venue's own session-boundness, - nothing else -- no broker, no network, no TUI-side calendar.""" - binding = _binding() - config = _config() # no broker: section -> coinbase -> 24/7 - lines = console.console_banner_lines(binding, repo, config, NOW_TS) - assert any("24/7" in line.text for line in lines) - assert lines[0].text.startswith("console: paper-forward") - - # The same call over a session-bound venue with a recorded schedule renders it. A - # SECOND binding, because session-boundness is cached per binding (the config IS the - # bound pair's -- a different venue means a rebound pair, never a config swapped - # under one binding). - alpaca = _config(broker=BrokerConfig(name="alpaca", endpoint="paper", data_feed="iex")) - repo.set_state("market_session:alpaca", "open") - repo.set_state("market_session_ts:alpaca", NOW_TS - 60) - repo.set_state("market_session_interval_sec:alpaca", 900) - repo.set_state("market_session_next_open:alpaca", NOW_TS + 43_200) - repo.set_state("market_session_next_close:alpaca", NOW_TS + 7_200) - lines = console.console_banner_lines(_binding(), repo, alpaca, NOW_TS) - session = lines[1] - assert "OPEN" in session.text - assert _short_dt(NOW_TS + 7_200) in session.text - - -def test_venue_session_bound_reads_the_adapter_declaration() -> None: - """24/7 vs session-bound comes from the ADAPTER's own capabilities declaration (the - registry holds no broker handle and constructs no transport -- the read is offline), - and a venue that cannot be resolved is treated as session-bound so the banner fails - loud rather than assuming a 24/7 it cannot know.""" - assert console.venue_session_bound(_config()) is False # coinbase default - alpaca = _config(broker=BrokerConfig(name="alpaca", endpoint="paper", data_feed="iex")) - assert console.venue_session_bound(alpaca) is True - unknown = _config(broker=BrokerConfig(name="not-installed")) - assert console.venue_session_bound(unknown) is True - - -# -- the port default the 24/7 recordings lean on (kept honest here too) --------------------------- - - -def test_a_24x7_market_schedule_is_open_with_no_times() -> None: - """The banner's 24/7 rendering and the recording's "24/7 records nothing" both lean on - the port's default schedule being OPEN-with-nulls; pinned once more at the console's - layer so a future port change cannot silently alter the banner's semantics.""" - schedule = MarketSchedule(state=SessionState.OPEN) - assert schedule.next_open_ts is None and schedule.next_close_ts is None - - -def test_the_banner_resolves_session_boundness_once_per_binding( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """Session-boundness is STATIC per binding (the adapter's own capabilities - declaration), so it is resolved ONCE and cached on the `ConsoleBinding` -- not - re-walked per banner render, which put an `importlib.metadata` discovery - (`venue_session_bound` -> `load_broker` -> `discover_brokers()`) on every poll of every - screen, on top of the `open_state()` the banner already does. `rebind` invalidates the - cache, because a different pair means a possibly different venue.""" - import keel_broker_api.registry as registry - - calls: list[str] = [] - real_load_broker = registry.load_broker - - def _counting_load_broker(name: str) -> Any: - calls.append(name) - return real_load_broker(name) - - monkeypatch.setattr(registry, "load_broker", _counting_load_broker) - - binding = _binding() - config = _config() # coinbase default -> the adapter walk resolves "coinbase" - for _ in range(3): - console.console_banner_lines(binding, repo, config, NOW_TS) - - assert calls == ["coinbase"] # ONE resolution for three banner builds - - binding.rebind(console.KNOWN_PROFILES[2]) - console.console_banner_lines(binding, repo, config, NOW_TS) - assert len(calls) == 2 # rebinding is the invalidation point - - -def test_the_profile_menu_footer_names_q_esc_p_and_m() -> None: - """Profile mode closes on `m` too, like menu and placeholder modes -- the footer says - which keys, so the binding and the hint cannot drift.""" - lines = console.build_profile_menu_lines(list(console.KNOWN_PROFILES)) - - assert "q/Esc/p/m" in lines[-1].text - - -def test_banner_lines_fit_the_80_column_clip() -> None: - """The banner is two rows on screens `_paint` clips at the window width, and this - dashboard targets 80 columns: every WORST-CASE shape -- the guarded LIVE profile, the - unrecognized raw pair, both schedule stamps present (alpaca's `/v2/clock` carries - next_open AND next_close), the stale/absent-record fail-louds -- must fit whole, with - no load-bearing tail (a timestamp, the guard note) lost past column 80.""" - records: list[agent.RecordedSession | None] = [ - None, # nothing recorded at all - _recorded("open", fresh=False), # a stale record - _recorded("clock_unavailable"), # a recorded unreadable clock - _recorded("open", next_open_ts=NOW_TS + 43_200, next_close_ts=NOW_TS + 7_200), - _recorded("open", next_close_ts=NOW_TS + 7_200), - _recorded("closed", next_open_ts=NOW_TS + 172_800), - ] - for profile in (None, console.KNOWN_PROFILES[0], console.KNOWN_PROFILES[1]): - for session_bound in (True, False): - for record in records: - lines = build_banner_lines( - profile, - session_bound, - record, - NOW_TS, - binding_pair=("config.yaml", "keel.db"), - ) - for line in lines: - assert len(line.text) <= 80, line.text - - -def test_profile_menu_rows_fit_the_80_column_clip(tmp_path: Any) -> None: - """Same budget for the Profile menu's rows: the LIVE row carries the longest pair AND - the [active] marker, and its guard note WRAPS to its own row rather than being clipped - off the end of the pair it guards.""" - _write_all_profiles(tmp_path) - profiles = console.discover_profiles(base_dir=tmp_path) - live = console.KNOWN_PROFILES[1] - for cursor in range(len(profiles)): - lines = console.build_profile_menu_lines( - profiles, cursor=cursor, binding_pair=(live.config_path, live.db_path) - ) - for line in lines: - assert len(line.text) <= 80, line.text - # The wrapped note is still on the screen, right under the guarded row, in the same - # alert style -- wrapping must not drop the guard's wording. - guarded = [line for line in lines if "guarded" in line.text] - assert guarded and all(line.style == "alert" for line in guarded) - assert any("confirmation" in line.text for line in guarded) diff --git a/tests/commands/test_console_ceremony.py b/tests/commands/test_console_ceremony.py deleted file mode 100644 index e83d982a..00000000 --- a/tests/commands/test_console_ceremony.py +++ /dev/null @@ -1,1087 +0,0 @@ -"""The typed-confirmation + live-guard adversarial audit, as TESTS (issue #392 C6; PRD -§5 C6 / O3 -- "a dedicated adversarial review of the typed-confirmation contract and -live-profile guards", encoded so the review reruns forever). - -THE TABLE. Every state-mutating console action has a row in `CEREMONY`, keyed by -`(registry, target)` -- the sub-menu it is invoked from and its dispatch target -- and -carrying one or more ceremony CELLS, each naming its class: - -* **typed-phrase** -- the CLI's OWN `_require_interactive_confirmation` (or the typed - asset-code / `yes` word) runs in-console through the curses suspend/restore dance: - never piped, never pre-filled, never bypassed, failing closed. -* **confirm-step** -- an explicit y/N (`click.confirm`-shaped): the LIVE profile's - guard and the retry flow's promote question. -* **armed-enter** -- the ARMED views: opening (and every poll) runs NOTHING; Enter is - the confirm step, the run blocks, and its result is held. -* **ungated-by-design** -- the CLI's own contract has no gate; each row's note says WHY - that is the safe direction (kill halts; autonomy off reduces capability; rules add - lands as candidate; db import is read-only w.r.t. the exchange). - -Each cell also names its PROOFS -- the existing test functions that pin it (one source -of truth: this suite asserts every named proof exists, and adds compact refusal proofs -only for the cells no existing test covered). The refusal invariant threaded through: -a declined gate, a wrong phrase or a cancel writes NOTHING -- the state the write would -touch is byte-identical after. - -THE TEETH (`test_the_table_covers_every_mutating_action_the_registries_dispatch`): the -mutating keys are DERIVED from the console modules' dispatch registries at runtime -- -everything that is not a module's declared read-only kind needs a row. A newly added -mutating action (a form, an armed run, an immediate action, a new kind nobody -classified) FAILS the suite until it is given a ceremony row; a stale row for a removed -action fails the same equality. -""" - -from __future__ import annotations - -import ast -import importlib -import inspect -from collections.abc import Callable -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -import pytest - -from keel.commands import ( - account_console, - compliance_console, - data_console, - help_console, - strategy_console, - trading_console, -) -from keel.commands import ( - console as console_mod, -) -from keel.commands.tui import run_live -from keel.data.db import connect, migrate -from keel.data.repository import Repository - -NOW_TS = 1_800_000_000 - -#: The ceremony classes -- a closed vocabulary; a row outside it fails its own test. -CLASSES = ("typed-phrase", "confirm-step", "armed-enter", "ungated-by-design") - - -@dataclass(frozen=True) -class Cell: - """One ceremony a mutating action carries: its class, the plain statement of what - the ceremony IS (or why its absence is the safe direction), and the existing test - functions that prove it.""" - - ceremony: str - note: str - proofs: tuple[tuple[str, str], ...] = () - - -T = "typed-phrase" -C = "confirm-step" -A = "armed-enter" -U = "ungated-by-design" - -#: The audit table itself. Keys are `(registry, target)`; the non-menu write paths the -#: dashboard carries (its `a`/`f` keys) and the scout browser's `a` attest key ride -#: under their own area names, declared in `EXTRA_KEYS` below. -CEREMONY: dict[tuple[str, str], tuple[Cell, ...]] = { - # -- the console shell (C2): the LIVE pair's guard --------------------------------------------- - ("CONSOLE_MENU", "profile"): ( - Cell( - C, - "selecting the LIVE deployment asks an explicit y/N at the terminal (a view " - "switch, not a typed gate: it changes what the console answers about, never " - "what the engine does); a decline keeps the binding exactly where it was", - ( - ("test_console", "test_live_requires_an_explicit_confirmation"), - ("test_console", "test_switching_to_paper_never_asks_for_confirmation"), - ( - "test_console", - "test_switching_to_the_active_profile_is_a_no_op_not_a_confirmation", - ), - ("test_console", "test_a_wrong_pair_is_refused"), - ("test_tui", "test_run_live_live_switch_declined_keeps_the_binding"), - ), - ), - Cell( - U, - "paper switches carry no ceremony by design: every paper pair is the same " - "class of deployment, and the wrapper's CLI flags remain the documented " - "direct-binding path", - (("test_console", "test_switching_rebinds_the_pair_in_one_action"),), - ), - ), - # -- the Compliance menu (C3) ------------------------------------------------------------------ - ("COMPLIANCE_MENU", "attest"): ( - Cell( - T, - "the operator types the ASSET CODE back, naming the thing being recorded -- " - "case-sensitive, nothing else accepts, and the question never leaks the " - "phrase; a wrong phrase records nothing", - ( - ( - "test_compliance_console", - "test_attest_form_refuses_to_proceed_without_the_typed_asset_code", - ), - ( - "test_compliance_console", - "test_the_attest_typed_gate_never_leaks_the_phrase_into_the_question", - ), - ( - "test_compliance_console", - "test_attest_form_is_cancelled_by_an_empty_asset", - ), - ), - ), - ), - ("COMPLIANCE_MENU", "attest-instrument"): ( - Cell( - U, - "the CLI's own gate is none (recording what contract a listing is adds an " - "admission INPUT; the screen still fails closed on it); the form cancels " - "cleanly and its vocabulary is the CLI's own Choice set", - ( - ( - "test_compliance_console", - "test_instrument_attest_form_uppercases_the_product_like_the_cli", - ), - ( - "test_compliance_console", - "test_instrument_attest_validation_raises_form_input_error_and_the_form_renders_it", - ), - ), - ), - ), - ("COMPLIANCE_MENU", "exempt"): ( - Cell( - U, - "the CLI's own gate is none; the form's own guards (the waivable-criterion " - "vocabulary, a refused blank rationale) are the CLI's, and a cancel writes " - "no waiver", - ( - ( - "test_compliance_console", - "test_exempt_form_records_a_documented_exception_and_refuses_a_blank_rationale", - ), - ), - ), - ), - ("COMPLIANCE_MENU", "unexempt"): ( - Cell( - U, - "revoking a waiver is de-risking and is always allowed, exactly as the CLI " - "allows it; the repository's own rowcount tells a revoke from a no-op", - ( - ("test_compliance_console", "test_unexempt_form_reports_a_revoke_and_an_absence"), - ), - ), - ), - ("COMPLIANCE_MENU", "subscription-attest"): ( - Cell( - U, - "the CLI's own gate is none -- an attested tier only ever LOWERS rail 14's " - "allowance to the tier's real cap; the form cancels on an empty tier and " - "renders the service's own refusals verbatim", - ( - ( - "test_compliance_console", - "test_subscription_attest_form_dispatches_to_the_subscription_service", - ), - ( - "test_compliance_console", - "test_subscription_attest_form_renders_a_service_error_calmly", - ), - ), - ), - ), - ("COMPLIANCE_MENU", "subscription-set"): ( - Cell( - U, - "the CLI's own gate is none for a raw hand-set allowance (the form's menu " - "row itself says 'prefer attest'); an empty amount cancels", - ( - ( - "test_compliance_console", - "test_subscription_set_form_dispatches_to_the_subscription_service", - ), - ), - ), - ), - ("COMPLIANCE_MENU", "withdrawals-attest"): ( - Cell( - T, - "`--enabled` RELEASES a rail-17 entry halt and demands the CLI's own typed " - "gate (a typed `yes`) -- the same posture as `keel autonomy on`; a declined " - "gate writes not a single state row", - ( - ( - "test_compliance_console", - "test_withdrawals_enabled_requires_the_typed_gate_before_any_write", - ), - ( - "test_compliance_console", - "test_withdrawals_form_rejects_an_unrecognized_answer_without_writing", - ), - ( - "test_compliance_console", - "test_the_withdrawals_typed_gate_is_the_clis_own", - ), - ), - ), - Cell( - U, - "`--suspended` only ever REDUCES capability (it re-arms the halt) and is " - "ungated, exactly as the CLI's own asymmetry keeps it", - (("test_compliance_console", "test_withdrawals_suspended_is_ungated_like_the_cli"),), - ), - ), - # the scout browser's offered (never auto-run) attest step: same typed gate as above - ("scout-view", "a"): ( - Cell( - T, - "the scout-results browser OFFERS the attest step for a selected candidate; " - "accepting it runs the SAME typed asset-code form the Compliance menu runs " - "-- the proposer never decides, nothing attests without the human's phrase", - ( - ( - "test_compliance_console", - "test_scout_attest_step_uses_the_same_typed_attest_form", - ), - ( - "test_tui", - "test_run_live_scout_attest_key_drives_the_typed_form_end_to_end", - ), - ), - ), - ), - # -- the strategy console (C4) ----------------------------------------------------------------- - ("STRATEGY_MENU", "add"): ( - Cell( - U, - "`rules add` lands the row as CANDIDATE -- the gated step is promotion, not " - "creation -- and the form's validations are the add service's own; an empty " - "kind writes nothing", - (("test_strategy_console", "test_the_add_form_cancels_on_an_empty_kind"),), - ), - ), - ("STRATEGY_MENU", "retry"): ( - Cell( - C, - "after the re-backtest, the promote attempt asks an explicit y/N (the O3 " - "promote confirmation); a declined or refused promote promotes nothing", - ( - ( - "test_strategy_console", - "test_the_retry_form_rebacktests_and_asks_before_promoting", - ), - ( - "test_strategy_console", - "test_the_retry_promote_reports_the_machines_refusal_verbatim", - ), - ), - ), - Cell( - T, - "the `--force` bypass is offered only after a declined/refused promote and " - "runs ONLY behind the console's TYPED gate " - "(`clis_typed_promote_force_gate` -- the CLI's `--force` is a bare flag with " - "no gate of its own; the console is deliberately STRICTER, quoting the CLI's " - "own force warning over the shared typed-confirmation gate); a wrong phrase " - "writes nothing and the status does not move", - ( - ( - "test_strategy_console", - "test_the_retry_force_requires_the_typed_phrase_and_refuses_a_wrong_one", - ), - ( - "test_strategy_console", - "test_clis_typed_promote_force_gate_uses_the_clis_own_gate", - ), - ("test_strategy_console", "test_clis_typed_promote_force_gate_fails_closed"), - ), - ), - ), - ("STRATEGY_MENU", "simulate"): ( - Cell( - A, - "the simulate view opens ARMED showing the plan (the report path it will " - "write); Enter is the confirm step, the run fetches/writes exactly as `keel " - "simulate` does and blocks the loop, and its verdict is held", - ( - ( - "test_strategy_console", - "test_run_live_simulate_opens_armed_and_never_runs_the_service_until_enter", - ), - ( - "test_strategy_console", - "test_run_live_simulate_enter_runs_the_service_once_and_holds_the_result", - ), - ), - ), - ), - ("STRATEGY_MENU", "enable"): ( - Cell( - U, - "`rules enable` is the DOCUMENTED restore path and carries no CLI gate; it " - "restores at candidate (never the prior status) and refuses a rule that is " - "not disabled", - ( - ( - "test_strategy_console", - "test_the_enable_form_restores_a_disabled_rule_at_candidate", - ), - ( - "test_strategy_console", - "test_the_enable_form_refuses_a_rule_that_is_not_disabled", - ), - ), - ), - ), - ("STRATEGY_MENU", "disable"): ( - Cell( - U, - "disabling stops a rule from being evaluated -- the de-risking direction, " - "with no CLI gate; an empty id changes nothing", - (("test_console_ceremony", - "test_a_cancelled_form_writes_nothing_the_state_it_would_touch"),), - ), - ), - ("STRATEGY_MENU", "demote"): ( - Cell( - U, - "demotion moves a rule back down the lifecycle with the service's own " - "recorded context, no CLI gate; an empty id changes nothing", - (("test_console_ceremony", - "test_a_cancelled_form_writes_nothing_the_state_it_would_touch"),), - ), - ), - # -- the Trading menu (C5) --------------------------------------------------------------------- - ("TRADING_MENU", "cycle"): ( - Cell( - A, - "the agent-cycle view opens ARMED naming the profile, the mode's semantics, " - "the autonomy state and the session honesty; Enter runs ONE cycle through " - "`agent.run_once` with the CLI's own order-confirmation gate (in confirm " - "mode the y is asked MID-CYCLE, with curses suspended)", - ( - ( - "test_trading_console", - "test_run_live_the_cycle_entry_is_armed_until_enter", - ), - ( - "test_trading_console", - "test_run_agent_cycle_dispatches_to_run_once_with_the_clis_own_confirm_gate", - ), - ( - "test_trading_console", - "test_run_live_the_cycle_dispatch_suspends_curses_around_the_mid_cycle_confirm_gate", - ), - ), - ), - ), - ("TRADING_MENU", "monitor"): ( - Cell( - A, - "the monitor view opens ARMED; Enter runs ONE poll (record the session, " - "skip while closed, else fetch candles) -- read-only w.r.t. money", - ( - ( - "test_trading_console", - "test_run_live_the_monitor_poll_is_armed_until_enter", - ), - ), - ), - ), - ("TRADING_MENU", "autonomy"): ( - Cell( - T, - "the ON direction asks the CLI's OWN arm gate (a typed word) with no expiry " - "-- arming lets the agent place orders unattended, and a declined gate " - "writes nothing", - ( - ( - "test_trading_console", - "test_autonomy_on_requires_the_clis_typed_gate_and_writes_nothing_on_a_refusal", - ), - ( - "test_trading_console", - "test_the_autonomy_arm_gate_is_the_clis_own_extracted_gate", - ), - ), - ), - Cell( - U, - "the OFF direction only ever reduces capability and is ungated, exactly as " - "`keel autonomy off` is", - (("test_trading_console", "test_autonomy_off_is_ungated_and_immediate"),), - ), - ), - ("TRADING_MENU", "record-flow"): ( - Cell( - T, - "declaring a deposit/withdrawal rebases rail 11's equity memory -- typed, " - "with the RAW amount named in the action phrase; the gate runs BEFORE " - "validation, so a refusal writes nothing", - ( - ( - "test_trading_console", - "test_record_flow_form_gates_before_validating_and_writes_nothing_on_a_refusal", - ), - ( - "test_trading_console", - "test_the_record_flow_gate_carries_the_amount_in_its_action_phrase", - ), - ), - ), - ), - ("TRADING_MENU", "reset-hwm"): ( - Cell( - T, - "clearing the drawdown reference is typed (it re-arms what rail 11 will " - "allow); a declined gate leaves the mark untouched", - (("test_trading_console", "test_reset_hwm_form_refusal_writes_nothing"),), - ), - ), - ("TRADING_MENU", "resume-entries"): ( - Cell( - T, - "the ONLY early release of the consecutive-loss halt (rail 16) is typed; a " - "declined gate leaves the halt armed and the loss counter as it was", - ( - ( - "test_trading_console", - "test_resume_entries_form_refusal_writes_nothing_and_success_clears_rail_16", - ), - ( - "test_trading_console", - "test_run_live_resume_entries_refusal_writes_nothing_end_to_end", - ), - ), - ), - ), - ("TRADING_MENU", "kill"): ( - Cell( - U, - "kill is one key with NO ceremony BY ITS OWN CLI CONTRACT: engaging the " - "halt is the safe direction, dispatch is immediate, and the CLI's own line " - "toasts -- the console adds no ceremony and removes none", - ( - ( - "test_trading_console", - "test_kill_dispatches_immediately_and_toasts_the_clis_own_line", - ), - ( - "test_trading_console", - "test_run_live_kill_engages_from_the_menu_with_no_ceremony", - ), - ), - ), - ), - ("TRADING_MENU", "resume"): ( - Cell( - T, - "RELEASING the kill switch is typed (`keel resume`'s own gate); a declined " - "gate keeps the halt engaged -- proven end-to-end through the live loop", - ( - ( - "test_trading_console", - "test_resume_form_refusal_writes_nothing_and_the_success_line_is_the_clis", - ), - ( - "test_trading_console", - "test_run_live_resume_refusal_writes_nothing_end_to_end", - ), - ( - "test_trading_console", - "test_run_live_resume_with_the_typed_yes_releases_the_halt", - ), - ("test_trading_console", "test_the_resume_gate_is_the_clis_own"), - ), - ), - ), - # -- the Account menu (C6 read-only; #415 adds its ONE gated write) --------------------------- - ("ACCOUNT_MENU", "update"): ( - Cell( - T, - "the update view opens ARMED with the whole plan (current vs latest, the " - "production wheels, the Release/ dir, the .bak-before-* database " - "backups, the RUNNING venv); Enter is NOT enough -- the run demands the " - "CLI's OWN typed gate (`keel update`'s exact wording, which names the " - "version, the launch folder and that the running binary is replaced), " - "rendered at the terminal through the suspend/restore dance. The gate " - "runs INSIDE the service's confirm seam, so there is no ungated path to " - "the mutations; a wrong phrase, a no-TTY run or a decline writes " - "not one file and never relaunches", - ( - ( - "test_account_console", - "test_run_live_update_entry_opens_the_armed_view_and_never_runs_the_service", - ), - ( - "test_account_console", - "test_run_live_update_enter_runs_the_gate_at_the_terminal_and_a_refusal_writes_nothing", - ), - ( - "test_account_console", - "test_run_update_at_terminal_gates_then_runs_then_relaunches", - ), - ), - ), - ), - # -- the Data menu (C5) ------------------------------------------------------------------------ - ("DATA_MENU", "fetch"): ( - Cell( - A, - "the fetch view opens ARMED showing the plan (products x granularities x " - "window for the ACTIVE profile); Enter runs the fetch service itself, " - "blocking, with the CLI's streamed lines held", - ( - ( - "test_data_console", - "test_run_live_fetch_is_armed_until_enter_and_enter_runs_exactly_one", - ), - ), - ), - ), - ("DATA_MENU", "fetch-check"): ( - Cell( - A, - "the dry-run opens ARMED like the fetch (same seam, same Enter); the check " - "itself never touches the network and writes nothing", - ( - ( - "test_data_console", - "test_run_live_check_entry_runs_the_check_and_renders_the_verdict", - ), - ( - "test_data_console", - "test_the_armed_check_screen_says_it_never_touches_the_network", - ), - ), - ), - ), - ("DATA_MENU", "repair-gaps"): ( - Cell( - A, - "gap repair RE-REQUESTS history windows (it writes candles): ARMED with the " - "plan first, Enter confirms, per-series outcomes render after", - ( - ( - "test_data_console", - "test_run_live_repair_gaps_confirms_then_renders_per_series_outcomes", - ), - ), - ), - ), - ("DATA_MENU", "db-import"): ( - Cell( - U, - "`keel db import` is read-only w.r.t. the exchange and carries no CLI gate; " - "the form validates the path with the CLI's OWN check (its message, " - "verbatim) and an empty path imports nothing", - ( - ("test_data_console", "test_db_import_cancels_on_an_empty_path"), - ( - "test_data_console", - "test_run_live_db_import_form_runs_at_the_terminal", - ), - ), - ), - ), - # -- the dashboard's own write keys (pre-console, still the landing screen) -------------------- - ("normal", "a"): ( - Cell( - T, - "the dashboard's `a` toggles autonomy through `toggle_autonomy`, whose " - "OFF->ON direction runs the CLI's OWN typed arm gate (`_confirm_arm_" - "autonomy`, the same `_require_interactive_confirmation` wording); a " - "decline writes nothing", - ( - ("test_tui", "test_toggle_autonomy_off_to_on_declined"), - ("test_tui", "test_toggle_autonomy_on_to_off_is_immediate_and_ungated"), - ("test_tui", "test_confirm_arm_autonomy_true_on_typed_yes_and_restores_screen"), - ), - ), - ), - ("normal", "f"): ( - Cell( - U, - "`f` fetches candle history -- money-safe by construction (data only, never " - "an order), the dashboard's own pre-console key, ungated like `keel fetch`; " - "the console's own path to the SAME write is Data -> fetch, which IS gated " - "(ARMED; Enter is the confirm step)", - ( - ( - "test_data_console", - "test_run_live_fetch_is_armed_until_enter_and_enter_runs_exactly_one", - ), - ), - ), - ), -} - -#: The write paths that are NOT menu entries -- declared, so the coverage equality in -#: the teeth test cannot be satisfied by an accidental extra row for them either. -#: HOW TO KEEP THIS COMPLETE (the audit's own method): the non-menu writes are exactly -#: the KEY BRANCHES in `run_live` that dispatch work -- walk the loop's `ch ==`/`elif` -#: chain in every mode (the dashboard `normal` screen's keys, and per-mode keys such as -#: the scout browser's `a`) and list every branch whose body is not a pure view or -#: navigation action; re-walk the chain whenever a key branch is added, because a brand -#: new write key has no registry entry for the teeth test to derive and only a row here -#: classifies it. -EXTRA_KEYS: frozenset[tuple[str, str]] = frozenset( - { - ("scout-view", "a"), - ("normal", "a"), - ("normal", "f"), - } -) - - -# -- the teeth: derive the mutating keys from the dispatch registries ------------------------------ - - -def _mutating_keys() -> set[tuple[str, str]]: - """Every (registry, target) the console dispatches that is not declared read-only. - - The derivation is deliberately CONSERVATIVE: an entry is mutating unless its kind is - in its module's read-only set. A new kind value nobody classified is therefore - mutating, and a new mutating entry without a ceremony row fails the equality below - -- that failure is this suite's whole reason to exist.""" - keys: set[tuple[str, str]] = set() - # the shell: switching the binding is the one mutating action (the LIVE guard) - read_only_actions = { - "dashboard", "help", "placeholder", "compliance", "strategy", - "research", "trading", "data", "account", - } - for entry in console_mod.CONSOLE_MENU: - if entry.action not in read_only_actions: - keys.add(("CONSOLE_MENU", entry.action)) - # the sub-menus: kinds per module that provably only read - read_only_kinds = { - "COMPLIANCE_MENU": {"view", "scout"}, - "STRATEGY_MENU": {"view", "insights"}, - "TRADING_MENU": set(), # armed, form AND action all mutate - "DATA_MENU": {"view"}, - "ACCOUNT_MENU": {"view"}, - "HELP_MENU": {"view"}, - } - for registry, menu in ( - ("COMPLIANCE_MENU", compliance_console.COMPLIANCE_MENU), - ("STRATEGY_MENU", strategy_console.STRATEGY_MENU), - ("TRADING_MENU", trading_console.TRADING_MENU), - ("DATA_MENU", data_console.DATA_MENU), - ("ACCOUNT_MENU", account_console.ACCOUNT_MENU), - ("HELP_MENU", help_console.HELP_MENU), - ): - for entry in menu: - if getattr(entry, "kind", "view") not in read_only_kinds[registry]: - keys.add((registry, entry.target)) - return keys - - -def test_the_table_covers_every_mutating_action_the_registries_dispatch() -> None: - """THE COVERAGE PROOF: the table's keys are EXACTLY the registries' mutating keys - plus the dashboard's declared write keys -- no mutating action without a ceremony - row (a new one fails here until it is classified), and no stale row for an action - that no longer exists.""" - derived = _mutating_keys() | set(EXTRA_KEYS) - table = set(CEREMONY) - missing = derived - table - assert not missing, f"unclassified mutating actions: {sorted(missing)}" - stale = table - derived - assert not stale, f"ceremony rows for actions that no longer exist: {sorted(stale)}" - - -def test_every_row_names_a_real_ceremony_class() -> None: - for key, cells in sorted(CEREMONY.items()): - assert cells, key - for cell in cells: - assert cell.ceremony in CLASSES, (key, cell.ceremony) - assert cell.note.strip(), key - - -def test_every_named_proof_is_a_real_test_function() -> None: - """One source of truth: the table only NAMES proofs -- this asserts each named - (module, function) resolves to a real test in this suite's package, so a reference - cannot rot into a string that pins nothing.""" - for key, cells in sorted(CEREMONY.items()): - for cell in cells: - assert cell.proofs, (key, "a cell with no proof at all") - for module_name, function_name in cell.proofs: - module = importlib.import_module(f"tests.commands.{module_name}") - assert hasattr(module, function_name), (key, module_name, function_name) - - -def test_every_typed_row_says_the_prompt_is_not_pre_fillable() -> None: - """O3's contract stated on every typed row: the phrase is typed by the human, never - piped, pre-filled or bypassed (the help-text twin of this pin lives in - `test_help_console`). The check is on the note's substance: each typed cell's note - carries the failing-closed semantics.""" - for key, cells in sorted(CEREMONY.items()): - for cell in cells: - if cell.ceremony == T: - lowered = cell.note.lower() - assert any( - word in lowered - for word in ("typed", "phrase", "yes", "asset code", "gate") - ), (key, cell.note) - - -# -- the compact refusal proofs: the cells no existing test covered -------------------------------- -# -#: Every form row whose cancellation no existing test pins gets ONE generic proof in -#: `test_a_cancelled_form_writes_nothing_the_state_it_would_touch` below: run the form -#: with every prompt answered empty and assert the cancellation line AND a -#: byte-identical state. - - -@pytest.fixture -def repo() -> Repository: - conn = connect(":memory:") - migrate(conn) - return Repository(conn) - - -def _canceling_prompt() -> Callable[[str], str]: - def prompt(_text: str) -> str: - return "" - - return prompt - - -def _rules_state(repo: Repository) -> Any: - return repo.get_rules() - - -def _generic_refusal_rows( - repo: Repository, config: Any -) -> dict[tuple[str, str], tuple[str, Callable[[Repository], Any]]]: - """The form rows without an existing cancellation proof, each with the runner that - cancels on all-empty answers and the state its write would touch.""" - prompt = _canceling_prompt() - rows: dict[tuple[str, str], tuple[str, Callable[[Repository], Any]]] = { - ("COMPLIANCE_MENU", "attest"): ( - lambda: compliance_console.run_form("attest", repo, config, prompt, NOW_TS), - lambda r: r.get_asset_attestations(), - ), - ("COMPLIANCE_MENU", "attest-instrument"): ( - lambda: compliance_console.run_form( - "attest-instrument", repo, config, prompt, NOW_TS - ), - lambda r: r.get_instrument_attestations(), - ), - ("COMPLIANCE_MENU", "exempt"): ( - lambda: compliance_console.run_form("exempt", repo, config, prompt, NOW_TS), - lambda r: r.list_screen_exceptions(), - ), - ("COMPLIANCE_MENU", "unexempt"): ( - lambda: compliance_console.run_form("unexempt", repo, config, prompt, NOW_TS), - lambda r: r.list_screen_exceptions(), - ), - ("COMPLIANCE_MENU", "subscription-attest"): ( - lambda: compliance_console.run_form( - "subscription-attest", repo, config, prompt, NOW_TS - ), - lambda r: r.list_broker_subscriptions(), - ), - ("COMPLIANCE_MENU", "subscription-set"): ( - lambda: compliance_console.run_form( - "subscription-set", repo, config, prompt, NOW_TS - ), - lambda r: r.list_broker_subscriptions(), - ), - ("COMPLIANCE_MENU", "withdrawals-attest"): ( - lambda: compliance_console.run_form( - "withdrawals-attest", repo, config, prompt, NOW_TS - ), - lambda r: (r.get_state("withdrawals_enabled"), r.get_state("withdrawals_attested_at")), - ), - ("STRATEGY_MENU", "retry"): ( - lambda: strategy_console.run_retry_form(repo, config, prompt, NOW_TS), - _rules_state, - ), - ("STRATEGY_MENU", "enable"): ( - lambda: strategy_console.run_enable_form(repo, config, prompt, NOW_TS), - _rules_state, - ), - ("STRATEGY_MENU", "disable"): ( - lambda: strategy_console.run_disable_form(repo, config, prompt, NOW_TS), - _rules_state, - ), - ("STRATEGY_MENU", "demote"): ( - lambda: strategy_console.run_demote_form(repo, config, prompt, NOW_TS), - _rules_state, - ), - ("DATA_MENU", "db-import"): ( - lambda: data_console.run_db_import_form(repo, prompt), - lambda r: r.get_transactions(), - ), - } - return rows - - -def _audit_config() -> Any: - from decimal import Decimal - - from keel.config import ( - AutoTradeConfig, - Caps, - Config, - DcaConfig, - MarketDataConfig, - MoneyMgmtConfig, - ) - from keel.types import Granularity - - return Config( - allowlist=["BTC"], - target_weights={}, - risk_pct=Decimal("0.01"), - caps=Caps( - max_per_order_usd=Decimal("100000"), - max_per_day_usd=Decimal("300000"), - max_exposure_usd=Decimal("1000000"), - max_per_asset_pct=Decimal("1"), - ), - market_data=MarketDataConfig(granularities=[Granularity.ONE_HOUR], history_days=365), - auto_trade=AutoTradeConfig(mode="paper", interval_sec=900), - money_mgmt=MoneyMgmtConfig( - max_total_dd_pct=Decimal("0.20"), max_weekly_dd_pct=Decimal("0.08") - ), - dca=DcaConfig(budget_usd=Decimal("50"), cadence_days=7), - ) - - -def test_a_cancelled_form_writes_nothing_the_state_it_would_touch(repo: Repository) -> None: - """The threaded refusal invariant, compactly: every form row above cancels on empty - answers and leaves the state its write would touch byte-identical. The TYPED - refusals (a wrong phrase, a declined gate) are proven by the named per-row tests; - this is the cancel-path half for the rows no existing test pinned.""" - config = _audit_config() - for key, (runner, state) in sorted(_generic_refusal_rows(repo, config).items()): - before = state(repo) - result = runner() - lowered = result.lower() - assert "cancelled" in lowered or "nothing" in lowered, (key, result) - assert state(repo) == before, key - - -# -- the cross-cutting invariants ------------------------------------------------------------------ - - -def test_the_live_binding_moves_only_through_the_guards_switch() -> None: - """No key path can rebind the console: `ConsoleBinding.rebind` is called from - exactly ONE place -- `console.switch_profile`, the function whose LIVE arm consults - `confirm_fn` -- pinned structurally over the whole console layer (an AST scan: any - other `.rebind(` call site fails). Direct binding via the CLI's own flags remains - the wrapper's documented path, outside this code entirely.""" - import keel.commands as commands_pkg - - rebind_sites: list[str] = [] - for path in sorted(Path(commands_pkg.__path__[0]).glob("*.py")): - tree = ast.parse(path.read_text(encoding="utf-8")) - for node in ast.walk(tree): - if ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "rebind" - ): - # the one legitimate site: inside switch_profile itself - if path.name == "console.py": - owner = next( - ( - n.name - for n in ast.walk(tree) - if isinstance(n, ast.FunctionDef) - and n.lineno <= node.lineno <= n.end_lineno - and n.name == "switch_profile" - ), - None, - ) - if owner == "switch_profile": - continue - rebind_sites.append(f"{path.name}:{node.lineno}") - assert not rebind_sites, rebind_sites - # and the guarded function itself keeps the LIVE arm behind confirm_fn - source = inspect.getsource(console_mod.switch_profile) - assert "requires_confirmation and not confirm_fn()" in source - - -def test_no_console_action_originates_an_order_outside_the_agent_pipeline() -> None: - """The order path is exactly one: `agent.run_once`, the same function `keel agent` - runs, carrying the CLI's own confirm gate (`trading_console.run_agent_cycle`'s - default `run_fn`). The thinness pin's executor allowance admits ONLY the two READ - helpers -- asserted here by importing that suite's own allowlist, so the two pins - cannot drift apart.""" - import keel.agent - from tests.commands import test_console_thinness as thin - - executor_calls = { - callee for _stem, _fn, callee in thin.CALL_ALLOWLIST - if callee.startswith("keel.execution.executor.") - } - assert executor_calls == { - "keel.execution.executor._withdrawals_enabled", # the rail-17 state read - "keel.execution.executor._fetch_available_quote", # the display balance read - }, executor_calls - signature = inspect.signature(trading_console.run_agent_cycle) - assert signature.parameters["run_fn"].default is keel.agent.run_once - - -def _seam_runner_groups() -> list[tuple[str, ...]]: - """One entry per `_run_terminal_form(...)` call site in the console layer, in source - order: every dotted runner name (`.(...)`) reachable from the call's - runner argument -- named inline in a lambda, or inside the local helper def a bare - Name argument refers to (resolved within the call site's own enclosing function, so a - same-named def elsewhere is not picked up). - - DERIVED, never hand-listed: a form-runner added at the seam is covered the day it - lands (the hand-maintained tuple this replaces had already missed three of the - trading forms), and a seam call site whose runner the extraction cannot read yields - an EMPTY entry, which the canary test below fails loudly on.""" - from tests.commands import test_console_thinness as thin - - groups: list[tuple[str, ...]] = [] - for path in thin._console_module_paths(): - tree = ast.parse(Path(path).read_text(encoding="utf-8")) - all_defs = [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)] - sites = [ - node - for node in ast.walk(tree) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == "_run_terminal_form" - ] - for call in sorted(sites, key=lambda c: c.lineno): - names: set[str] = set() - for arg in call.args[1:]: - if isinstance(arg, ast.Lambda): - names |= _dotted_runner_calls(arg) - elif isinstance(arg, ast.Name): - enclosing = max( - (d for d in all_defs if d.lineno < call.lineno <= d.end_lineno), - key=lambda d: d.lineno, - default=None, - ) - if enclosing is None: - continue - for defn in all_defs: - if ( - defn.name == arg.id - and enclosing.lineno <= defn.lineno - and defn.end_lineno <= enclosing.end_lineno - ): - names |= _dotted_runner_calls(defn) - groups.append(tuple(sorted(names))) - return groups - - -def _dotted_runner_calls(node: ast.AST) -> set[str]: - """Every `x.y(...)` call inside `node` (a lambda body or a helper def handed to the - seam) -- the runner dispatches, as dotted names. Bare-name calls (`open_state()`, - `now_fn()`, ...) are plumbing, not runners, and are excluded by shape.""" - names: set[str] = set() - for sub in ast.walk(node): - if ( - isinstance(sub, ast.Call) - and isinstance(sub.func, ast.Attribute) - and isinstance(sub.func.value, ast.Name) - ): - names.add(f"{sub.func.value.id}.{sub.func.attr}") - return names - - -def test_the_seam_runner_derivation_covers_every_call_site() -> None: - """The canary for the derived runners: the console layer's `_run_terminal_form(` call - sites -- counted independently, by text -- are exactly one list entry each, and no - entry is empty. Without this, an extraction that silently matched nothing (an AST - change, a rename of the seam) would hand the suspend test an empty runner set and it - would pass vacuously; with it, every seam call site contributes the runners it - dispatches, or the suite says so.""" - import re - - from tests.commands import test_console_thinness as thin - - text_sites = 0 - for path in thin._console_module_paths(): - text = Path(path).read_text(encoding="utf-8") - text_sites += len(re.findall(r"_run_terminal_form\(", text)) - text_sites -= len(re.findall(r"def _run_terminal_form\(", text)) - groups = _seam_runner_groups() - assert text_sites == len(groups), (text_sites, groups) - assert all(groups), groups - - -def test_every_blocking_run_that_can_prompt_suspends_curses() -> None: - """Every terminal-prompting run in the live loop goes through the ONE shared - suspend/restore seam (`_run_terminal_form` -- `def_prog_mode` -> `endwin` -> the - run -> `reset_prog_mode` -> refresh), pinned structurally: no form-runner or cycle - dispatch inside `run_live` may execute outside a `_run_terminal_form(...)` call - span, or outside a helper def that the seam is handed. The runners scanned for are - DERIVED from the seam's own call sites (`_seam_runner_groups`), so a form-runner - added at the seam is covered without anyone remembering to extend a hand-listed - tuple. The end-to-end dance proofs are the named per-row tests (the cycle's - mid-flight gate, the db-import form, resume's typed yes).""" - source = inspect.getsource(run_live) - tree = ast.parse(source) - seam_calls: list[ast.Call] = [] - for node in ast.walk(tree): - if ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == "_run_terminal_form" - ): - seam_calls.append(node) - assert seam_calls, "the scan found no _run_terminal_form calls -- it has rotted" - - # The spans where a runner may legitimately appear: the seam call itself (a lambda - # argument), plus the local helper DEFS the seam is handed (resolved within the - # seam call's own enclosing function, so a same-named def elsewhere is not covered). - all_defs = [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)] - covered: list[tuple[int, int]] = [(c.lineno, c.end_lineno) for c in seam_calls] - for call in seam_calls: - enclosing = max( - (d for d in all_defs if d.lineno < call.lineno <= d.end_lineno), - key=lambda d: d.lineno, - default=None, - ) - if enclosing is None: - continue - for arg in call.args[1:]: - if isinstance(arg, ast.Name): - for defn in all_defs: - if defn.name == arg.id and enclosing.lineno <= defn.lineno: - covered.append((defn.lineno, defn.end_lineno)) - - runners = sorted({name for group in _seam_runner_groups() for name in group}) - assert runners, "the derivation found no runners -- it has rotted" - lines = source.splitlines() - for index, line in enumerate(lines, start=1): - for runner in runners: - # only actual CALLS (a docstring may name the seam without invoking it) - if runner + "(" in line and not any( - start <= index <= end for start, end in covered - ): - pytest.fail( - f"{runner} dispatched outside the suspend/restore seam " - f"(line {index}): {line.strip()}" - ) - # the seam itself keeps the dance -- a prompt must never render under curses - seam_source = inspect.getsource(run_live.__globals__["_run_terminal_form"]) - for call in ("def_prog_mode", "endwin", "reset_prog_mode"): - assert call in seam_source, call - - -def test_the_account_menu_contributes_exactly_one_mutating_action_the_typed_update() -> None: - """Through C6 the Account branch was read-only top to bottom (its own pin said - so). #415 adds its ONE write path: the update entry, kind "armed", whose run is - TYPED (the row above). pnl and versions stay views, and no OTHER mutating action - may appear in the branch without a ceremony row -- the teeth test surfaces it.""" - assert [(e.target, e.kind) for e in account_console.ACCOUNT_MENU] == [ - ("pnl", "view"), - ("versions", "view"), - ("update", "armed"), - ] - assert {key for key in _mutating_keys() if key[0] == "ACCOUNT_MENU"} == { - ("ACCOUNT_MENU", "update") - } diff --git a/tests/commands/test_console_thinness.py b/tests/commands/test_console_thinness.py index c34b9dd1..aa25f846 100644 --- a/tests/commands/test_console_thinness.py +++ b/tests/commands/test_console_thinness.py @@ -85,10 +85,17 @@ #: scanned: it is the command that binds the socket and launches a browser, which is service work #: and is exactly what Rule 5 says belongs outside this layer. def _console_module_paths() -> list[str]: - paths = [os.path.join(REPO_ROOT, "keel", "commands", name) for name in ("console.py", "tui.py")] - paths.extend(sorted(glob.glob(os.path.join(REPO_ROOT, "keel", "commands", "*console*.py")))) - paths.extend(sorted(glob.glob(os.path.join(REPO_ROOT, "keel", "web", "*.py")))) - return sorted(set(paths)) + """Every front-end module these rules hold over. + + **This used to be two globs and is now one (#541).** `keel/commands/console.py`, `tui.py` and + the seven `*console*.py` modules were deleted with the TUI -- they were reachable only from + inside it -- so what remains is the front-end that replaced them. The glob over `keel/web/` + was always here; it is the whole file set now. + + A glob that silently matched nothing would make every rule below vacuously green, which is + what `test_the_scan_actually_scanned_the_console_layer` exists to prevent, and why that test + names modules explicitly rather than counting them.""" + return sorted(set(glob.glob(os.path.join(REPO_ROOT, "keel", "web", "*.py")))) #: Rule 5's entry-scoped exceptions, in the same shape as every other allowance here: @@ -698,7 +705,14 @@ def test_the_scan_actually_scanned_the_console_layer() -> None: vacuously green.""" paths = _console_module_paths() stems = {os.path.splitext(os.path.basename(p))[0] for p in paths} - assert {"console", "tui"} <= stems + # `{"console", "tui"}` was asserted here until #541 deleted both, along with the seven + # `*console*` modules this pin was originally written for. The rules did not go with them: + # they hold over `keel/web/`, which is the front-end that replaced the console layer and has + # been inside this file set since #435. + assert "tui" not in stems and "console" not in stems, ( + "the console layer is back; if that is deliberate it needs its glob restored above, " + "because these rules would otherwise not be applied to it" + ) # #435: the web UI is a front-end over the same services and is pinned by the same rules. # Named explicitly so that deleting or renaming a web module fails HERE, loudly, rather than # quietly shrinking the scanned set and leaving the rules green over less code. @@ -721,15 +735,12 @@ def test_the_scan_actually_scanned_the_console_layer() -> None: # passing Rules 1-5 while silently losing the money-contract pin entirely. assert SERIALISER_STEMS <= stems assert any(os.path.join("keel", "web") in path for path in paths) - assert { - "compliance_console", - "strategy_console", - "research_console", - "trading_console", - "data_console", - "help_console", - "account_console", - } <= stems + # The seven `*console*` modules were named here, one per line, so that deleting one would + # fail HERE rather than quietly shrink the scanned set. #541 deleted all seven at once, and + # this is where that had to be acknowledged -- which is the mechanism working, not failing. + # They are asserted ABSENT now for the same reason `render` is: a module reappearing under a + # name these rules no longer glob would be unpinned code wearing a familiar filename. + assert not [stem for stem in stems if stem.endswith("_console")], sorted(stems) def test_every_allowance_names_a_real_callee() -> None: diff --git a/tests/commands/test_data_console.py b/tests/commands/test_data_console.py deleted file mode 100644 index 60534e30..00000000 --- a/tests/commands/test_data_console.py +++ /dev/null @@ -1,652 +0,0 @@ -"""Tests for `keel.commands.data_console` -- the Data menu (issue #391 C5; PRD §3's Data -branch). - -Five entries, all pinned here: - -* **fetch** -- an ARMED view that shows the PLAN (the products × granularities × window - the ACTIVE profile's config resolves to, and the db it warms) before anything runs; - Enter dispatches to `keel.commands.fetch.run_fetch` -- the SAME flow `keel fetch` runs - -- blocking like the CLI with the progress lines the CLI would have streamed HELD and - rendered, and the run's failure kept above nothing (an error never discards them). -* **fetch --check** -- its own ARMED entry: never opens a network connection, and the - exit verdict (the service's own `error` message) renders. -* **repair gaps** -- an ARMED confirm (it re-requests windows from the venue), then the - service run with the per-series outcomes rendered. -* **freshness overview** -- OFFLINE: the current assessment (`run_fetch(check=True)`, - the same sweep the CLI prints; a broker is never constructed), rebuilt per poll. -* **db import** -- a path form through the CLI's OWN import service (`import_dir`) and - output lines, with the CLI's own DIR_PATH validation errors surfaced verbatim. - -Mirrors `tests/commands/test_trading_console.py`'s fixture style. -""" - -from __future__ import annotations - -import sys -from decimal import Decimal -from typing import Any - -import pytest - -from keel.commands import data_console as dc -from keel.commands.fetch import FetchResult -from keel.config import ( - AutoTradeConfig, - Caps, - Config, - DcaConfig, - MarketDataConfig, - MoneyMgmtConfig, -) -from keel.data.db import connect, migrate -from keel.data.repository import Repository -from keel.types import Candle, Granularity - -NOW_TS = 1_800_000_000 -DAY = 86_400 - - -@pytest.fixture -def repo() -> Repository: - conn = connect(":memory:") - migrate(conn) - r = Repository(conn) - r.set_state("kill_switch", False) - return r - - -def _config(**overrides: Any) -> Config: - base: dict[str, Any] = dict( - allowlist=["BTC", "ETH"], - target_weights={}, - risk_pct=Decimal("0.01"), - caps=Caps( - max_per_order_usd=Decimal("100000"), - max_per_day_usd=Decimal("300000"), - max_exposure_usd=Decimal("1000000"), - max_per_asset_pct=Decimal("1"), - ), - market_data=MarketDataConfig( - granularities=[Granularity.ONE_HOUR, Granularity.ONE_DAY], history_days=365 - ), - auto_trade=AutoTradeConfig(mode="paper", interval_sec=900), - money_mgmt=MoneyMgmtConfig( - max_total_dd_pct=Decimal("0.20"), max_weekly_dd_pct=Decimal("0.08") - ), - dca=DcaConfig(budget_usd=Decimal("50"), cadence_days=7), - ) - base.update(overrides) - return Config(**base) - - -def _prompt(answers: list[str]) -> Any: - queue = iter(answers) - asked: list[str] = [] - - def fn(text: str) -> str: - asked.append(text) - return next(queue) - - fn.asked = asked - return fn - - -def _daily_candles(n: int, *, start: int = NOW_TS - 40 * DAY) -> list[Candle]: - return [ - Candle( - ts=start + i * DAY, - open=Decimal("100"), - high=Decimal("101"), - low=Decimal("99"), - close=Decimal("100"), - volume=Decimal("10"), - ) - for i in range(n) - ] - - -# -- the sub-menu (PRD §3's Data branch) ----------------------------------------------------------- - - -def test_the_data_menu_is_the_prd_data_branch() -> None: - assert [entry.label for entry in dc.DATA_MENU] == [ - "fetch", - "fetch --check", - "repair gaps", - "freshness overview", - "db import", - ] - - -def test_the_menu_screen_renders_every_entry_and_the_keys() -> None: - lines = dc.build_data_menu_lines(cursor=0) - texts = [line.text for line in lines] - for entry in dc.DATA_MENU: - assert any(entry.label in t for t in texts), entry.label - assert any("up/k down/j move" in t for t in texts) - marked = [t for t in texts if t.lstrip().startswith(">")] - assert len(marked) == 1 and "fetch" in marked[0] - - -def test_the_data_screens_fit_the_80_column_clip() -> None: - plan = dc.fetch_plan(_config(), "keel.db", "fetch") - screens = [ - dc.build_data_menu_lines(cursor=2), - dc.build_fetch_armed_lines(plan), - dc.build_fetch_armed_lines(dc.fetch_plan(_config(), "keel.db", "fetch-check")), - dc.build_fetch_armed_lines(dc.fetch_plan(_config(), "keel.db", "repair-gaps")), - dc.build_fetch_result_lines( - "fetch", - ( - "data cached in: keel.db", - " ok BTC-USD ONE_DAY n=40 0 bars behind, 0 internal gaps", - ), - error=None, - verdict=None, - ), - dc.build_freshness_lines( - (" MISSING ETH-USD ONE_HOUR n=0 nothing cached",) - ), - ] - for screen in screens: - for line in screen: - assert len(line.text) <= 80, line.text - - -def test_every_blocking_screen_discloses_what_ctrl_c_does() -> None: - """[review #405] A frozen screen is exactly where an operator reaches for Ctrl-C, - so every blocking surface -- the ARMED screens of all three variants and the held - results -- must state plainly, BEFORE the run, what it does: the whole console - exits (gracefully) and any held results are discarded.""" - screens = [ - dc.build_fetch_armed_lines(dc.fetch_plan(_config(), "keel.db", target)) - for target in ("fetch", "fetch-check", "repair-gaps") - ] - screens.append( - dc.build_fetch_result_lines( - "fetch", ("data cached in: keel.db",), error=None, verdict=None - ) - ) - for screen in screens: - joined = "\n".join(line.text for line in screen) - assert "Ctrl-C" in joined - assert "exits the whole console" in joined - assert "discards" in joined - - -def test_the_armed_footers_state_all_three_close_keys() -> None: - """[review #405] `m` is bound on every ARMED screen (the loop's close set is - q/Esc/m) -- the footer must say so, matching the result footers.""" - for target in ("fetch", "fetch-check", "repair-gaps"): - joined = "\n".join( - line.text - for line in dc.build_fetch_armed_lines( - dc.fetch_plan(_config(), "keel.db", target) - ) - ) - assert "q/Esc/m" in joined - assert "Press q or Esc" not in joined - - -# -- fetch: the ARMED plan and the run ------------------------------------------------------------- - - -def test_the_fetch_plan_names_products_granularities_window_and_db() -> None: - plan = dc.fetch_plan(_config(), "keel.db", "fetch") - assert plan.products == ("BTC-USD", "ETH-USD") # the allowlist, in quote currency - assert plan.granularities == (Granularity.ONE_HOUR, Granularity.ONE_DAY) - assert plan.years == 5 # `keel fetch --years`'s own default - assert plan.db_path == "keel.db" - assert plan.check is False - assert plan.repair_gaps is False - - -def test_the_armed_fetch_screen_shows_the_plan_before_anything_runs() -> None: - plan = dc.fetch_plan(_config(), "keel.db", "fetch") - joined = "\n".join(line.text for line in dc.build_fetch_armed_lines(plan)) - assert "ARMED" in joined - assert "BTC-USD" in joined and "ETH-USD" in joined - assert "ONE_HOUR" in joined and "ONE_DAY" in joined - assert "keel.db" in joined - assert "5y" in joined or "5 y" in joined or "years" in joined.lower() - - -def test_the_armed_check_screen_says_it_never_touches_the_network() -> None: - import re - - plan = dc.fetch_plan(_config(), "keel.db", "fetch-check") - joined = "\n".join(line.text for line in dc.build_fetch_armed_lines(plan)) - assert "ARMED" in joined - collapsed = re.sub(r"\s+", " ", joined.lower()) - assert "never opens a network connection" in collapsed - - -def test_the_armed_repair_screen_says_it_re_requests_windows() -> None: - plan = dc.fetch_plan(_config(), "keel.db", "repair-gaps") - joined = "\n".join(line.text for line in dc.build_fetch_armed_lines(plan)) - assert "ARMED" in joined - assert "re-request" in joined.lower() or "re-fetch" in joined.lower() or "window" in joined - - -def test_run_console_fetch_dispatches_to_the_services_flow( - repo: Repository, -) -> None: - """THE dispatch: `run_fetch` itself, with the CLI's own defaults (years=5, the - config's granularities, the default tolerance) and a LAZY broker factory -- `--check` - and the all-current skip must never construct one, exactly as the CLI's wrapper does.""" - calls: list[dict[str, Any]] = [] - - def spy_run(*args: Any, **kwargs: Any) -> FetchResult: - calls.append(kwargs) - return FetchResult() - - plan = dc.fetch_plan(_config(), "keel.db", "fetch") - dc.run_console_fetch( - repo, - _config(), - plan, - now_ts=NOW_TS, - build_client=lambda: "the-broker", - run_fn=spy_run, - ) - assert len(calls) == 1 - sent = calls[0] - assert sent["products"] == ["BTC-USD", "ETH-USD"] - assert sent["years"] == 5 - assert sent["check"] is False - assert sent["repair_gaps"] is False - assert sent["now_ts"] == NOW_TS - assert sent["db_path"] == "keel.db" - # the progress lines the service streams are collected for the held results screen - progress: list[str] = [] - dc.run_console_fetch( - repo, - _config(), - plan, - now_ts=NOW_TS, - build_client=lambda: "the-broker", - run_fn=lambda *a, **k: (calls.append(k), progress.append("data cached in: keel.db")) - and FetchResult(), - ) - assert progress == ["data cached in: keel.db"] - - -def test_the_fetch_results_hold_the_progress_lines_and_render_them() -> None: - lines = dc.build_fetch_result_lines( - "fetch", - ( - "data cached in: keel.db", - "", - "fetching...", - "", - "after fetch:", - " ok BTC-USD ONE_DAY n=40 0 bars behind, 0 internal gaps", - ), - error=None, - verdict=None, - ) - texts = [line.text for line in lines] - assert "data cached in: keel.db" in texts - # the freshness rows render VERBATIM -- their column alignment is part of what the - # CLI prints, not decoration to re-indent - assert ( - " ok BTC-USD ONE_DAY n=40 0 bars behind, 0 internal gaps" in texts - ) - - -def test_a_failed_fetch_keeps_its_progress_above_the_error() -> None: - lines = dc.build_fetch_result_lines( - "fetch", - ("data cached in: keel.db", "fetching..."), - error="connection reset", - verdict=None, - ) - texts = [line.text for line in lines] - error_idx = next(i for i, t in enumerate(texts) if "fetch failed" in t) - assert any("data cached in" in t for t in texts[:error_idx]) - - -def test_the_check_verdict_renders_the_services_own_error() -> None: - lines = dc.build_fetch_result_lines( - "fetch-check", - ("data cached in: keel.db", " MISSING BTC-USD ONE_HOUR n=0 nothing cached"), - error=None, - verdict="2 series missing or stale", - ) - joined = "\n".join(line.text for line in lines) - assert "2 series missing or stale" in joined - # the failing verdict is loud, the passing one is calm - passing = dc.build_fetch_result_lines( - "fetch-check", ("data cached in: keel.db",), error=None, verdict=None - ) - assert not any("fetch failed" in line.text for line in passing) - - -def test_the_check_verdict_footer_is_pinned_outside_the_scroll() -> None: - """The check verdict is the load-bearing fact of a check run: pinned under the body - (`compliance_console.pinned_frame` reserves it), so no scroll offset hides it.""" - failing = dc.check_verdict_footer("2 series missing or stale") - assert any("2 series missing or stale" in line.text for line in failing) - assert any(line.style == "alert" for line in failing) - passing = dc.check_verdict_footer(None) - assert passing == [] # a passing run's verdict already rides the service's own lines - - -# -- the freshness overview: offline, the current assessment --------------------------------------- - - -def test_the_freshness_overview_renders_the_current_assessment_offline( - repo: Repository, -) -> None: - repo.upsert_candles("BTC-USD", Granularity.ONE_DAY, _daily_candles(40)) - lines = dc.build_freshness_lines(dc.freshness_lines(repo, _config(), "keel.db", NOW_TS)) - joined = "\n".join(line.text for line in lines) - assert "BTC-USD" in joined - assert "ONE_DAY" in joined - # the empty series render too -- the sweep judges every (product, granularity) - assert "ETH-USD" in joined - assert "MISSING" in joined or "STALE" in joined - - -def test_the_freshness_read_never_constructs_a_broker(repo: Repository) -> None: - def _no_broker() -> Any: - raise AssertionError("the freshness overview must never construct a broker") - - lines = dc.freshness_lines(repo, _config(), "keel.db", NOW_TS, build_client=_no_broker) - assert lines # the sweep ran and answered without the factory ever being called - - -# -- db import: the path form ---------------------------------------------------------------------- - - -def _csv_dir(tmp_path: Any) -> Any: - directory = tmp_path / "transactions" - directory.mkdir() - (directory / "coinbase.csv").write_text( - "ID,Timestamp,Transaction Type,Asset,Quantity Transacted,Price Currency," - "Price at Transaction,Subtotal,Total (inclusive of fees and/or Spread)," - "Fees and/or Spread,Notes\n" - "TX1,2023-01-01 10:00:00 UTC,Buy,BTC,0.5,USD,20000,10000,10020,20,\n" - ) - return directory - - -def test_db_import_runs_the_services_import_and_renders_the_clis_output( - repo: Repository, tmp_path: Any -) -> None: - result = dc.run_db_import_form(repo, _prompt([str(_csv_dir(tmp_path))])) - assert result.startswith("imported=1 skipped=0") - assert len(repo.get_transactions()) == 1 - - -def test_db_import_surfaces_the_warnings_the_service_records( - repo: Repository, tmp_path: Any -) -> None: - directory = tmp_path / "exports" - directory.mkdir() - (directory / "unknown.csv").write_text("some,unrelated,header\n1,2,3\n") - result = dc.run_db_import_form(repo, _prompt([str(directory)])) - assert "imported=0" in result - assert "warning:" in result - assert "could not detect a known CSV header shape" in result - - -def test_db_import_validation_errors_surface_verbatim( - repo: Repository, tmp_path: Any -) -> None: - """A bad path is refused with the CLI's OWN message -- pinned byte-for-byte against - what `keel db import ` actually prints, so the two front-ends can never - grow two wordings for one refusal.""" - from click.testing import CliRunner - - import keel.cli as cli_module - - spy = _RecordingRepo(repo) - bad = str(tmp_path / "no-such-dir") - result = dc.run_db_import_form(spy, _prompt([bad])) - assert spy.calls == [] # the import service was never handed a nonexistent dir - assert "does not exist" in result - - cli_out = CliRunner().invoke(cli_module.cli, ["db", "import", bad]).output - cli_error_line = next(line for line in cli_out.splitlines() if line.startswith("Error:")) - # the console's refusal IS the CLI's refusal, byte for byte - assert result == cli_error_line - - -def test_db_import_cancels_on_an_empty_path(repo: Repository) -> None: - spy = _RecordingRepo(repo) - result = dc.run_db_import_form(spy, _prompt([""])) - assert spy.calls == [] - assert "cancelled" in result.lower() - - -class _RecordingRepo: - """A real `Repository` wrapped so every WRITE is recorded -- proves a refused import - never reaches the service. Reads fall through.""" - - def __init__(self, inner: Repository) -> None: - self._inner = inner - self.calls: list[tuple[str, dict[str, Any]]] = [] - - def __getattr__(self, name: str) -> Any: - return getattr(self._inner, name) - - def upsert_transaction(self, tx: dict[str, Any]) -> None: - self.calls.append(("upsert_transaction", {"tx": tx})) - self._inner.upsert_transaction(tx) - - -# -- the loop wiring (fake curses): ARMED gating --------------------------------------------------- - - -def _fake_curses_mod(monkeypatch: pytest.MonkeyPatch, stdscr: Any) -> Any: - from tests.commands.test_tui import _fake_curses - - fake = _fake_curses() - fake.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake) - return fake - - -def _binding(repo: Repository, config: Config) -> Any: - import click - - from keel.commands.console import ConsoleBinding - - ctx = click.Context( - click.Command("tui"), obj={"config_path": "config.yaml", "db_path": "keel.db"} - ) - binding = ConsoleBinding(ctx, config_path="config.yaml", db_path="keel.db") - binding.open_state = lambda: (repo, config) # type: ignore[method-assign] - return binding - - -def _drive( - repo: Repository, - config: Config, - keys: list[int], - monkeypatch: pytest.MonkeyPatch | None = None, -) -> Any: - from keel.commands import tui as tui_mod - from tests.commands.test_tui import _KeySequenceStdscr - - stdscr = _KeySequenceStdscr(height=30, width=120, keys=keys) - if monkeypatch is not None: - _fake_curses_mod(monkeypatch, stdscr) - binding = _binding(repo, config) - tui_mod.run_live(binding.open_state, lambda: NOW_TS, interval=0.01, console_binding=binding) - return stdscr - - -def test_run_live_data_menu_opens_from_the_console_menu_and_esc_steps_back( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - stdscr = _drive(repo, _config(), [ord("m"), ord("6"), -1, 27, -1, 27], monkeypatch) - painted = [call[2] for call in stdscr.calls] - data_idx = next(i for i, t in enumerate(painted) if "keel console -- data" in t) - data_text = "\n".join(painted[data_idx:]) - for label in ("fetch", "fetch --check", "repair gaps", "freshness overview", "db import"): - assert label in data_text, label - assert any("keel console -- menu" in t for t in painted[data_idx:]) - - -def test_run_live_fetch_is_armed_until_enter_and_enter_runs_exactly_one( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """THE ARMED gate: opening Data -> fetch, polling, and closing fire NOTHING; Enter - fires EXACTLY ONE service run; the progress lines it streamed are held and painted.""" - runs: list[dict[str, Any]] = [] - - def spy_run(*args: Any, **kwargs: Any) -> FetchResult: - runs.append(kwargs) - sink = kwargs["progress"].append # what the dispatch wires as the echo stream - sink("data cached in: keel.db") - sink("fetching...") - sink("after fetch:") - return FetchResult() - - monkeypatch.setattr(dc, "run_console_fetch", spy_run) - - # m; 6 -> Data; 1 -> fetch (ARMED); poll; Esc; q -- nothing ran. - stdscr = _drive( - repo, _config(), [ord("m"), ord("6"), ord("1"), -1, 27], monkeypatch - ) - assert runs == [] - assert any("ARMED" in call[2] for call in stdscr.calls) - - # ...and Enter runs exactly one fetch, holding the progress lines. - stdscr = _drive( - repo, _config(), [ord("m"), ord("6"), ord("1"), 10, -1, 27], monkeypatch - ) - assert len(runs) == 1 - painted = "\n".join(call[2] for call in stdscr.calls) - assert "data cached in: keel.db" in painted - assert "after fetch:" in painted - - -def test_run_live_check_entry_runs_the_check_and_renders_the_verdict( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - def spy_run(*args: Any, **kwargs: Any) -> FetchResult: - sink = kwargs["progress"].append # what the dispatch wires as the echo stream - sink("data cached in: keel.db") - assert args[2].check is True # the plan the loop built for THIS entry - return FetchResult(error="2 series missing or stale") - - monkeypatch.setattr(dc, "run_console_fetch", spy_run) - # m; 6 -> Data; 2 -> fetch --check; Enter RUNS; poll; Esc; q. - stdscr = _drive( - repo, _config(), [ord("m"), ord("6"), ord("2"), 10, -1, 27], monkeypatch - ) - painted = "\n".join(call[2] for call in stdscr.calls) - assert "2 series missing or stale" in painted - - -def test_run_live_repair_gaps_confirms_then_renders_per_series_outcomes( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - def spy_run(*args: Any, **kwargs: Any) -> FetchResult: - assert args[2].repair_gaps is True # the plan the loop built for THIS entry - sink = kwargs["progress"].append # what the dispatch wires as the echo stream - sink("repairing interior gaps...") - sink( - " BTC-USD ONE_HOUR windows=2 probed=2 skipped=0 recovered=40 " - "absent_at_source=0" - ) - return FetchResult() - - monkeypatch.setattr(dc, "run_console_fetch", spy_run) - # m; 6 -> Data; 3 -> repair gaps (ARMED); Enter RUNS; poll; Esc; q. - stdscr = _drive( - repo, _config(), [ord("m"), ord("6"), ord("3"), 10, -1, 27], monkeypatch - ) - painted = "\n".join(call[2] for call in stdscr.calls) - assert "repairing interior gaps..." in painted - assert "recovered=40" in painted - - -def test_run_live_the_freshness_overview_is_offline_and_per_poll( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """The freshness view reads the CURRENT assessment each poll and never constructs a - broker -- the offline views' contract, proven end-to-end through the loop.""" - monkeypatch.setattr( - "keel.commands._common._build_broker", - lambda *a, **k: (_ for _ in ()).throw( - AssertionError("the freshness view must never construct a broker") - ), - ) - repo.upsert_candles("BTC-USD", Granularity.ONE_DAY, _daily_candles(40)) - # m; 6 -> Data; 4 -> freshness overview; poll; Esc; q. - stdscr = _drive( - repo, _config(), [ord("m"), ord("6"), ord("4"), -1, 27], monkeypatch - ) - painted = "\n".join(call[2] for call in stdscr.calls) - assert "BTC-USD" in painted - assert "ONE_DAY" in painted - - -def test_run_live_db_import_form_runs_at_the_terminal( - repo: Repository, tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - directory = _csv_dir(tmp_path) - import click - - answers = iter([str(directory)]) - monkeypatch.setattr( - click, "prompt", lambda text, **kw: next(answers), raising=True - ) - # m; 6 -> Data; 5 -> db import (the form runs at the terminal); poll; q. - stdscr = _drive( - repo, _config(), [ord("m"), ord("6"), ord("5"), -1, ord("q")], monkeypatch - ) - painted = "\n".join(call[2] for call in stdscr.calls) - assert "imported=1 skipped=0" in painted - assert len(repo.get_transactions()) == 1 - fake_curses = sys.modules["curses"] - assert "def_prog_mode" in fake_curses.calls - assert "reset_prog_mode" in fake_curses.calls - - -def test_run_live_the_data_menu_scrolls_banner_aware( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - from tests.commands.test_tui import _fake_curses - - fake = _fake_curses() - # m; 6 -> Data; End; poll; q. - stdscr = _drive( - repo, _config(), [ord("m"), ord("6"), fake.KEY_END, -1, ord("q")], monkeypatch - ) - painted = [call[2] for call in stdscr.calls] - data_idx = next(i for i, t in enumerate(painted) if "keel console -- data" in t) - assert any("db import" in t for t in painted[data_idx:]) - - -def test_run_live_re_entering_the_data_menu_resets_the_cursor_to_the_top( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """[review #405] The same shared reset point the Trading menu keeps: a sub-menu - cursor must never survive a re-entry. Leave Data with the row on db import (the - terminal FORM), re-enter, and the top row is marked again -- the replayed Enter - opens the fetch ARMED view instead of running the form.""" - from tests.commands.test_tui import _fake_curses - - down = _fake_curses().KEY_DOWN - # m -> menu; 6 -> Data; j x4 -> cursor on db import (row 5); q -> menu; 6 -> - # RE-ENTER Data (cursor must reset); poll; Enter -> the TOP entry (fetch, ARMED); - # poll; Esc; then out and quit. - stdscr = _drive( - repo, - _config(), - [ord("m"), ord("6"), *([down] * 4), ord("q"), ord("6"), -1, 10, -1, 27], - monkeypatch, - ) - painted = [call[2] for call in stdscr.calls] - marked = [(i, t) for i, t in enumerate(painted) if t.startswith(">")] - import_marked = [i for i, t in marked if "db import" in t] - fetch_marked = [i for i, t in marked if "fetch" in t] - assert import_marked # the cursor really did reach db import before leaving - assert fetch_marked - assert max(fetch_marked) > max(import_marked) # back on the TOP row after re-entry - assert any("ARMED -- nothing has run yet." in t for t in painted[max(import_marked) :]) diff --git a/tests/commands/test_help_console.py b/tests/commands/test_help_console.py deleted file mode 100644 index de829323..00000000 --- a/tests/commands/test_help_console.py +++ /dev/null @@ -1,444 +0,0 @@ -"""Tests for `keel.commands.help_console` -- the O8 help & glossary system (issue #394 C7). - -Three surfaces, pinned here: - -* **The glossary** -- `docs/glossary.md` is the ONE hand-written home for console term - definitions. The TUI help renders it (bounded read, mtime-cached like the research - readers), the fiqh terms' definitions are ANCHORED to `docs/fiqh-basis.md` (verbatim - quotes, or an honest "not stated" where the document is silent, exactly as C3's shariah - screen handles gharar), and no other surface defines console terms (the shariah screen's - vocabulary is pinned BYTE-EQUAL to the glossary here, so the two cannot drift). -* **Parameter help** -- the help system does NOT duplicate `describe_params`: it renders - that service's own doc strings, types, defaults and choices for every rule kind. -* **The contextual help registry** -- every console mode contributes "what am I looking - at" / "what will this do" text from the module that owns the screen, reachable with `?` - from every mode, and the typed actions' help says the prompt cannot be pre-filled. -""" - -from __future__ import annotations - -import ast -import inspect -import re -from pathlib import Path - -from keel import agent -from keel.commands import compliance_console as cc -from keel.commands import help_console as hc -from keel.commands import tui -from keel.commands.rules import describe_params - -_REPO_ROOT = Path(__file__).resolve().parents[2] -_FIQH_BASIS = (_REPO_ROOT / "docs" / "fiqh-basis.md").read_text(encoding="utf-8") - -#: The console's needed vocabulary, as the mission statement of this slice names it: what -#: the screens render, what the gates demand, what the fiqh terms mean, what the data -#: concepts are. A term missing from the glossary is a screen a newcomer cannot read. -_NEEDED_TERMS = { - "rail", - "attestation", - "exemption", - "screening", - "promotion gate", - "paper mode", - "live mode", - "kill switch", - "autonomy", - "qabd", - "riba", - "gharar", - "maysir", - "purification", - "session-bound venue", - "market clock", - "trust window", - "DCA benchmark", - "granularity", - "trials ledger", -} - - -def _terms() -> dict[str, hc.GlossaryTerm]: - return {t.term: t for t in hc.load_glossary()} - - -# -- the glossary: ONE source, every needed term -------------------------------------------------- - - -def test_the_glossary_file_exists_and_defines_every_needed_term_once() -> None: - terms = hc.load_glossary() - names = [t.term for t in terms] - assert len(set(names)) == len(names), "duplicate glossary terms" - missing = _NEEDED_TERMS - set(names) - assert not missing, missing - # and it is THE file: the loader's default path is the repo's docs/glossary.md - assert hc.GLOSSARY_PATH.name == "glossary.md" - assert hc.GLOSSARY_PATH.is_file() - - -def test_every_glossary_definition_is_nonempty_and_points_at_a_source() -> None: - for term in hc.load_glossary(): - assert term.definition.strip(), term.term - assert term.source.strip(), term.term - assert term.citation is None or term.citation in _FIQH_BASIS, term.term - - -def test_fiqh_definitions_are_anchored_verbatim_to_fiqh_basis() -> None: - """The anchoring rule: a fiqh term's definition is a VERBATIM passage of - docs/fiqh-basis.md (whitespace-normalized), carrying the document's own section as its - citation -- never a help-authored summary that could drift from the document.""" - squashed = " ".join(_FIQH_BASIS.split()) - for name in ( - "qabd", - "riba", - "maisir", - "purification", - "attestation", - "exemption", - "screening", - "instrument attestation", - ): - term = _terms()[name] - assert term.fiqh, name - assert term.stated, name - assert term.citation in _FIQH_BASIS, name - assert " ".join(term.definition.split()) in squashed, name - - -def test_two_fiqh_definitions_spot_pinned_word_for_word() -> None: - """Two spot-pins, word for word, from the document itself -- the two-sided pin style - of `tests/test_fiqh_basis.py`: the sentence must be IN fiqh-basis.md and the glossary - must carry it unchanged.""" - qabd = _terms()["qabd"] - assert qabd.definition == ( - "possession is the ability to dispose, not physical custody" - ) - riba = _terms()["riba"] - assert riba.definition == ( - "Coinbase pays USDC rewards on idle balances, that interest is riba, and it " - "accrues with no order placed" - ) - squashed = " ".join(_FIQH_BASIS.split()) - for definition in (qabd.definition, riba.definition): - assert definition in squashed # squashed: the doc hard-wraps mid-sentence - - -def test_gharar_is_honestly_not_stated_in_fiqh_basis_like_c3_rendered_it() -> None: - gharar = _terms()["gharar"] - assert gharar.fiqh - assert not gharar.stated - assert "not stated" in gharar.definition.lower() - - -def test_the_rail_entry_counts_eighteen_and_cites_the_table_honestly() -> None: - """[review #406] SAFETY-CRITICAL honesty pin. The glossary is the console's single - source of vocabulary, and this entry claimed NINETEEN rails while both authorities - say eighteen -- `keel/execution/guards.py` ("eighteen in all, since there is no - rail 15") and docs/fiqh-basis.md ("Eighteen rails exist (1-14, 16, 17, 18, 19 -- - there is no rail 15)"). The Source line must not overclaim either: the fiqh-basis - rails TABLE enumerates the prudential rails (2-14, 16); rails 1, 17, 18 and 19 are - stated in that document's own prose sections, and the citation says so.""" - rail = _terms()["rail"] - definition = " ".join(rail.definition.split()) - assert "Eighteen exist" in definition - assert "1-14, 16, 17, 18, 19" in definition - assert "nineteen" not in definition.lower() - source = " ".join(rail.source.split()) - assert "nineteen" not in source.lower() - # the honest split: the table's coverage is named as the prudential rails, and the - # prose-only rails are named as prose -- not folded into "the table enumerates all". - assert "2-14" in source - assert "prose" in source - - -def test_the_promotion_gate_entry_states_the_real_gate() -> None: - """[review #406] SAFETY-CRITICAL honesty pin. The entry previously invented a - DCA-benchmark floor (there is none in the promote path -- the DCA comparison lives - in the simulate report), hid two of the four real floors, and described pooling as - kind-wide when `keel/strategy/promotion.py` pools per parameter SET, on the - sample-size axis only, with the overfitting gate explicitly NOT pooled.""" - gate = _terms()["promotion gate"] - d = " ".join(gate.definition.split()).lower() - # the four performance floors, by their own names - for floor in ("min_trades", "min_expectancy", "min_rr", "min_win_rate"): - assert floor in d, floor - # the overfitting gate is the PBO/degradation-slope CONJUNCTION, not a bare bound - assert "pbo" in d - assert "degradation slope" in d - # pooling's real scope, in promotion.py's own docstring wording - assert "parameter set" in d - assert "sample-size" in d - assert "not pooled" in d - # the DCA comparison lives in the simulate report, and is not a floor of this gate - assert "dca" in d - assert "simulate" in d - assert "not a floor" in d - - -def test_the_glossary_agrees_with_the_shariah_screens_vocabulary() -> None: - """No drifted duplicates: C3's shariah screen renders its vocabulary from its own - anchored quotes; every one of those terms must carry the SAME definition in the ONE - glossary, pinned here so neither surface can quietly redefine a fiqh term.""" - terms = _terms() - for term in cc.VOCABULARY: - assert term.term in terms, term.term - assert " ".join(terms[term.term].definition.split()) == " ".join( - term.definition.split() - ), term.term - - -def test_the_glossary_read_is_bounded() -> None: - """The glossary rides the SAME bounded reader the research corpus uses -- a runaway - writer's megabyte file can never make the help screen read it whole (the bound itself - is pinned by the research readers' own tests; this pins that the glossary uses it).""" - assert hc.MAX_GLOSSARY_BYTES == 1024 * 1024 - - -def test_the_glossary_is_cached_per_mtime(tmp_path: Path) -> None: - """Repaints do not re-read an unchanged file; a changed mtime refreshes -- the - research doc-view cache's contract, applied to the glossary.""" - path = tmp_path / "glossary.md" - path.write_text( - "## rail\none of keel's per-order guards\nSource: keel's own vocabulary\n", - encoding="utf-8", - ) - cache: dict[tuple[str, int], list[hc.GlossaryTerm]] = {} - first = hc.cached_glossary(path, cache) - assert [t.term for t in first] == ["rail"] - # an unchanged mtime re-uses the cache (identity, not equality) - assert hc.cached_glossary(path, cache) is first - # a rewrite (newer mtime) refreshes - path.write_text( - "## rail\none of keel's per-order guards\nSource: keel's own vocabulary\n\n" - "## qabd\npossession is the ability to dispose, not physical custody\n" - 'Source: docs/fiqh-basis.md -- "### Rail 17"\n', - encoding="utf-8", - ) - stamp = path.stat() - import os - - os.utime(path, ns=(stamp.st_atime_ns, stamp.st_mtime_ns + 1_000_000)) - second = hc.cached_glossary(path, cache) - assert [t.term for t in second] == ["rail", "qabd"] - - -def test_parse_glossary_is_pure_over_text() -> None: - terms = hc.parse_glossary( - "# The keel glossary\n\nintro prose is ignored.\n\n" - "## rail\none of keel's per-order guards.\nSource: keel's own vocabulary\n\n" - "## qabd\npossession is the ability to dispose,\nnot physical custody.\n" - "Source: docs/fiqh-basis.md -- \"### Rail 17 -- withdrawal capability\"\n" - ) - assert [t.term for t in terms] == ["rail", "qabd"] - assert terms[0].definition == "one of keel's per-order guards." - assert not terms[0].fiqh - # a wrapped definition is joined; a fiqh-basis source anchors the term - assert terms[1].definition == ( - "possession is the ability to dispose, not physical custody." - ) - assert terms[1].fiqh - assert terms[1].citation == "### Rail 17 -- withdrawal capability" - - -def test_an_absent_glossary_renders_a_calm_empty_state(tmp_path: Path) -> None: - """A deployment runs from an installed wheel with no docs/ checkout -- the help screen - must say the glossary file is absent and where it lives, never traceback.""" - missing = tmp_path / "no-such-glossary.md" - lines = hc.build_glossary_lines(hc.load_glossary(missing), path=missing) - joined = " ".join(" ".join(line.text for line in lines).split()) - assert "glossary" in joined.lower() - # A long path wraps mid-word (no hyphen), so the containment check concatenates - # the wrapped rows with NO separator: the wrap is layout, not content. - assert str(missing) in "".join(line.text for line in lines) - assert all(len(line.text) <= 80 for line in lines) - - -def test_the_glossary_view_renders_every_term_wrapped_to_the_budget() -> None: - lines = hc.build_glossary_lines(hc.load_glossary()) - joined = "\n".join(line.text for line in lines) - for term in hc.load_glossary(): - assert term.term in joined, term.term - assert all(len(line.text) <= 80 for line in lines) - - -# -- parameter help: LINKS to describe_params, never duplicates ----------------------------------- - - -def test_params_help_renders_describe_params_own_doc_strings() -> None: - """The O8 single-source rule, pinned: the help view for a kind carries - `describe_params`' ACTUAL doc strings, defaults, types and choices -- introspected - from the rule classes. A duplicated table would fail the moment a class changed.""" - lines = hc.build_params_help_lines("turtle_breakout") - squashed = " ".join(" ".join(line.text for line in lines).split()) - params = describe_params("turtle_breakout") - assert set(params) == { - "granularity", - "entry_lookback", - "exit_lookback", - "adx_period", - "adx_threshold", - "atr_period", - "atr_stop_mult", - "use_macd_confirm", - "s1_filter", - "min_volume_filter", - "volume_ma_period", - "volume_mult", - "target_rr", - } - for name, help_ in params.items(): - assert name in squashed, name - assert " ".join(help_.doc.split()) in squashed, name - assert help_.type_name in squashed or name == "granularity", name - - -def test_params_help_names_its_single_source() -> None: - joined = " ".join( - " ".join(line.text for line in hc.build_params_help_lines("dca")).split() - ) - assert "describe_params" in joined or "the rule classes" in joined - - -def test_params_kinds_view_lists_every_rule_kind() -> None: - lines = hc.build_params_kinds_lines() - joined = "\n".join(line.text for line in lines) - for kind in sorted(agent.RULE_REGISTRY): - assert kind in joined, kind - assert all(len(line.text) <= 80 for line in lines) - - -def test_params_help_for_an_unknown_kind_is_a_calm_refusal() -> None: - lines = hc.build_params_help_lines("no_such_kind") - joined = "\n".join(line.text for line in lines) - assert "unknown rule kind" in joined - assert all(len(line.text) <= 80 for line in lines) - - -# -- the contextual help registry (O8) ------------------------------------------------------------ - - -def test_every_console_mode_contributes_contextual_help() -> None: - """`?` is reachable from EVERY console mode and renders that mode's own contribution: - the registry is closed (a mode without an entry fails here, not silently in the TUI), - each entry is one subject plus a plain-English description, and the rendering fits the - 80-column budget.""" - assert hc.CONSOLE_MODES, "the registry is empty" - for mode in hc.CONSOLE_MODES: - entries = hc.contextual_help(mode) - assert entries, mode - assert all(e.subject.strip() and e.description.strip() for e in entries), mode - lines = hc.build_context_help_lines(mode) - assert len(lines) > 1, mode - assert all(len(line.text) <= 80 for line in lines), mode - squashed = " ".join(" ".join(row.text for row in lines).split()) - for entry in entries: - assert entry.subject in squashed, (mode, entry.subject) - - -def test_the_registry_covers_every_mode_the_live_loop_dispatches_on() -> None: - """No dead key and no dead registry row: every `mode ==` literal in `run_live`'s - dispatch is in the registry (or is the overlay/help machinery itself), and every - registry key is a real mode.""" - source = inspect.getsource(tui.run_live) - dispatched = set(re.findall(r'mode == "([a-z-]+)"', source)) - unregistered = dispatched - set(hc.CONSOLE_MODES) - assert not unregistered, unregistered - unknown = set(hc.CONSOLE_MODES) - dispatched - assert not unknown, unknown - - -def _dispatched_modes(test: ast.expr) -> list[str]: - """The `mode == "..."` constants an `if` test carries (`mode == "x" and guard` - included) -- an `if` whose test names none is not a dispatch branch. PURE.""" - if ( - isinstance(test, ast.Compare) - and isinstance(test.left, ast.Name) - and test.left.id == "mode" - and len(test.ops) == 1 - and isinstance(test.ops[0], ast.Eq) - and len(test.comparators) == 1 - and isinstance(test.comparators[0], ast.Constant) - and isinstance(test.comparators[0].value, str) - ): - return [test.comparators[0].value] - if isinstance(test, ast.BoolOp): - return [m for value in test.values for m in _dispatched_modes(value)] - return [] - - -def test_the_question_key_is_handled_in_every_console_mode_branch() -> None: - """The `?` overlay is reachable from EVERY console mode -- pinned structurally: each - `mode ==` branch of `run_live` must handle `ord("?")` (open the overlay, or close it - for the overlay's own mode), so a future mode cannot silently ship without it. - - [review #406] The extraction is AST-based, and each `If` node's TRUE span - (`lineno`..`end_lineno`) is the block. The previous regex scan never stopped at a - dedent, so the last block silently absorbed every line after it -- including the - normal mode's own `?` handler -- and a mode that shipped without the key (discover, - in that review) passed this test dishonestly. Nothing outside a branch's own span - can satisfy the assertion now.""" - source = inspect.getsource(tui.run_live) - lines = source.splitlines() - blocks: list[tuple[str, list[str]]] = [] - for node in ast.walk(ast.parse(source)): - if isinstance(node, ast.If): - for mode in _dispatched_modes(node.test): - blocks.append((mode, lines[node.lineno - 1 : node.end_lineno])) - assert len(blocks) >= 20, "the scan found no dispatch blocks -- it has rotted" - for mode, body in blocks: - assert any('ord("?")' in line for line in body), mode - - -def test_the_typed_actions_help_says_the_prompt_cannot_be_pre_filled() -> None: - """O3's contract stated in O8's words: every typed action's help text says explicitly - that the prompt cannot be pre-filled -- the console never pipes, pre-fills or - bypasses.""" - joined = " ".join( - entry.description for mode in hc.CONSOLE_MODES for entry in hc.contextual_help(mode) - ) - assert joined.count("cannot be pre-filled") >= 4, joined - # and specifically on the screens that carry the typed gates - for mode in ("trading", "compliance", "strategy"): - mode_text = " ".join(e.description for e in hc.contextual_help(mode)) - assert "cannot be pre-filled" in mode_text, mode - - -def test_record_flow_and_reset_hwm_help_disclose_their_own_typed_gates() -> None: - """[review #406] record-flow and reset-hwm ARE typed gates in the CLI - (`_require_interactive_confirmation`, exactly like resume/resume-entries), so their - help row must scope the typed disclosure to ALL three actions it names -- not leave - it attached to resume-entries alone, which read as though the other two could be - pre-filled.""" - row = next( - e - for e in hc.contextual_help("trading") - if e.subject == "resume-entries, reset-hwm, record-flow" - ) - lowered = row.description.lower() - assert "cannot be pre-filled" in lowered - # the disclosure LEADS the row, before any per-action sentence, so it reads as the - # rule for all three -- not as resume-entries' parenthetical (which left the other - # two reading as pre-fillable) - assert lowered.index("cannot be pre-filled") < lowered.index("resume-entries") - for action in ("resume-entries", "reset-hwm", "record-flow"): - assert action in lowered, action - - -def test_the_help_menu_lists_the_prd_help_branch() -> None: - """The Help menu becomes real: glossary, the per-screen/per-action catalog, parameter - help and the keys/safety notes -- the PRD §3 Help branch.""" - labels = [entry.label for entry in hc.HELP_MENU] - assert labels == ["glossary", "screens & actions", "rule parameters", "keys & safety"] - lines = hc.build_help_menu_lines() - joined = "\n".join(line.text for line in lines) - for label in labels: - assert label in joined, label - assert all(len(line.text) <= 80 for line in lines) - - -def test_the_screens_catalog_renders_every_modes_contribution() -> None: - lines = hc.build_screens_catalog_lines() - joined = "\n".join(line.text for line in lines) - for mode in hc.CONSOLE_MODES: - assert mode in joined, mode - assert all(len(line.text) <= 80 for line in lines) diff --git a/tests/commands/test_research_console.py b/tests/commands/test_research_console.py deleted file mode 100644 index 9b58aee8..00000000 --- a/tests/commands/test_research_console.py +++ /dev/null @@ -1,332 +0,0 @@ -"""Tests for `keel.commands.research_console` -- the Research menu and the O5 evidence -readers (issue #390 C4; PRD O5). - -Pinned here: - -* **The sub-menu** -- PRD §3's Research branch: experiments, research docs, promotion - reports, the trials ledger. -* **The corpus readers** -- a directory listing newest-first (mtime, then name), a BOUNDED - read of the chosen document (a runaway file can never cost the console an unbounded read), - and the compliance console's mtime-cache lesson: an unchanged file is not re-read on - repaint. -* **The trials reader** -- `trials list`'s own rendering over `read_trials`/`trial_counts` - plus `verify_chain`'s verdict, both read-only service calls. -* **The simulate-report reachability** -- the promotion-reports corpus is the directory - `run_simulation` writes into (`default_report_path`), so a just-run simulation's report is - in the list, newest-first. -""" - -from __future__ import annotations - -import os -from pathlib import Path - -import pytest - -from keel.commands import research_console as rc -from keel.commands.simulate import default_report_path -from keel.research import ledger as trials_ledger - -NOW_TS = 1_800_000_000 - - -# -- the sub-menu (PRD §3's Research branch) ------------------------------------------------------- - - -def test_the_research_menu_is_the_prd_research_branch() -> None: - assert [entry.label for entry in rc.RESEARCH_MENU] == [ - "experiments", - "research docs", - "promotion reports", - "trials ledger", - ] - trials = rc.research_entry(4) - assert trials is not None - assert trials.kind == "trials" - - -def test_the_corpus_directories_are_the_engines_own_paths() -> None: - """Single-sourced, never a TUI-side path table: the experiments dir is the trials - ledger's own parent (resolved at CALL time, so the test-isolation patch and any - relocated ledger relocate the reader too), and the reports dir is where - `run_simulation` writes.""" - assert rc.corpus_path("experiments") == trials_ledger.DEFAULT_LEDGER_PATH.parent - assert rc.corpus_path("reports") == default_report_path(NOW_TS).parent - assert rc.corpus_path("research") == Path("docs/research") - - -# -- the corpus readers ---------------------------------------------------------------------------- - - -def _write(path: Path, text: str, *, mtime: float | None = None) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text) - if mtime is not None: - os.utime(path, (mtime, mtime)) - - -def test_list_documents_is_newest_first_and_calm_on_an_absent_directory(tmp_path: Path) -> None: - older = tmp_path / "2026-01-01-old.md" - newer = tmp_path / "2026-02-01-new.md" - _write(older, "old", mtime=1_000_000.0) - _write(newer, "new", mtime=2_000_000.0) - files = rc.list_documents(tmp_path) - assert [f.path.name for f in files] == ["2026-02-01-new.md", "2026-01-01-old.md"] - # An absent directory is an empty list, never a traceback (the scout browser's contract). - assert rc.list_documents(tmp_path / "nope") == () - - -def test_list_documents_filters_by_suffix(tmp_path: Path) -> None: - _write(tmp_path / "a.md", "x", mtime=1.0) - _write(tmp_path / "b.py", "x", mtime=2.0) - _write(tmp_path / "c.jsonl", "x", mtime=3.0) - names = {f.path.name for f in rc.list_documents(tmp_path)} - assert names == {"a.md", "b.py", "c.jsonl"} - only_md = {f.path.name for f in rc.list_documents(tmp_path, ".md")} - assert only_md == {"a.md"} - - -def test_read_document_lines_is_bounded(tmp_path: Path) -> None: - """A file past the byte bound is read only up to the bound, with a loud truncation note - -- never an unbounded read of whatever a runaway writer produced.""" - big = tmp_path / "big.md" - big.write_text("x" * (rc.MAX_DOC_BYTES + 10)) - lines = rc.read_document_lines(big) - joined = "\n".join(lines) - assert "truncated" in (" ".join(joined.split())) - assert len(joined) < rc.MAX_DOC_BYTES + 10_000 - - -def test_read_document_lines_renders_text_verbatim(tmp_path: Path) -> None: - doc = tmp_path / "doc.md" - doc.write_text("# Title\n\nbody line\n") - lines = rc.read_document_lines(doc) - assert "# Title" in lines - assert "body line" in lines - - -def test_cached_document_lines_skips_the_read_for_an_unchanged_file( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """The mtime-cache lesson: the reader repaints per poll; an UNCHANGED document is - served from the cache, and a changed mtime re-reads.""" - doc = tmp_path / "doc.md" - doc.write_text("v1") - cache: dict[tuple[str, int], list[str]] = {} - reads: list[str] = [] - - real_read = rc.read_document_lines - - def counting_read(path: Path) -> list[str]: - reads.append(str(path)) - return real_read(path) - - monkeypatch.setattr(rc, "read_document_lines", counting_read) - first = rc.cached_document_lines(doc, cache) - second = rc.cached_document_lines(doc, cache) - assert first == second == ["v1"] - assert reads == [str(doc)] - # A changed mtime refreshes. - doc.write_text("v2") - third = rc.cached_document_lines(doc, cache) - assert third == ["v2"] - assert reads == [str(doc), str(doc)] - - -def test_the_doc_list_renders_files_and_an_honest_empty_state(tmp_path: Path) -> None: - _write(tmp_path / "only.md", "x", mtime=1.0) - files = rc.list_documents(tmp_path) - lines = rc.build_doc_list_lines("experiments", files, tmp_path) - texts = [line.text for line in lines] - joined = "\n".join(texts) - # Wrap-normalized: the 80-col budget can break any phrase across lines (a long - # tmp path shifts every wrap point), so assertions read whitespace-collapsed text. - collapsed = " ".join(joined.split()) - assert any("only.md" in t for t in texts) - # The directory is named, wrapped to the budget (a long tmp path breaks across lines, - # so the assertion reads the JOINED body, not one line). - assert tmp_path.name in collapsed or str(tmp_path) in collapsed - assert "newest first" in collapsed - - empty = rc.build_doc_list_lines("experiments", (), tmp_path) - joined = "\n".join(line.text for line in empty) - assert "no documents" in (" ".join(joined.split())) - - -def test_the_doc_list_rows_fit_the_80_column_clip(tmp_path: Path) -> None: - """A long report filename's row wraps to the budget rather than clipping: the tail of - the name is exactly the part that identifies the file.""" - long_name = ( - "2026-08-17-engine-validation-hourly-with-a-very-long-descriptive-name.md" - ) - _write(tmp_path / long_name, "x", mtime=1.0) - files = rc.list_documents(tmp_path) - lines = rc.build_doc_list_lines("reports", files, tmp_path) - for line in lines: - assert len(line.text) <= 80, line.text - joined = "\n".join(line.text for line in lines) - assert long_name[:20] in joined # the name renders (wrapped), not truncated away - - -def test_the_doc_screen_renders_the_documents_own_lines(tmp_path: Path) -> None: - doc = tmp_path / "experiment.md" - doc.write_text("# What we tried\n\nresult: net negative\n") - lines = rc.build_doc_lines("experiments", doc, ["# What we tried", "", "result: net negative"]) - texts = [line.text for line in lines] - assert any("# What we tried" in t for t in texts) - assert any("result: net negative" in t for t in texts) - assert any("experiment.md" in t for t in texts) - - -def test_the_promotion_reports_corpus_holds_a_just_run_simulation_report(tmp_path: Path) -> None: - """`run_simulation` writes into the reports corpus (`default_report_path`), so the - report a console simulate run just wrote is immediately listable there, newest-first.""" - report = tmp_path / default_report_path(NOW_TS).name - _write(report, "verdict: TRAIN-MORE", mtime=float(NOW_TS)) - _write(tmp_path / "2020-01-01-older.md", "old", mtime=1.0) - files = rc.list_documents(tmp_path) - assert files[0].path.name == report.name - - -# -- the trials reader (list + verify, read-only) --------------------------------------------- - - -def _ledger_with_two_trials(tmp_path: Path) -> Path: - path = tmp_path / "trials-ledger.jsonl" - trials_ledger.append_trial( - path, - trial_id="t1", - session="sweep-a", - rule="turtle_breakout", - params={"entry_lookback": 40}, - provenance="a_priori", - kind="sweep_node", - decision="rejected", - series_missing=True, - ) - trials_ledger.append_trial( - path, - trial_id="t2", - session="sweep-a", - rule="turtle_breakout", - params={"entry_lookback": 55}, - provenance="fitted", - kind="sweep_node", - decision="selected", - series_missing=True, - ) - return path - - -def test_the_trials_view_lists_the_ledger_and_renders_the_chain_verdict(tmp_path: Path) -> None: - path = _ledger_with_two_trials(tmp_path) - lines = rc.build_trials_lines(path) - texts = [line.text for line in lines] - joined = "\n".join(texts) - assert any("t1" in t for t in texts) - assert any("t2" in t for t in texts) - assert "M=" in joined - assert "chain intact" in (" ".join(joined.split())) - - -def test_the_trials_view_renders_a_broken_chain_fail_loud(tmp_path: Path) -> None: - path = _ledger_with_two_trials(tmp_path) - # Tamper with the first row: the chain must NOT verify. - rows = path.read_text().splitlines() - import json as _json - - first = _json.loads(rows[0]) - first["rule"] = "tampered" - rows[0] = _json.dumps(first) - path.write_text("\n".join(rows) + "\n") - lines = rc.build_trials_lines(path) - joined = "\n".join(line.text for line in lines) - assert "chain intact" not in (" ".join(joined.split())) - assert "CHAIN BROKEN" in joined or "error" in joined.lower() - - -def test_the_trials_view_is_calm_about_an_absent_ledger(tmp_path: Path) -> None: - lines = rc.build_trials_lines(tmp_path / "absent.jsonl") - joined = "\n".join(line.text for line in lines) - assert "no trials" in (" ".join(joined.split())) - - -# -- the menu screen ---------------------------------------------------------------------------- - - -def test_the_menu_screen_renders_every_entry() -> None: - lines = rc.build_research_menu_lines(cursor=0) - texts = [line.text for line in lines] - for entry in rc.RESEARCH_MENU: - assert any(entry.label in t for t in texts), entry.label - - -# -- the loop wiring (fake curses): the readers are reachable and read-only ---------------------- - - -def test_run_live_research_readers_are_reachable(monkeypatch: pytest.MonkeyPatch) -> None: - """Loop-level: Research -> trials renders the ledger view with its chain verdict, and - Research -> experiments lists the (test-isolated) corpus with its calm empty state -- - both read-only.""" - import sys - from decimal import Decimal - - import click - - from keel.commands import tui as tui_mod - from keel.commands.console import ConsoleBinding - from keel.config import ( - AutoTradeConfig, - Caps, - Config, - DcaConfig, - MarketDataConfig, - MoneyMgmtConfig, - ) - from keel.data.db import connect, migrate - from keel.data.repository import Repository - from tests.commands.test_tui import _fake_curses, _KeySequenceStdscr - - conn = connect(":memory:") - migrate(conn) - repo = Repository(conn) - config = Config( - allowlist=["BTC"], - target_weights={}, - risk_pct=Decimal("0.01"), - caps=Caps( - max_per_order_usd=Decimal("100000"), - max_per_day_usd=Decimal("300000"), - max_exposure_usd=Decimal("1000000"), - max_per_asset_pct=Decimal("1"), - ), - market_data=MarketDataConfig(granularities=[], history_days=365), - auto_trade=AutoTradeConfig(mode="paper", interval_sec=900), - money_mgmt=MoneyMgmtConfig( - max_total_dd_pct=Decimal("0.20"), max_weekly_dd_pct=Decimal("0.08") - ), - dca=DcaConfig(budget_usd=Decimal("50"), cadence_days=7), - ) - - # m -> menu; 7 -> Research; 4 -> trials ledger; poll; Esc back to Research; 1 -> - # experiments (empty -- the corpus directory follows the patched ledger's parent, - # so test isolation empties it); poll; Esc; q quits. - keys = [ord("m"), ord("7"), ord("4"), -1, 27, ord("1"), -1, 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - fake = _fake_curses() - fake.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake) - - ctx = click.Context( - click.Command("tui"), obj={"config_path": "config.yaml", "db_path": "keel.db"} - ) - binding = ConsoleBinding(ctx, config_path="config.yaml", db_path="keel.db") - binding.open_state = lambda: (repo, config) # type: ignore[method-assign] - tui_mod.run_live( - binding.open_state, lambda: NOW_TS, interval=0.01, console_binding=binding - ) - - painted = "\n".join(call[2] for call in stdscr.calls) - assert "trials ledger" in painted - assert "chain intact" in painted or "no trials" in painted - assert "no documents" in painted # the isolated experiments corpus, calmly empty diff --git a/tests/commands/test_service_isolation.py b/tests/commands/test_service_isolation.py index afec8760..e1f24856 100644 --- a/tests/commands/test_service_isolation.py +++ b/tests/commands/test_service_isolation.py @@ -1,18 +1,21 @@ -"""Architecture pins for the service extraction (issue #387 C1): the shared service layer -- -and `keel.commands.tui`, the console's future consumer -- must be reachable WITHOUT the CLI -composition root. +"""Architecture pins for the service extraction (issue #387 C1): the shared service layer must be +reachable WITHOUT the CLI composition root. PRD O2: "the TUI renders and dispatches. All behavior comes from the same services the CLI -calls." That has a structural precondition the UI code cannot be trusted to keep by +calls." That had a structural precondition the UI code could not be trusted to keep by convention: before the extraction, `keel/commands/tui.py` had to lazy-import `from keel.cli import _screen_product` inside two functions (an import-cycle dodge), which -meant the "shared" gate physically lived in the front-end. Two pins keep that shape from -coming back: +meant the "shared" gate physically lived in the front-end. + +**The TUI is gone (#541) and these pins are not.** The front-end that replaced it -- `keel/web/`, +and `keel serve` over it -- has exactly the same relationship to the service layer, and the +failure mode is identical: a service that cannot be imported without the CLI is a service the +next front-end will have to reach through the CLI. Two pins keep that shape from coming back: 1. `test_services_import_without_the_cli` -- in a fresh interpreter, importing every audited - service module AND `keel.commands.tui` must leave `keel.cli` out of `sys.modules`. A fresh - interpreter is required because THIS process has `keel.cli` loaded (the parity tests drive - it); the check is about the import graph, not any one process's history. + service module must leave `keel.cli` out of `sys.modules`. A fresh interpreter is required + because THIS process has `keel.cli` loaded; the check is about the import graph, not any one + process's history. 2. `test_no_commands_module_imports_the_cli` -- a source-level scan (AST, module-level statements only, so a docstring mentioning `keel.cli` stays legal) asserting no `keel/commands/*` module imports the composition root at load time. Function-level lazy @@ -41,10 +44,8 @@ "keel.commands.autonomy", "keel.commands.brokers", "keel.commands.confirm", - "keel.commands.console", "keel.commands.db", "keel.commands.fetch", - "keel.commands.help_console", "keel.commands.insights", "keel.commands.monitor", "keel.commands.pnl", @@ -55,9 +56,13 @@ "keel.commands.subscription", "keel.commands.trading", "keel.commands.trials", - "keel.commands.tui", "keel.commands.versions", "keel.commands.withdrawals", + # `keel.corpus` is not under `keel/commands/`, and is here because it is what #541 kept + # when the console layer was deleted: the MCP server reads the research corpora through it, + # and an MCP server that could only start after the CLI had loaded would be the exact + # coupling this test exists to prevent. + "keel.corpus", ] diff --git a/tests/commands/test_strategy_console.py b/tests/commands/test_strategy_console.py deleted file mode 100644 index 4d4b27c4..00000000 --- a/tests/commands/test_strategy_console.py +++ /dev/null @@ -1,1017 +0,0 @@ -"""Tests for `keel.commands.strategy_console` -- the Rules menu, the tried-vs-used ledger, -simulate-from-console, the add form and the retry flow (issue #390 C4; PRD O11). - -Four surfaces, all pinned here: - -* **The sub-menu** -- PRD §3's Rules branch as the strategy console: the ledger, simulate, - add, retry (backtest + promote, `--force` typed), enable/disable/demote, insights. -* **The tried-vs-used ledger (O11.2)** -- every rule with its lifecycle status and its - RECORDED context, rendered CHEAPLY on entry (rule rows, stamps, and the insights gate - distance for paper rules -- never a backtest: the entry render invokes ZERO backtests, - pinned by spy, because a 19-rule hourly deployment costs real hours to re-backtest). The - per-rule backtest verdict is an EXPLICIT, Enter-gated re-compute ("re-compute this rule's - verdict") that runs the full-window backtest exactly once per Enter and is held in the - ledger's state; a rule with no candles to backtest against renders "no backtest on - record" -- never a TUI-authored narrative. -* **Simulate (O11.1)** -- an ARMED view that shows the target report path BEFORE any run - (the confirm step), `run_simulation` spied (never really computed here), and the results - rendering the service's own verdict/report verbatim. -* **The forms (O11.3/O11.4)** -- add (per-field parameter help from `describe_params`, - landing as candidate with the service's own validation errors), retry (backtest + - promote with an explicit confirm and a TYPED `--force` gate that refuses on a wrong - phrase), and enable as the documented restore path. - -Mirrors `tests/commands/test_compliance_console.py`'s fixture style. -""" - -from __future__ import annotations - -import sys -from decimal import Decimal -from pathlib import Path -from typing import Any - -import pytest - -from keel.commands import strategy_console as sc -from keel.commands.simulate import SimulationOutcome -from keel.config import ( - AutoTradeConfig, - Caps, - Config, - DcaConfig, - MarketDataConfig, - MoneyMgmtConfig, -) -from keel.data.db import connect, migrate -from keel.data.repository import Repository -from keel.types import Candle, Granularity - -NOW_TS = 1_800_000_000 - - -@pytest.fixture -def repo() -> Repository: - conn = connect(":memory:") - migrate(conn) - r = Repository(conn) - r.set_state("kill_switch", False) - return r - - -def _config(**overrides: Any) -> Config: - base: dict[str, Any] = dict( - allowlist=["BTC", "ETH"], - target_weights={}, - risk_pct=Decimal("0.01"), - caps=Caps( - max_per_order_usd=Decimal("100000"), - max_per_day_usd=Decimal("300000"), - max_exposure_usd=Decimal("1000000"), - max_per_asset_pct=Decimal("1"), - ), - market_data=MarketDataConfig(granularities=[], history_days=365), - auto_trade=AutoTradeConfig(mode="paper", interval_sec=900), - money_mgmt=MoneyMgmtConfig( - max_total_dd_pct=Decimal("0.20"), max_weekly_dd_pct=Decimal("0.08") - ), - dca=DcaConfig(budget_usd=Decimal("50"), cadence_days=7), - ) - base.update(overrides) - return Config(**base) - - -def _daily_candles(n: int, *, start: int = 1_700_000_000) -> list[Candle]: - """Flat daily candles: enough history for a rule to evaluate, no trend to trade.""" - return [ - Candle( - ts=start + i * 86400, - open=Decimal("100"), - high=Decimal("101"), - low=Decimal("99"), - close=Decimal("100"), - volume=Decimal("10"), - ) - for i in range(n) - ] - - -def _prompt(answers: list[str]) -> Any: - queue = iter(answers) - asked: list[str] = [] - - def fn(text: str) -> str: - asked.append(text) - return next(queue) - - fn.asked = asked - return fn - - -# -- the sub-menu (PRD §3's Rules branch) ---------------------------------------------------------- - - -def test_the_strategy_menu_is_the_prd_rules_branch() -> None: - assert [entry.label for entry in sc.STRATEGY_MENU] == [ - "tried-vs-used ledger", - "simulate + results", - "add a strategy", - "retry a strategy", - "enable (restore)", - "disable", - "demote", - "insights", - ] - retry = sc.strategy_entry(4) - assert retry is not None - assert retry.label == "retry a strategy" - assert "backtest" in retry.description and "promote" in retry.description - - -def test_the_menu_screen_renders_every_entry_and_the_keys() -> None: - lines = sc.build_strategy_menu_lines(cursor=0) - texts = [line.text for line in lines] - for entry in sc.STRATEGY_MENU: - assert any(entry.label in t for t in texts), entry.label - assert any("up/k down/j" in t for t in texts) - - -# -- the tried-vs-used ledger (O11.2) ---------------------------------------------------------- - - -def test_opening_the_ledger_runs_zero_backtests_on_a_19_rule_deployment( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """THE entry contract (the blocker this view was redesigned around): building the ledger - invokes the backtest fn ZERO times, even when every row HAS candles a backtest could run - over -- the old design re-backtested every rule synchronously on entry (measured on 5y of - hourly candles: one rsi_meanrev rule alone took ~7.5 minutes; a 19-rule deployment ~2.4 - hours, uncancellable). 19 fixture rows -- the flagship deployment's size -- prove it.""" - from keel.strategy import backtest as backtest_mod - - kinds = [ - "turtle_breakout", - "pullback_continuation", - "rsi_meanrev", - "dca", - ] - for index in range(19): - repo.insert_rule( - kinds[index % len(kinds)], - {"product_id": "BTC-USD"}, - status=["live", "paper", "candidate", "disabled"][index % 4], - now_ts=NOW_TS, - ) - repo.upsert_candles("BTC-USD", Granularity.ONE_DAY, _daily_candles(60)) - repo.upsert_candles("BTC-USD", Granularity.ONE_HOUR, _daily_candles(60)) - - calls: list[int] = [] - real_backtest = backtest_mod.backtest - monkeypatch.setattr( - backtest_mod, "backtest", lambda *a, **k: calls.append(1) or real_backtest(*a, **k) - ) - - ledger = sc.build_strategy_ledger(repo, _config(), NOW_TS) - - assert len(ledger) == 19 - assert calls == [] - - -def test_the_recompute_verdict_names_the_exact_failing_floor_from_a_real_backtest( - repo: Repository, -) -> None: - """A candidate with candles on record but far too few trades: the EXPLICIT per-rule - re-compute renders the gate's OWN reason for that floor -- `n_trades N < min_trades 100` - -- sourced from a real backtest judged through `can_promote`, not a TUI summary of it.""" - repo.insert_rule( - "turtle_breakout", - {"product_id": "BTC-USD", "entry_lookback": 40}, - status="candidate", - now_ts=NOW_TS, - ) - repo.upsert_candles("BTC-USD", Granularity.ONE_DAY, _daily_candles(60)) - - ledger = sc.build_strategy_ledger(repo, _config(), NOW_TS) - verdict = sc.compute_rule_verdict(repo, _config(), ledger[0]) - joined = "\n".join(verdict.reason_lines) - assert "n_trades" in joined and "min_trades" in joined - # The overfitting axis is honestly NOT RUN, in the machine's own words. - assert "NOT RUN" in joined - assert verdict.stats_line is not None and "n_trades=" in verdict.stats_line - - -def test_the_recompute_verdict_renders_no_backtest_on_record_without_candles( - repo: Repository, -) -> None: - """No cached candles for the product: no backtest can have ever run, and the re-computed - verdict says exactly that rather than inventing one.""" - repo.insert_rule( - "turtle_breakout", {"product_id": "ETH-USD"}, status="candidate", now_ts=NOW_TS - ) - ledger = sc.build_strategy_ledger(repo, _config(), NOW_TS) - verdict = sc.compute_rule_verdict(repo, _config(), ledger[0]) - assert "no backtest on record" in "\n".join(verdict.reason_lines) - - -def test_the_recompute_verdict_delegates_its_backtest_half_to_the_service( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """The verdict view never re-derives a backtest: its BACKTEST half is the `rules` - service's own compute core (`resolve_rule_backtest` + `backtest_resolved`, the same - read/build/backtest `keel rules backtest` runs), not a console-side twin of the - granularity loop and the input assembly. Pinned by spy because the thinness pin's - allowance for a console-side engine call is GONE -- a verdict that assembles its own - inputs again would pass every behavior test and still be the drifting second copy - (issue #392 C6's review finding).""" - repo.insert_rule( - "turtle_breakout", - {"product_id": "BTC-USD", "entry_lookback": 40}, - status="candidate", - now_ts=NOW_TS, - ) - repo.upsert_candles("BTC-USD", Granularity.ONE_DAY, _daily_candles(60)) - - calls: list[str] = [] - real_resolve = sc.resolve_rule_backtest - real_run = sc.backtest_resolved - monkeypatch.setattr( - sc, - "resolve_rule_backtest", - lambda *a, **k: calls.append("resolve") or real_resolve(*a, **k), - ) - monkeypatch.setattr( - sc, - "backtest_resolved", - lambda resolved: calls.append("run") or real_run(resolved), - ) - - ledger = sc.build_strategy_ledger(repo, _config(), NOW_TS) - verdict = sc.compute_rule_verdict(repo, _config(), ledger[0]) - - assert calls == ["resolve", "run"] - assert verdict.stats_line is not None and "n_trades=" in verdict.stats_line - - -def test_one_poisoned_row_neither_blanks_the_ledger_nor_kills_its_recompute( - repo: Repository, -) -> None: - """A row whose stored params crash the backtest (a quoted float, the exact shape the add - service now refuses but a pre-guard row can still carry) must cost ONLY its own verdict: - the healthy row still renders and still computes, the poisoned row renders at entry with - its error carried in its re-computed verdict -- `build_rule_track_record`'s - graceful-degradation precedent, applied per row.""" - repo.insert_rule( - "rsi_meanrev", - {"product_id": "BTC-USD", "oversold": "10.0"}, # poisoned: a quoted float - status="candidate", - now_ts=NOW_TS, - ) - repo.insert_rule( - "turtle_breakout", - {"product_id": "BTC-USD", "entry_lookback": 40}, - status="candidate", - now_ts=NOW_TS, - ) - # rsi_meanrev decides on ONE_HOUR (its own timeframe); turtle on ONE_DAY -- the - # poisoned row must actually REACH its poisoned arithmetic, not dodge it. - repo.upsert_candles("BTC-USD", Granularity.ONE_DAY, _daily_candles(60)) - repo.upsert_candles("BTC-USD", Granularity.ONE_HOUR, _daily_candles(60)) - - ledger = sc.build_strategy_ledger(repo, _config(), NOW_TS) - # ENTRY renders BOTH rows -- one poisoned row never blanks the ledger. - assert len(ledger) == 2 - lines = sc.build_ledger_lines(ledger) - texts = [line.text for line in lines] - assert any("rsi_meanrev" in t for t in texts) - assert any("turtle_breakout" in t for t in texts) - - by_kind = {entry.kind: entry for entry in ledger} - poisoned = sc.compute_rule_verdict(repo, _config(), by_kind["rsi_meanrev"]) - healthy = sc.compute_rule_verdict(repo, _config(), by_kind["turtle_breakout"]) - assert any("failed" in reason for reason in poisoned.reason_lines) - assert healthy.stats_line is not None and "n_trades=" in healthy.stats_line - - # And the rendered ledger carries the healthy verdict and the poisoned error alike. - lines = sc.build_ledger_lines( - ledger, - verdicts={ - by_kind["rsi_meanrev"].rule_id: poisoned, - by_kind["turtle_breakout"].rule_id: healthy, - }, - ) - joined = "\n".join(line.text for line in lines) - assert "n_trades" in joined - assert "failed" in joined - - -def test_the_ledger_groups_every_lifecycle_status_with_its_recorded_context( - repo: Repository, -) -> None: - """live / paper / candidate / disabled all render, grouped in-use-first, with the - RECORDED stamps (promoted_at / demoted_at) -- never a narrative.""" - live_id = repo.insert_rule( - "dca", {"product_id": "BTC-USD"}, status="live", now_ts=NOW_TS - ) - repo.update_rule_status(live_id, "live") # stamps promoted_at - repo.insert_rule( - "dca", {"product_id": "ETH-USD"}, status="disabled", now_ts=NOW_TS - ) - repo.insert_rule( - "pullback_continuation", - {"product_id": "BTC-USD"}, - status="paper", - now_ts=NOW_TS, - ) - repo.insert_rule( - "turtle_breakout", - {"product_id": "BTC-USD"}, - status="candidate", - now_ts=NOW_TS, - ) - - ledger = sc.build_strategy_ledger(repo, _config(), NOW_TS) - statuses = [entry.status for entry in ledger] - assert statuses[0] == "live" - assert set(statuses) == {"live", "paper", "candidate", "disabled"} - - lines = sc.build_ledger_lines(ledger, cursor=0) - texts = [line.text for line in lines] - joined = "\n".join(texts) - assert "IN USE" in joined - assert "TRIED" in joined or "CANDIDATE" in joined - assert "DISABLED" in joined - # The disabled row names the documented restore path (the `rules enable` docstring's own). - assert "rules enable" in joined - # The paper row carries the insights service's own gate-distance reading (an empty - # track record reads as the floor it still owes, not a fabricated absence). - assert "trades_remaining" in joined - # Entry never claims a backtest ran: rows without a held verdict say so honestly. - assert "re-compute" in joined - - -def test_the_paper_row_carries_the_insights_gate_distance_when_one_exists( - repo: Repository, -) -> None: - """A paper rule with a paper track record: the ledger renders the insights service's own - gate-distance blocking reasons (trades_remaining wording) AT ENTRY -- recorded paper - trades, never a backtest -- and DISCLOSES on the rendered line that the distance is - kind-wide (insights' own semantics), not this row's alone.""" - import json - - repo.insert_rule( - "pullback_continuation", {"product_id": "BTC-USD"}, status="paper", now_ts=NOW_TS - ) - entry_id = repo.insert_order( - dict( - mode="paper", - product_id="BTC-USD", - side="BUY", - order_type="market", - qty=Decimal("1"), - limit_price=Decimal("100"), - status="filled", - fee=Decimal("0"), - expected_fill=Decimal("100"), - actual_fill=Decimal("100"), - raw_response=json.dumps( - { - "role": "entry", - "rule_name": "pullback_continuation", - "entry": "100", - "stop": "90", - "target": "120", - "qty": "1", - "ts": 1_000, - } - ), - confirmation="paper", - rule_id=None, - created_at=1_000, - updated_at=1_000, - ) - ) - repo.insert_order( - dict( - mode="paper", - product_id="BTC-USD", - side="SELL", - order_type="market", - qty=Decimal("1"), - limit_price=Decimal("110"), - status="filled", - fee=Decimal("0"), - expected_fill=Decimal("110"), - actual_fill=Decimal("110"), - raw_response=json.dumps( - { - "role": "exit", - "rule_name": "pullback_continuation", - "entry_order_id": entry_id, - "entry": "100", - "exit": "110", - "qty": "1", - "pnl": "10", - "r_multiple": "1.0", - "mfe": "12", - "mae": "2", - "outcome": "win", - "entry_ts": 1_000, - "exit_ts": 2_000, - } - ), - confirmation="paper", - rule_id=None, - created_at=2_000, - updated_at=2_000, - ) - ) - - ledger = sc.build_strategy_ledger(repo, _config(), NOW_TS) - paper = next(e for e in ledger if e.status == "paper") - joined = "\n".join(paper.paper_gate_lines) - assert "trades_remaining" in joined - assert "n_trades 1 < min_trades" in joined - # The kind-wide disclosure rides ON the rendered line, not in a code comment. - assert "kind-wide" in joined - - -def test_the_paper_row_renders_its_recorded_demotion_context(repo: Repository) -> None: - """A rule demoted live->paper carries its stamp in the `promoted_at` column - (`update_rule_status` writes every non-disabled transition there), and the PAPER group - renders it with wording that names the column honestly -- a demoted-to-paper row is no - longer indistinguishable from every other paper row.""" - rule_id = repo.insert_rule( - "dca", {"product_id": "BTC-USD"}, status="live", now_ts=NOW_TS - ) - repo.update_rule_status(rule_id, "paper") # the runbook's demotion path - - ledger = sc.build_strategy_ledger(repo, _config(), NOW_TS) - assert ledger[0].status == "paper" - lines = sc.build_ledger_lines(ledger) - joined = "\n".join(line.text for line in lines) - assert "paper since" in joined - assert "promoted_at" in joined - assert "demotion" in joined - - -def test_the_ledger_detail_renders_params_with_their_docs(repo: Repository) -> None: - """The ledger's detail view renders every param through `describe_params` -- the O8 - per-field help, single-sourced from the class.""" - repo.insert_rule( - "turtle_breakout", - {"product_id": "BTC-USD", "entry_lookback": 55}, - status="candidate", - now_ts=NOW_TS, - ) - ledger = sc.build_strategy_ledger(repo, _config(), NOW_TS) - lines = sc.build_ledger_detail_lines(ledger[0]) - texts = [line.text for line in lines] - joined = "\n".join(texts) - assert "entry_lookback" in joined - assert "55" in joined - # The class's own docstring renders beside the value. - assert "Donchian-high entry" in joined - - -def test_the_ledger_detail_is_armed_with_the_recompute_warning(repo: Repository) -> None: - """The detail view without a held verdict is the re-compute's ARMED state: the warning - that Enter runs the FULL-WINDOW backtest (minutes on long series, the screen frozen like - simulate/fetch) renders BEFORE any Enter can start one.""" - repo.insert_rule( - "turtle_breakout", {"product_id": "BTC-USD"}, status="candidate", now_ts=NOW_TS - ) - ledger = sc.build_strategy_ledger(repo, _config(), NOW_TS) - - lines = sc.build_ledger_detail_lines(ledger[0]) - joined = "\n".join(line.text for line in lines) - assert "re-compute this rule's verdict" in joined - assert "FULL-WINDOW backtest" in joined - assert "minutes" in joined - - # With a held verdict, the detail renders the verdict itself (and may re-compute again). - verdict = sc.compute_rule_verdict(repo, _config(), ledger[0]) - lines = sc.build_ledger_detail_lines(ledger[0], verdict=verdict) - joined = "\n".join(line.text for line in lines) - assert "no backtest on record" in joined # no candles were cached for this row - - -def test_the_ledger_lines_fit_the_80_column_clip(repo: Repository) -> None: - """The same budget every console screen keeps: `_paint` clips at the window width and - this dashboard targets 80 columns -- a reason sentence, the restore-path instruction - or the paper row's demotion context must wrap to its own row rather than lose its - tail there.""" - paper_id = repo.insert_rule( - "pullback_continuation", - {"product_id": "BTC-USD"}, - status="live", - now_ts=NOW_TS, - ) - repo.update_rule_status(paper_id, "paper") # stamps promoted_at (the demotion stamp) - repo.insert_rule( - "dca", {"product_id": "ETH-USD"}, status="disabled", now_ts=NOW_TS - ) - ledger = sc.build_strategy_ledger(repo, _config(), NOW_TS) - verdicts = { - entry.rule_id: sc.compute_rule_verdict(repo, _config(), entry) - for entry in ledger - } - for lines in ( - sc.build_ledger_lines(ledger, verdicts=verdicts), - sc.build_ledger_detail_lines(ledger[0], verdict=verdicts[ledger[0].rule_id]), - ): - for line in lines: - assert len(line.text) <= 80, line.text - - -# -- simulate (O11.1): the ARMED view, the spied service, the results ------------------------------ - - -def test_the_simulate_plan_names_the_target_report_path_before_any_run() -> None: - plan = sc.simulate_plan(_config(), "keel.db", now_ts=NOW_TS) - assert plan.report_path == sc.default_report_path(NOW_TS) - lines = sc.build_simulate_armed_lines(plan) - texts = [line.text for line in lines] - joined = "\n".join(texts) - assert "ARMED" in joined - assert str(plan.report_path) in joined - assert "Enter" in joined - - -def test_run_simulate_dispatches_to_run_simulation_with_the_active_profile( - repo: Repository, -) -> None: - """The one compute/network touch: `run_simulation` itself, handed the repo+config of the - ACTIVE profile and the CLI's own defaults (5y, $500/month, allowlist products) -- spied - here, never really run.""" - plan = sc.simulate_plan(_config(), "keel.db", now_ts=NOW_TS) - calls: list[dict[str, Any]] = [] - - def spy_run(*args: Any, **kwargs: Any) -> SimulationOutcome: - calls.append({"args": args, "kwargs": kwargs}) - return SimulationOutcome( - verdict_status="TRAIN-MORE", - verdict_reasons=("n_trades 0 < min_trades 100",), - report_path=plan.report_path, - report_markdown="# report\n\nverdict: TRAIN-MORE\n", - artifact_path=None, - ) - - outcome = sc.run_simulate( - repo, - _config(), - plan, - now_ts=NOW_TS, - build_client=None, - run_fn=spy_run, - ) - assert len(calls) == 1 - assert calls[0]["args"][0] is repo - assert calls[0]["kwargs"]["db_path"] == "keel.db" - assert calls[0]["kwargs"]["years"] == 5 - assert calls[0]["kwargs"]["monthly_contribution"] == Decimal("500") - assert calls[0]["kwargs"]["products"] == ["BTC-USD", "ETH-USD"] - # The path the ARMED screen pre-showed is the path the run writes: pinned INTO the run - # (`out_path`), so a run crossing UTC midnight cannot write a different filename than - # the one the operator confirmed. - assert calls[0]["kwargs"]["out_path"] == plan.report_path - assert outcome.verdict_status == "TRAIN-MORE" - - -def test_the_simulate_results_render_the_services_verdict_and_report_verbatim( - repo: Repository, -) -> None: - outcome = SimulationOutcome( - verdict_status="TRAIN-MORE", - verdict_reasons=("n_trades 0 < min_trades 100", "edge negative vs DCA"), - report_path=Path("docs/superpowers/reports/2026-08-17-engine-validation.md"), - report_markdown=( - "# Engine validation\n\nverdict: TRAIN-MORE\n\n" - "net of fees the strategy underperformed the DCA benchmark\n" - ), - artifact_path=None, - ) - lines = sc.build_simulate_result_lines(outcome, progress=("data cached in: keel.db",)) - texts = [line.text for line in lines] - joined = "\n".join(texts) - assert "TRAIN-MORE" in joined - assert "net of fees the strategy underperformed the DCA benchmark" in joined - assert "docs/superpowers/reports/2026-08-17-engine-validation.md" in joined - # The progress the CLI would have streamed is not lost: it heads the results. - assert "data cached in: keel.db" in joined - - -def test_the_simulate_verdict_footer_is_pinned_outside_the_scroll() -> None: - outcome = SimulationOutcome( - verdict_status="TRAIN-MORE", - verdict_reasons=("n_trades 0 < min_trades 100",), - report_path=Path("docs/reports/x.md"), - report_markdown="line\n" * 200, - artifact_path=None, - ) - footer = sc.simulate_verdict_footer(outcome) - joined = "\n".join(line.text for line in footer) - assert "TRAIN-MORE" in joined - assert "docs/reports/x.md" in joined - - -def test_the_simulate_screens_fit_the_80_column_clip() -> None: - """Same budget as every other console screen, on the PINNED/load-bearing lines - specifically: a verdict+path footer that clipped would hide exactly what the run - concluded and where it was written, so the verdict and the path each get their own - row and the results footer wraps rather than losing its tail.""" - long_path = Path( - "docs/superpowers/reports/2026-08-17-engine-validation-with-a-long-name.md" - ) - outcome = SimulationOutcome( - verdict_status="GO-LIVE", - verdict_reasons=("a failing gate sentence that is long enough to need wrapping " - "on an 80-column terminal, honestly",), - report_path=long_path, - report_markdown="# report\n\nverdict: GO-LIVE\n", - artifact_path=long_path, - ) - for lines in ( - sc.simulate_verdict_footer(outcome), - sc.build_simulate_result_lines(outcome, progress=("a progress line long enough " - "to need wrapping too",)), - ): - for line in lines: - assert len(line.text) <= 80, line.text - # The pinned footer keeps both load-bearing facts, the verdict first and the report - # path on its own row(s) beneath it. - footer = [line.text for line in sc.simulate_verdict_footer(outcome)] - assert "GO-LIVE" in footer[0] - assert "report:" in footer[1] - assert long_path.name[:20] in "\n".join(footer[1:]) - - -# -- the add form (O11.3) ---------------------------------------------------------------------- - - -def test_the_add_form_offers_per_field_parameter_help(repo: Repository) -> None: - """Every turtle param is prompted with its doc, type and default from - `describe_params`; an empty answer keeps the default, and the row lands as candidate.""" - answers = ["turtle_breakout", "BTC-USD"] + [""] * len( - sc.describe_params("turtle_breakout") - ) - prompt = _prompt(answers) - result = sc.run_add_form(repo, _config(), prompt, NOW_TS) - asked = "\n".join(prompt.asked) - assert "entry_lookback" in asked - assert "Donchian-high entry" in asked - assert "atr_stop_mult" in asked - rows = repo.get_rules() - assert len(rows) == 1 - assert rows[0]["status"] == "candidate" - assert rows[0]["params"]["entry_lookback"] == 40 # the kind's own default - assert "added rule" in result - assert "status=candidate" in result - - -def test_the_add_form_sets_only_the_fields_answered(repo: Repository) -> None: - params = sc.describe_params("turtle_breakout") - answers = ["turtle_breakout", "BTC-USD"] - for name in params: - answers.append("70" if name == "entry_lookback" else "") - result = sc.run_add_form(repo, _config(), _prompt(answers), NOW_TS) - rows = repo.get_rules() - assert rows[0]["params"]["entry_lookback"] == 70 - assert "added rule" in result - - -def test_the_add_form_surfaces_the_services_own_validation_errors(repo: Repository) -> None: - """A quoted number for a float param: the SERVICE's message renders as the form's - Error: line, and nothing is written.""" - params = sc.describe_params("rsi_meanrev") - answers = ["rsi_meanrev", "BTC-USD"] - for name in params: - # `oversold` is a float param: a QUOTED value is the refusal the CLI makes. - answers.append('"10.0"' if name == "oversold" else "") - result = sc.run_add_form(repo, _config(), _prompt(answers), NOW_TS) - assert result.startswith("Error:") - assert "cannot use these params" in result - assert repo.get_rules() == [] - - -def test_the_add_form_cancels_on_an_empty_kind(repo: Repository) -> None: - result = sc.run_add_form(repo, _config(), _prompt([""]), NOW_TS) - assert "cancelled" in result - assert repo.get_rules() == [] - - -# -- the retry flow (O11.4) -------------------------------------------------------------------- - - -def _retry_rule(repo: Repository) -> int: - """A turtle candidate with daily candles on record: the backtest resolves (the kind's - own ONE_DAY granularity), produces a short sample, and the gate's own wording is what - the form renders.""" - rule_id = repo.insert_rule( - "turtle_breakout", - {"product_id": "BTC-USD", "entry_lookback": 40}, - status="candidate", - now_ts=NOW_TS, - ) - repo.upsert_candles("BTC-USD", Granularity.ONE_DAY, _daily_candles(30)) - return rule_id - - -def test_the_retry_form_rebacktests_and_asks_before_promoting(repo: Repository) -> None: - """Retry = re-backtest (always) + re-attempt promote (only on an explicit y/N confirm). A - declined confirm means no promotion attempt at all, and no typed gate either.""" - _retry_rule(repo) - promotes: list[str] = [] - result = sc.run_retry_form( - repo, - _config(), - _prompt(["1", "n", "n"]), - NOW_TS, - typed_force_fn=lambda *a: (promotes.append("asked"), False)[1], - ) - assert promotes == [] - assert repo.get_rules()[0]["status"] == "candidate" - assert "n_trades=" in result # the backtest ran and its line rendered - - -def test_the_retry_promote_reports_the_machines_refusal_verbatim(repo: Repository) -> None: - """A confirmed promote attempt without a PBO session: the gate's OWN NOT_RUN refusal is - the form's result, and the status does not move.""" - _retry_rule(repo) - result = sc.run_retry_form( - repo, - _config(), - _prompt(["1", "y", "", "n"]), - NOW_TS, - typed_force_fn=lambda *a: pytest.fail("not reached: force was declined"), - ) - assert "overfitting check" in result - assert "NOT RUN" in result - assert repo.get_rules()[0]["status"] == "candidate" - - -def test_the_retry_force_requires_the_typed_phrase_and_refuses_a_wrong_one( - repo: Repository, -) -> None: - """`--force` stays typed (O3): the gate asks for the CLI's own `Type "yes" to confirm` - phrase; a wrong phrase means not a single status write.""" - _retry_rule(repo) - phrases: list[str] = [] - result = sc.run_retry_form( - repo, - _config(), - _prompt(["1", "n", "y"]), - NOW_TS, - typed_force_fn=lambda *a: (phrases.append("asked"), False)[1], - ) - assert phrases == ["asked"] - assert "typed confirmation" in result - assert repo.get_rules()[0]["status"] == "candidate" - - -def test_the_retry_force_with_the_right_phrase_advances_the_rule(repo: Repository) -> None: - _retry_rule(repo) - result = sc.run_retry_form( - repo, - _config(), - _prompt(["1", "n", "y"]), - NOW_TS, - typed_force_fn=lambda *a: True, - ) - assert "FORCE-PROMOTING" in result - assert repo.get_rules()[0]["status"] == "paper" - - -def test_clis_typed_promote_force_gate_uses_the_clis_own_gate( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The typed gate IS `_require_interactive_confirmation` with action wording that quotes - the CLI's own force warning -- pinned so the console and the CLI's phrase can never - drift apart.""" - asked: list[tuple[str, str]] = [] - - def fake_gate(action: str, detail: str) -> None: - asked.append((action, detail)) - - monkeypatch.setattr("keel.commands._common._require_interactive_confirmation", fake_gate) - assert sc.clis_typed_promote_force_gate(7, "dca", "candidate", "paper") is True - assert len(asked) == 1 - action, _detail = asked[0] - assert "force-promote rule 7 (dca)" in action - assert "BYPASSING" in action - - -def test_clis_typed_promote_force_gate_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: - def refusing_gate(action: str, detail: str) -> None: - raise RuntimeError("no tty") - - monkeypatch.setattr("keel.commands._common._require_interactive_confirmation", refusing_gate) - assert sc.clis_typed_promote_force_gate(7, "dca", "candidate", "paper") is False - - -# -- enable as the documented restore path ------------------------------------------------------ - - -def test_the_enable_form_restores_a_disabled_rule_at_candidate(repo: Repository) -> None: - rule_id = repo.insert_rule( - "dca", {"product_id": "BTC-USD"}, status="disabled", now_ts=NOW_TS - ) - del rule_id - result = sc.run_enable_form(repo, _config(), _prompt(["1"]), NOW_TS) - assert repo.get_rules()[0]["status"] == "candidate" - assert "CANDIDATE" in result - - -def test_the_enable_form_refuses_a_rule_that_is_not_disabled(repo: Repository) -> None: - repo.insert_rule("dca", {"product_id": "BTC-USD"}, status="paper", now_ts=NOW_TS) - result = sc.run_enable_form(repo, _config(), _prompt(["1"]), NOW_TS) - assert result.startswith("Error:") - assert "not disabled" in result - assert repo.get_rules()[0]["status"] == "paper" - - -# -- the loop wiring (fake curses): the simulate confirm gate ---------------------------------- - - -def _fake_curses_mod(monkeypatch: pytest.MonkeyPatch, stdscr: Any) -> Any: - from tests.commands.test_tui import _fake_curses - - fake = _fake_curses() - fake.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake) - return fake - - -def test_run_live_simulate_opens_armed_and_never_runs_the_service_until_enter( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """THE confirm gate: opening Rules -> simulate, polling, and closing must never invoke - `run_simulation` -- the run happens only on an explicit Enter, exactly like the discover - overlay's one network call.""" - from keel.commands import tui as tui_mod - - config = _config() - runs: list[int] = [] - - def spy_run(*args: Any, **kwargs: Any) -> SimulationOutcome: - runs.append(1) - return SimulationOutcome( - verdict_status="TRAIN-MORE", - verdict_reasons=(), - report_path=Path("docs/superpowers/reports/x.md"), - report_markdown="verdict: TRAIN-MORE\n", - artifact_path=None, - ) - - monkeypatch.setattr(sc, "run_simulate", spy_run) - - from tests.commands.test_tui import _KeySequenceStdscr - - # m -> menu; 4 -> Rules; 2 -> simulate (ARMED); poll; Esc closes; q quits. - keys = [ord("m"), ord("4"), ord("2"), -1, 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - _fake_curses_mod(monkeypatch, stdscr) - - binding = _binding(repo, config) - tui_mod.run_live(binding.open_state, lambda: NOW_TS, interval=0.01, console_binding=binding) - - assert runs == [] - painted = [call[2] for call in stdscr.calls] - assert any("ARMED" in t for t in painted) - - -def test_run_live_simulate_enter_runs_the_service_once_and_holds_the_result( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - from keel.commands import tui as tui_mod - from tests.commands.test_tui import _KeySequenceStdscr - - config = _config() - runs: list[int] = [] - - def spy_run(*args: Any, **kwargs: Any) -> SimulationOutcome: - runs.append(1) - return SimulationOutcome( - verdict_status="TRAIN-MORE", - verdict_reasons=("n_trades 0 < min_trades 100",), - report_path=Path("docs/superpowers/reports/x.md"), - report_markdown="verdict: TRAIN-MORE (net of fees)\n", - artifact_path=None, - ) - - monkeypatch.setattr(sc, "run_simulate", spy_run) - - # m -> menu; 4 -> Rules; 2 -> simulate; Enter RUNS; poll repaints the held result; Esc. - keys = [ord("m"), ord("4"), ord("2"), 10, -1, 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - _fake_curses_mod(monkeypatch, stdscr) - - binding = _binding(repo, config) - tui_mod.run_live(binding.open_state, lambda: NOW_TS, interval=0.01, console_binding=binding) - - assert runs == [1] - painted = [call[2] for call in stdscr.calls] - assert any("TRAIN-MORE" in t for t in painted) - - -def test_run_live_the_ledger_entry_runs_no_backtest_and_enter_recomputes_once( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """Loop-level, THE blocker regression: opening Rules -> ledger (and polling it) invokes - the backtest fn ZERO times; the verdict happens only on Enter -- ONCE per Enter, in the - rule's detail view -- and the machine's own floor wording paints once it has.""" - from keel.commands import tui as tui_mod - from keel.strategy import backtest as backtest_mod - - config = _config() - repo.insert_rule( - "turtle_breakout", - {"product_id": "BTC-USD", "entry_lookback": 40}, - status="candidate", - now_ts=NOW_TS, - ) - repo.upsert_candles("BTC-USD", Granularity.ONE_DAY, _daily_candles(30)) - - calls: list[int] = [] - real_backtest = backtest_mod.backtest - monkeypatch.setattr( - backtest_mod, - "backtest", - lambda *a, **k: calls.append(1) or real_backtest(*a, **k), - ) - - from tests.commands.test_tui import _KeySequenceStdscr - - # Entry only: m -> menu; 4 -> Rules; 1 -> ledger; poll; Esc closes; q quits. - keys = [ord("m"), ord("4"), ord("1"), -1, 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - _fake_curses_mod(monkeypatch, stdscr) - binding = _binding(repo, config) - tui_mod.run_live(binding.open_state, lambda: NOW_TS, interval=0.01, console_binding=binding) - - assert calls == [] # ENTRY rendered the ledger without one backtest - painted = "\n".join(call[2] for call in stdscr.calls) - assert "tried-vs-used ledger" in painted - assert "re-compute" in painted # the honest no-verdict-yet line - - # Now the explicit re-compute: 1 -> ledger; Enter -> the rule's detail (ARMED); - # Enter -> ONE backtest; poll repaints the held verdict; Esc; Esc; q quits. - keys = [ord("m"), ord("4"), ord("1"), 10, 10, -1, 27, 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - _fake_curses_mod(monkeypatch, stdscr) - tui_mod.run_live(binding.open_state, lambda: NOW_TS, interval=0.01, console_binding=binding) - - assert calls == [1] # exactly one backtest for exactly one Enter - painted = "\n".join(call[2] for call in stdscr.calls) - assert "min_trades" in painted # the gate's own floor wording, held and repainted - - -def test_run_live_simulate_failure_keeps_the_progress_lines_it_streamed( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """A failed simulate run must not discard the progress it streamed before failing: the - lines render above the error, exactly as they head the results on success.""" - from keel.commands import tui as tui_mod - from tests.commands.test_tui import _KeySequenceStdscr - - config = _config() - - def failing_run(*args: Any, **kwargs: Any) -> SimulationOutcome: - progress = kwargs.get("progress") - if progress is not None: - progress.append("fetching BTC-USD history...") - raise RuntimeError("coverage gap: no candles") - - monkeypatch.setattr(sc, "run_simulate", failing_run) - - # m -> menu; 4 -> Rules; 2 -> simulate; Enter RUNS and fails; poll repaints; Esc; q. - keys = [ord("m"), ord("4"), ord("2"), 10, -1, 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - _fake_curses_mod(monkeypatch, stdscr) - binding = _binding(repo, config) - tui_mod.run_live(binding.open_state, lambda: NOW_TS, interval=0.01, console_binding=binding) - - painted = "\n".join(call[2] for call in stdscr.calls) - assert "simulate failed" in painted - assert "fetching BTC-USD history" in painted - # The progress renders ABOVE the error line, not below it. - first_error = next( - i for i, call in enumerate(stdscr.calls) if "simulate failed" in call[2] - ) - assert any( - "fetching BTC-USD history" in stdscr.calls[i][2] for i in range(first_error) - ) - - -def _binding(repo: Repository, config: Config) -> Any: - """A console binding whose open_state answers (repo, config) -- the loaders are the - CLI's own seams, swapped here for the in-memory pair.""" - import click - - from keel.commands.console import ConsoleBinding - - ctx = click.Context( - click.Command("tui"), obj={"config_path": "config.yaml", "db_path": "keel.db"} - ) - binding = ConsoleBinding(ctx, config_path="config.yaml", db_path="keel.db") - binding.open_state = lambda: (repo, config) # type: ignore[method-assign] - return binding diff --git a/tests/commands/test_trading_console.py b/tests/commands/test_trading_console.py deleted file mode 100644 index 2cd27833..00000000 --- a/tests/commands/test_trading_console.py +++ /dev/null @@ -1,1277 +0,0 @@ -"""Tests for `keel.commands.trading_console` -- the Trading menu (issue #391 C5; PRD §3's -Trading branch, O3's typed contracts). - -Six surfaces, all pinned here: - -* **The sub-menu** -- PRD §3's Trading branch in tree order: agent cycle (ARMED confirm), - monitor poll (ARMED), autonomy, record-flow [typed], reset-hwm [typed], - resume-entries [typed], kill (one-key, per its own CLI contract), resume [typed]. -* **The agent cycle** -- the ARMED view is the confirm step: which profile, what - paper/confirm-mode semantics mean, the autonomy state, and the SESSION HONESTY line - (a session-bound CLOSED venue says the cycle will skip with `market_closed`; the - clock-unavailable case gets its own line) -- sourced from the RECORDED session state, - never a TUI-side calendar. Enter runs `agent.run_once` through `run_agent_cycle` with - the CLI's own confirm gate; the cycle's rendered result (`render_loop_result`) is held. -* **The monitor poll** -- one poll, ARMED, dispatched through `monitor_cycle`. -* **The typed forms** -- record-flow (the CLI's own validation messages and typed gate), - reset-hwm, resume-entries, resume: every gate is the CLI's OWN - `_require_interactive_confirmation` with the CLI's OWN wording (the constants' - single home is `keel/commands/trading.py`, pinned here), and a declined gate means - not a single state row is written -- spy-proven, and for resume/resume-entries - end-to-end through the live loop like C3's withdrawals proof. -* **kill** -- the CLI's own asymmetry: ENGAGING is one key with NO ceremony (never - typed); the console mirrors that exactly -- selecting the entry dispatches - `engage_kill_switch` and toasts the CLI's own line. -* **autonomy** -- the CLI's own semantics: OFF->ON behind the CLI's typed arm gate - (extracted from `autonomy_on`'s body, byte-identical wording), ON->OFF ungated. - -Mirrors `tests/commands/test_strategy_console.py`'s fixture style. -""" - -from __future__ import annotations - -import sys -from decimal import Decimal -from typing import Any - -import pytest - -from keel import agent -from keel.commands import trading_console as tc -from keel.config import ( - AutoTradeConfig, - Caps, - Config, - DcaConfig, - MarketDataConfig, - MoneyMgmtConfig, -) -from keel.data.db import connect, migrate -from keel.data.repository import Repository -from keel.types import Granularity - -NOW_TS = 1_800_000_000 - - -@pytest.fixture -def repo() -> Repository: - conn = connect(":memory:") - migrate(conn) - r = Repository(conn) - r.set_state("kill_switch", False) - return r - - -def _config(**overrides: Any) -> Config: - base: dict[str, Any] = dict( - allowlist=["BTC", "ETH"], - target_weights={}, - risk_pct=Decimal("0.01"), - caps=Caps( - max_per_order_usd=Decimal("100000"), - max_per_day_usd=Decimal("300000"), - max_exposure_usd=Decimal("1000000"), - max_per_asset_pct=Decimal("1"), - ), - market_data=MarketDataConfig( - granularities=[Granularity.ONE_HOUR], history_days=365 - ), - auto_trade=AutoTradeConfig(mode="paper", interval_sec=900), - money_mgmt=MoneyMgmtConfig( - max_total_dd_pct=Decimal("0.20"), max_weekly_dd_pct=Decimal("0.08") - ), - dca=DcaConfig(budget_usd=Decimal("50"), cadence_days=7), - ) - base.update(overrides) - return Config(**base) - - -def _prompt(answers: list[str]) -> Any: - queue = iter(answers) - asked: list[str] = [] - - def fn(text: str) -> str: - asked.append(text) - return next(queue) - - fn.asked = asked - return fn - - -class _RecordingRepo: - """A real `Repository` wrapped so every WRITE is recorded with its exact arguments -- - the spy the "a declined gate writes nothing" proofs read. Reads fall through.""" - - def __init__(self, inner: Repository) -> None: - self._inner = inner - self.calls: list[tuple[str, dict[str, Any]]] = [] - - def __getattr__(self, name: str) -> Any: - return getattr(self._inner, name) - - def set_state(self, key: str, value: Any) -> None: - self.calls.append(("set_state", {"key": key, "value": value})) - self._inner.set_state(key, value) - - def set_autonomous(self, value: bool, now_ts: int, **kwargs: Any) -> None: - self.calls.append( - ("set_autonomous", {"value": value, "now_ts": now_ts, **kwargs}) - ) - self._inner.set_autonomous(value, now_ts, **kwargs) - - -def _recorded_session( - state: str, *, recorded_ts: int = NOW_TS, interval_sec: int = 900 -) -> agent.RecordedSession: - """A FRESH recorded session in the given state -- the B1 recording the plan reads.""" - return agent.RecordedSession( - venue="alpaca", - state=state, - recorded_ts=recorded_ts, - interval_sec=interval_sec, - next_open_ts=None, - next_close_ts=None, - fresh=True, - ) - - -# -- the sub-menu (PRD §3's Trading branch) -------------------------------------------------------- - - -def test_the_trading_menu_is_the_prd_trading_branch() -> None: - assert [entry.label for entry in tc.TRADING_MENU] == [ - "agent cycle (single)", - "monitor poll (single)", - "autonomy", - "record-flow", - "reset-hwm", - "resume-entries", - "kill", - "resume", - ] - - -def test_the_prd_marks_resume_entries_and_resume_as_typed_and_the_menu_says_so() -> None: - typed = {entry.label for entry in tc.TRADING_MENU if entry.typed} - # The CLI's own typed halt-releasers (tests/test_cli.py's `_HALT_COMMANDS`), plus - # autonomy's ON direction -- the PRD tree marks resume-entries/resume "(typed)". - assert typed == {"record-flow", "reset-hwm", "resume-entries", "resume", "autonomy"} - - -def test_the_menu_screen_renders_every_entry_and_the_keys() -> None: - lines = tc.build_trading_menu_lines(cursor=0) - texts = [line.text for line in lines] - for entry in tc.TRADING_MENU: - assert any(entry.label in t for t in texts), entry.label - assert any("up/k down/j move" in t for t in texts) - assert any("q/Esc/m to the console menu" in t for t in texts) - # exactly one cursor-marked row - marked = [t for t in texts if t.lstrip().startswith(">")] - assert len(marked) == 1 and "agent cycle" in marked[0] - - -def test_the_trading_screens_fit_the_80_column_clip() -> None: - """Every screen the Trading menu renders stays inside the 80-column budget `_paint` - clips at -- wrapped by the builders, never clipped mid-fact.""" - plan = tc.CyclePlan( - db_path="keel.db", - profile_label="paper-forward", - mode="paper", - autonomous=False, - session_line=None, - ) - live_plan = tc.CyclePlan( - db_path="keel-live.db", - profile_label="LIVE", - mode="confirm", - autonomous=True, - session_line=tc.session_honesty_line(True, _recorded_session("closed")), - ) - result = agent.LoopResult( - ts=NOW_TS, - skipped=False, - skip_reason=None, - mode="paper", - polled=12, - products=["BTC-USD", "ETH-USD"], - stale_products=[], - paper_equity=Decimal("12345.67"), - drawdown_total_pct=Decimal("0.01"), - drawdown_weekly_pct=Decimal("0.0"), - ) - monitor_plan = tc.MonitorPlan( - db_path="keel.db", - products=("BTC-USD", "ETH-USD"), - granularities=(Granularity.ONE_HOUR,), - interval_sec=900.0, - session_line=None, - ) - screens = [ - tc.build_trading_menu_lines(cursor=3), - tc.build_cycle_armed_lines(plan), - tc.build_cycle_armed_lines(live_plan), - tc.build_cycle_result_lines(result), - tc.build_monitor_armed_lines(monitor_plan), - tc.build_monitor_result_lines(_fake_monitor_cycle()), - ] - for screen in screens: - for line in screen: - assert len(line.text) <= 80, line.text - - -def test_every_blocking_screen_discloses_what_ctrl_c_does() -> None: - """[review #405] A frozen screen is exactly where an operator reaches for Ctrl-C, - so every blocking surface -- ARMED and held-result, cycle and poll -- must state - plainly, BEFORE the run, what it does: the whole console exits (gracefully) and - any held results are discarded.""" - plan = tc.CyclePlan( - db_path="keel.db", - profile_label="paper-forward", - mode="paper", - autonomous=False, - session_line=None, - ) - monitor_plan = tc.MonitorPlan( - db_path="keel.db", - products=("BTC-USD",), - granularities=(Granularity.ONE_HOUR,), - interval_sec=900.0, - session_line=None, - ) - result = agent.LoopResult( - ts=NOW_TS, skipped=True, skip_reason="market_closed", mode=None, polled=0 - ) - screens = [ - tc.build_cycle_armed_lines(plan), - tc.build_cycle_result_lines(result), - tc.build_monitor_armed_lines(monitor_plan), - tc.build_monitor_result_lines(_fake_monitor_cycle()), - ] - for screen in screens: - joined = "\n".join(line.text for line in screen) - assert "Ctrl-C" in joined - assert "exits the whole console" in joined - assert "discards" in joined - - -def test_the_armed_footers_state_all_three_close_keys() -> None: - """[review #405] `m` is bound on every ARMED screen (the loop's close set is - q/Esc/m) -- the footer must say so, matching the result footers, or the binding is - under-documented on the one screen that waits on it.""" - cycle_plan = tc.CyclePlan( - db_path="keel.db", - profile_label="paper-forward", - mode="paper", - autonomous=False, - session_line=None, - ) - monitor_plan = tc.MonitorPlan( - db_path="keel.db", - products=("BTC-USD",), - granularities=(Granularity.ONE_HOUR,), - interval_sec=900.0, - session_line=None, - ) - for screen in ( - tc.build_cycle_armed_lines(cycle_plan), - tc.build_monitor_armed_lines(monitor_plan), - ): - joined = "\n".join(line.text for line in screen) - assert "q/Esc/m" in joined - assert "Press q or Esc" not in joined - - -def _fake_monitor_cycle() -> Any: - from keel.commands.monitor import MonitorCycle - - return MonitorCycle( - line=f"[{NOW_TS}] polled 3 new candle row(s) across ['BTC-USD']", - session=None, - session_bound=False, - written=3, - ) - - -# -- the session honesty line (display of B1 semantics, no new logic) ------------------------------ - - -def test_a_session_bound_closed_venue_says_the_cycle_will_skip_with_market_closed() -> None: - line = tc.session_honesty_line(True, _recorded_session("closed")) - assert line is not None - assert "market_closed" in line - assert "CLOSED" in line - - -def test_the_clock_unavailable_case_gets_its_own_line() -> None: - line = tc.session_honesty_line(True, _recorded_session("clock_unavailable")) - assert line is not None - assert "market_clock_unavailable" in line - assert "market_closed" not in line.replace("market_clock_unavailable", "") - - -def test_a_247_venue_and_an_open_session_have_no_honesty_line() -> None: - assert tc.session_honesty_line(False, None) is None # 24/7: no session gate at all - assert tc.session_honesty_line(True, _recorded_session("open")) is None - - -def test_a_stale_or_absent_record_names_the_fail_closed_skip() -> None: - stale = agent.RecordedSession( - venue="alpaca", - state="open", - recorded_ts=NOW_TS - 10 * 86400, - interval_sec=900, - next_open_ts=None, - next_close_ts=None, - fresh=False, - ) - for recorded in (None, stale): - line = tc.session_honesty_line(True, recorded) - assert line is not None - assert "market_clock_unavailable" in line - - -# -- the agent cycle: the ARMED confirm step ------------------------------------------------------- - - -def test_the_cycle_plan_names_the_active_profile_and_mode_semantics( - repo: Repository, -) -> None: - repo.set_autonomous(False, NOW_TS) - plan = tc.cycle_plan( - repo, - _config(), - "keel.db", - NOW_TS, - profile_label="paper-forward", - session_bound=False, - recorded=None, - ) - assert plan.mode == "paper" - assert plan.autonomous is False - assert plan.profile_label == "paper-forward" - assert plan.db_path == "keel.db" - assert plan.session_line is None - - -def test_the_armed_cycle_screen_confirms_the_profile_and_paper_semantics() -> None: - plan = tc.CyclePlan( - db_path="keel.db", - profile_label="paper-forward", - mode="paper", - autonomous=False, - session_line=None, - ) - joined = "\n".join(line.text for line in tc.build_cycle_armed_lines(plan)) - assert "ARMED" in joined - assert "paper-forward" in joined - assert "keel.db" in joined - assert "paper" in joined and "SIMULATED" in joined - assert "REAL MONEY" not in joined - - -def test_the_armed_cycle_screen_says_live_unmistakably() -> None: - plan = tc.CyclePlan( - db_path="keel-live.db", - profile_label="LIVE", - mode="confirm", - autonomous=False, - session_line=None, - ) - lines = tc.build_cycle_armed_lines(plan) - joined = "\n".join(line.text for line in lines) - assert "LIVE" in joined and "keel-live.db" in joined - assert "REAL MONEY" in joined - # and the REAL MONEY line carries the alert style -- unmistakable, not just present - assert any("REAL MONEY" in line.text and line.style == "alert" for line in lines) - - -def test_the_armed_cycle_screen_names_the_autonomy_semantics_when_armed() -> None: - plan = tc.CyclePlan( - db_path="keel-live.db", - profile_label="LIVE", - mode="confirm", - autonomous=True, - session_line=None, - ) - joined = "\n".join(line.text for line in tc.build_cycle_armed_lines(plan)) - assert "autonomy" in joined.lower() - assert "NO further prompt" in joined or "without asking" in joined - - -def test_the_armed_cycle_screen_carries_the_session_honesty_line(repo: Repository) -> None: - plan = tc.CyclePlan( - db_path="keel.db", - profile_label="paper-equities", - mode="paper", - autonomous=False, - session_line=tc.session_honesty_line(True, _recorded_session("closed")), - ) - joined = "\n".join(line.text for line in tc.build_cycle_armed_lines(plan)) - assert "market_closed" in joined - - -def test_run_agent_cycle_dispatches_to_run_once_with_the_clis_own_confirm_gate( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """THE dispatch: `agent.run_once` itself, with the SAME `_interactive_confirm` - function `keel agent` hands it -- the console has no order path of its own, so an - in-console cycle IS the agent pipeline (O3/issue #391's acceptance).""" - from keel.commands.confirm import _interactive_confirm - - calls: list[dict[str, Any]] = [] - - def spy_run_once(broker, repo_, config_, **kwargs: Any) -> agent.LoopResult: - calls.append({"broker": broker, "confirm_fn": kwargs.get("confirm_fn")}) - return agent.LoopResult( - ts=NOW_TS, skipped=False, skip_reason=None, mode="paper", polled=0 - ) - - result = tc.run_agent_cycle( - repo, - _config(), - now_ts=NOW_TS, - build_broker=lambda: "the-broker", - run_fn=spy_run_once, - ) - assert calls == [{"broker": "the-broker", "confirm_fn": _interactive_confirm}] - assert result.polled == 0 - - -def test_the_cycle_result_renders_the_services_own_lines_verbatim() -> None: - result = agent.LoopResult( - ts=NOW_TS, - skipped=False, - skip_reason=None, - mode="paper", - polled=7, - products=["BTC-USD"], - stale_products=[], - paper_equity=Decimal("999.99"), - drawdown_total_pct=Decimal("0.02"), - drawdown_weekly_pct=Decimal("0.0"), - ) - lines = tc.build_cycle_result_lines(result) - import re - - from keel.commands.trading import render_loop_result - - # a long cycle line WRAPS to the 80-column budget rather than clipping -- so the - # verbatim pin compares whitespace-normalized: the CONTENT is byte-identical, only - # the fold point may differ - collapsed = re.sub(r"\s+", " ", "\n".join(line.text for line in lines)) - for line in render_loop_result(result): - assert re.sub(r"\s+", " ", line) in collapsed - - -def test_the_cycle_result_renders_a_skips_reason_verbatim() -> None: - result = agent.LoopResult( - ts=NOW_TS, skipped=True, skip_reason="market_closed", mode=None, polled=0 - ) - texts = [line.text.strip() for line in tc.build_cycle_result_lines(result)] - assert f"[{NOW_TS}] skipped: market_closed" in texts - - -def test_the_cycle_result_renders_the_blocked_entries_lines_the_cli_prints() -> None: - blocked = agent.BlockedEntry( - rule_name="tb", - product="BTC-USD", - granularity=Granularity.FIFTEEN_MINUTE, - expected_ts=NOW_TS, - stored_ts=NOW_TS - 900, - reason="stale", - ) - result = agent.LoopResult( - ts=NOW_TS, - skipped=False, - skip_reason=None, - mode="paper", - polled=1, - blocked_entries=[blocked], - ) - joined = "\n".join(line.text for line in tc.build_cycle_result_lines(result)) - assert "blocked:" in joined - assert "needs a confirmed" in joined - - -# -- the monitor poll: one poll, ARMED ------------------------------------------------------------- - - -def test_the_monitor_plan_uses_the_config_the_cli_polls() -> None: - config = _config() - plan = tc.monitor_plan(config, "keel.db", session_bound=False, recorded=None) - assert plan.products == ("BTC-USD", "ETH-USD") # _default_sim_products(config) - assert plan.granularities == (Granularity.ONE_HOUR,) - assert plan.interval_sec == 900.0 - assert plan.db_path == "keel.db" - - -def test_the_armed_monitor_screen_says_what_one_poll_does() -> None: - plan = tc.MonitorPlan( - db_path="keel.db", - products=("BTC-USD",), - granularities=(Granularity.ONE_HOUR,), - interval_sec=900.0, - session_line=None, - ) - joined = "\n".join(line.text for line in tc.build_monitor_armed_lines(plan)) - assert "ARMED" in joined - assert "BTC-USD" in joined - assert "ONE_HOUR" in joined or "1h" in joined or "3600" in joined - # the poll's interval -- the cadence the session record trusts -- renders too - # (review #405: a computed-never-rendered plan field is dead display state) - assert "interval 900" in joined - - -def test_run_monitor_poll_dispatches_to_monitor_cycle(repo: Repository) -> None: - from keel.commands.monitor import MonitorCycle - - calls: list[dict[str, Any]] = [] - - def spy_cycle(broker, repo_, config_, products, granularities, now_ts, interval): - calls.append( - { - "broker": broker, - "products": products, - "granularities": granularities, - "now_ts": now_ts, - "interval": interval, - } - ) - return MonitorCycle( - line=f"[{now_ts}] polled 1 new candle row(s) across {products}", - session=None, - session_bound=False, - written=1, - ) - - config = _config() - cycle = tc.run_monitor_poll( - repo, - config, - now_ts=NOW_TS, - build_broker=lambda: "the-broker", - cycle_fn=spy_cycle, - ) - assert calls == [ - { - "broker": "the-broker", - "products": ["BTC-USD", "ETH-USD"], - "granularities": [Granularity.ONE_HOUR], - "now_ts": NOW_TS, - "interval": 900.0, - } - ] - assert "polled 1" in cycle.line - - -def test_the_monitor_result_renders_the_cycles_line_verbatim() -> None: - lines = tc.build_monitor_result_lines(_fake_monitor_cycle()) - texts = [line.text.strip() for line in lines] - assert f"[{NOW_TS}] polled 3 new candle row(s) across ['BTC-USD']" in texts - - -# -- kill: one key, NO ceremony (the CLI's own contract) ------------------------------------------- - - -def test_kill_dispatches_immediately_and_toasts_the_clis_own_line( - repo: Repository, -) -> None: - repo.set_state("kill_switch", False) - line = tc.run_kill(repo) - assert line == "kill-switch ENGAGED: all trading halted." - assert repo.get_state("kill_switch") is True - - -def test_the_kill_line_is_pinned_to_the_services_single_home(repo: Repository) -> None: - """The line the console toasts IS the constant the CLI prints -- one home, two - front-ends, no drift (the C3 withdrawals-wording fix, applied from the start).""" - from keel.commands import trading - - assert tc.run_kill(repo) == trading.KILL_ENGAGED_LINE - assert trading.KILL_ENGAGED_LINE == "kill-switch ENGAGED: all trading halted." - - -# -- the typed gates: the CLI's own wording, its single home --------------------------------------- - - -def test_the_resume_gate_is_the_clis_own(monkeypatch: pytest.MonkeyPatch) -> None: - """`resume`'s typed gate must be `_require_interactive_confirmation` with the CLI's - own action/detail wording -- pinned against the SHARED constants (`trading.py`, - their one home) and the CLI's own call site -- and it fails CLOSED.""" - import inspect - - import click as click_mod - - import keel.cli as cli_module - import keel.commands._common as common - from keel.commands.trading import RESUME_ACTION, RESUME_DETAIL - - asked: list[tuple[str, str]] = [] - - def refusing_gate(action: str, detail: str) -> None: - asked.append((action, detail)) - raise click_mod.ClickException("aborted (confirmation not given).") - - monkeypatch.setattr(common, "_require_interactive_confirmation", refusing_gate) - assert tc.clis_typed_resume_gate() is False - - def accepting_gate(action: str, detail: str) -> None: - asked.append((action, detail)) - - monkeypatch.setattr(common, "_require_interactive_confirmation", accepting_gate) - assert tc.clis_typed_resume_gate() is True - - assert asked[0] == (RESUME_ACTION, RESUME_DETAIL) - assert asked[0][0] == "disengage the kill-switch" - # and the CLI command itself runs the gate on those same constants - cli_source = inspect.getsource(cli_module.resume.callback) - assert "RESUME_ACTION" in cli_source - assert "RESUME_DETAIL" in cli_source - - -def test_the_resume_entries_gate_is_the_clis_own(monkeypatch: pytest.MonkeyPatch) -> None: - import inspect - - import click as click_mod - - import keel.cli as cli_module - import keel.commands._common as common - from keel.commands.trading import ( - RESUME_ENTRIES_ACTION, - RESUME_ENTRIES_DETAIL, - ) - - asked: list[tuple[str, str]] = [] - - def gate(action: str, detail: str) -> None: - asked.append((action, detail)) - raise click_mod.ClickException("aborted (confirmation not given).") - - monkeypatch.setattr(common, "_require_interactive_confirmation", gate) - assert tc.clis_typed_resume_entries_gate() is False - assert asked[0] == (RESUME_ENTRIES_ACTION, RESUME_ENTRIES_DETAIL) - assert asked[0][0] == "clear the consecutive-loss halt (rail 16)" - cli_source = inspect.getsource(cli_module.resume_entries.callback) - assert "RESUME_ENTRIES_ACTION" in cli_source - - -def test_the_reset_hwm_gate_is_the_clis_own(monkeypatch: pytest.MonkeyPatch) -> None: - import inspect - - import click as click_mod - - import keel.cli as cli_module - import keel.commands._common as common - from keel.commands.trading import RESET_HWM_ACTION, RESET_HWM_DETAIL - - asked: list[tuple[str, str]] = [] - - def gate(action: str, detail: str) -> None: - asked.append((action, detail)) - raise click_mod.ClickException("aborted (confirmation not given).") - - monkeypatch.setattr(common, "_require_interactive_confirmation", gate) - assert tc.clis_typed_reset_hwm_gate() is False - assert asked[0] == (RESET_HWM_ACTION, RESET_HWM_DETAIL) - assert asked[0][0] == "reset rail 11's high-water mark" - cli_source = inspect.getsource(cli_module.reset_hwm.callback) - assert "RESET_HWM_ACTION" in cli_source - - -def test_the_record_flow_gate_carries_the_amount_in_its_action_phrase( - monkeypatch: pytest.MonkeyPatch, -) -> None: - import keel.commands._common as common - from keel.commands.trading import RECORD_FLOW_DETAIL - - asked: list[tuple[str, str]] = [] - - def gate(action: str, detail: str) -> None: - asked.append((action, detail)) - - monkeypatch.setattr(common, "_require_interactive_confirmation", gate) - assert tc.clis_typed_record_flow_gate("-250") is True - assert asked[0][0] == "rebase rail 11's high-water mark by -250" - assert asked[0][1] == RECORD_FLOW_DETAIL - - -# -- the typed forms: a declined gate writes NOTHING ----------------------------------------------- - - -def test_resume_form_refusal_writes_nothing_and_the_success_line_is_the_clis( - repo: Repository, -) -> None: - spy = _RecordingRepo(repo) - result = tc.run_resume_form(spy, gate_fn=lambda: False) - assert spy.calls == [] - assert "not given" in result or "cancelled" in result.lower() - - result = tc.run_resume_form(spy, gate_fn=lambda: True) - assert ("set_state", {"key": "kill_switch", "value": False}) in spy.calls - assert result == "kill-switch disengaged: trading resumed." - - -def test_resume_entries_form_refusal_writes_nothing_and_success_clears_rail_16( - repo: Repository, -) -> None: - spy = _RecordingRepo(repo) - repo.set_state("streak_halt_until", 2_000_000_000) - repo.set_state("consecutive_losses", 3) - - result = tc.run_resume_entries_form(spy, gate_fn=lambda: False) - assert spy.calls == [] - assert repo.get_state("streak_halt_until") == 2_000_000_000 - assert "not given" in result or "cancelled" in result.lower() - - result = tc.run_resume_entries_form(spy, gate_fn=lambda: True) - assert ("set_state", {"key": "streak_halt_until", "value": 0}) in spy.calls - assert ("set_state", {"key": "consecutive_losses", "value": 0}) in spy.calls - assert result == "consecutive-loss breaker cleared: new entries permitted." - - -def test_reset_hwm_form_refusal_writes_nothing(repo: Repository) -> None: - spy = _RecordingRepo(repo) - repo.set_state("equity_high_water_mark", Decimal("15000")) - - result = tc.run_reset_hwm_form(spy, gate_fn=lambda: False) - assert spy.calls == [] - assert repo.get_state("equity_high_water_mark") == Decimal("15000") - - result = tc.run_reset_hwm_form(spy, gate_fn=lambda: True) - assert repo.get_state("equity_high_water_mark") is None - assert repo.get_state("drawdown_total_pct") == Decimal("0") - assert result == ( - "equity high-water mark reset: it will re-seed from the next cycle's equity." - ) - - -def test_record_flow_form_gates_before_validating_and_writes_nothing_on_a_refusal() -> None: - """The CLI's own ORDER: the typed gate (naming the raw amount) comes FIRST, then the - validation, then the write -- so a declined gate means no parse, no write, nothing.""" - spy = _RecordingRepo(repo) - gated: list[str] = [] - - def decline(amount: str) -> bool: - gated.append(amount) - return False - - result = tc.run_record_flow_form(spy, _prompt(["nan"]), gate_fn=decline) - assert gated == ["nan"] - assert spy.calls == [] - assert "not given" in result or "cancelled" in result.lower() - - -def test_record_flow_form_surfaces_the_clis_own_validation_errors_verbatim( - repo: Repository, -) -> None: - from keel.commands.trading import parse_flow_amount - - spy = _RecordingRepo(repo) - result = tc.run_record_flow_form( - spy, _prompt(["abc"]), gate_fn=lambda _amount: True - ) - assert spy.calls == [] - assert "--amount must be a number, got 'abc'" in result - - result = tc.run_record_flow_form( - spy, _prompt(["nan"]), gate_fn=lambda _amount: True - ) - assert spy.calls == [] - assert "--amount must be a finite number, got 'nan'" in result - - with pytest.raises(ValueError, match="must be a number"): - parse_flow_amount("abc") - with pytest.raises(ValueError, match="must be a finite number"): - parse_flow_amount("inf") - - -def test_record_flow_form_records_the_flow_and_renders_the_clis_own_lines( - repo: Repository, -) -> None: - repo.set_state("equity_high_water_mark", Decimal("10000")) - result = tc.run_record_flow_form( - repo, _prompt(["500"]), gate_fn=lambda _amount: True - ) - assert "flow of 500 recorded" in result - assert "High-water mark rebased to 10500" in result # 10000 + the 500 deposit - assert repo.get_state("equity_high_water_mark") == Decimal("10500") - - -def test_record_flow_on_a_repo_with_no_mark_yet_gets_the_clis_own_no_mark_line( - repo: Repository, -) -> None: - result = tc.run_record_flow_form( - repo, _prompt(["500"]), gate_fn=lambda _amount: True - ) - assert "flow of 500 recorded" in result - assert "No high-water mark yet" in result - assert "next cycle will seed it" in result - - -def test_record_flow_form_cancels_on_an_empty_amount(repo: Repository) -> None: - spy = _RecordingRepo(repo) - result = tc.run_record_flow_form(spy, _prompt([""]), gate_fn=lambda: True) - assert spy.calls == [] - assert "cancelled" in result.lower() - - -# -- autonomy: the CLI's own semantics ------------------------------------------------------------- - - -def test_autonomy_on_requires_the_clis_typed_gate_and_writes_nothing_on_a_refusal( - repo: Repository, -) -> None: - spy = _RecordingRepo(repo) - result = tc.run_autonomy_form(spy, _config(), _prompt(["on"]), NOW_TS, arm_gate=lambda: False) - assert spy.calls == [] - assert "not given" in result or "cancelled" in result.lower() - - -def test_autonomy_on_arms_with_no_expiry_and_the_clis_own_warning( - repo: Repository, -) -> None: - spy = _RecordingRepo(repo) - result = tc.run_autonomy_form(spy, _config(), _prompt(["on"]), NOW_TS, arm_gate=lambda: True) - assert ("set_autonomous", {"value": True, "now_ts": NOW_TS, "expires_ts": None}) in ( - spy.calls - ) - assert "autonomy ON, with NO expiry" in result - assert "--for-hours" in result - - -def test_autonomy_off_is_ungated_and_immediate(repo: Repository) -> None: - repo.set_autonomous(True, NOW_TS) - gated: list[bool] = [] - - def gate() -> bool: - gated.append(True) - return True - - spy = _RecordingRepo(repo) - result = tc.run_autonomy_form(spy, _config(), _prompt(["off"]), NOW_TS, arm_gate=gate) - assert gated == [] # OFF only ever reduces capability -- no ceremony - # the call shape is the CLI's own (`autonomy off` passes no expiry at all) - assert ("set_autonomous", {"value": False, "now_ts": NOW_TS}) in spy.calls - assert result == "autonomy off: every order will ask for confirmation." - - -def test_the_autonomy_arm_gate_is_the_clis_own_extracted_gate( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """The ON direction's gate is `autonomy_on`'s OWN typed gate, extracted to - `keel.commands.autonomy` (its one home) -- pinned here against the CLI's call site, - with the CLI's exact action wording and mode/allowlist facts.""" - import inspect - - import click as click_mod - - from keel.commands import autonomy as autonomy_mod - from keel.commands.autonomy import autonomy_on - - asked: list[tuple[str, str]] = [] - - def gate(action: str, detail: str) -> None: - asked.append((action, detail)) - - # `autonomy_on_gate` resolves its gate through the copy `autonomy.py` imported at - # module load -- the patch lands where the call will actually resolve it - monkeypatch.setattr( - autonomy_mod, "_require_interactive_confirmation", gate, raising=True - ) - assert tc.clis_autonomy_on_gate(_config()) is True - assert asked[0][0] == "turn autonomy ON" - assert "mode=paper" in asked[0][1] - assert "allowlist=['BTC', 'ETH']" in asked[0][1] - assert "until you turn it off" in asked[0][1] # no --for-hours: the CLI default window - - # and the CLI's `autonomy on` runs the SAME extracted gate -- one home, two front-ends - source = inspect.getsource(autonomy_on.callback) - assert "autonomy_on_gate" in source - - def refusing(action: str, detail: str) -> None: - raise click_mod.ClickException("aborted") - - monkeypatch.setattr(autonomy_mod, "_require_interactive_confirmation", refusing) - assert tc.clis_autonomy_on_gate(_config()) is False # fails CLOSED - - -# -- the loop wiring (fake curses): ARMED gating and the typed proofs end-to-end ------------------- - - -def _fake_curses_mod(monkeypatch: pytest.MonkeyPatch, stdscr: Any) -> Any: - from tests.commands.test_tui import _fake_curses - - fake = _fake_curses() - fake.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake) - return fake - - -def _binding(repo: Repository, config: Config) -> Any: - import click - - from keel.commands.console import ConsoleBinding - - ctx = click.Context( - click.Command("tui"), obj={"config_path": "config.yaml", "db_path": "keel.db"} - ) - binding = ConsoleBinding(ctx, config_path="config.yaml", db_path="keel.db") - binding.open_state = lambda: (repo, config) # type: ignore[method-assign] - return binding - - -def _drive( - repo: Repository, - config: Config, - keys: list[int], - monkeypatch: pytest.MonkeyPatch | None = None, -) -> Any: - from keel.commands import tui as tui_mod - from tests.commands.test_tui import _KeySequenceStdscr - - stdscr = _KeySequenceStdscr(height=30, width=120, keys=keys) - if monkeypatch is not None: - _fake_curses_mod(monkeypatch, stdscr) - binding = _binding(repo, config) - tui_mod.run_live(binding.open_state, lambda: NOW_TS, interval=0.01, console_binding=binding) - return stdscr - - -def test_run_live_trading_menu_opens_from_the_console_menu_and_esc_steps_back( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - stdscr = _drive(repo, _config(), [ord("m"), ord("3"), -1, 27, -1, 27], monkeypatch) - painted = [call[2] for call in stdscr.calls] - trading_idx = next( - i for i, t in enumerate(painted) if "keel console -- trading" in t - ) - trading_text = "\n".join(painted[trading_idx:]) - for label in ( - "agent cycle", - "monitor poll", - "autonomy", - "record-flow", - "reset-hwm", - "resume-entries", - "kill", - "resume", - ): - assert label in trading_text, label - assert any("keel console -- menu" in t for t in painted[trading_idx:]) - - -def test_run_live_the_cycle_entry_is_armed_until_enter( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """THE confirm gate: opening Trading -> agent cycle, polling, and closing must never - invoke `agent.run_once` -- the cycle happens only on an explicit Enter.""" - runs: list[int] = [] - - def spy_run(*args: Any, **kwargs: Any) -> agent.LoopResult: - runs.append(1) - return agent.LoopResult( - ts=NOW_TS, skipped=True, skip_reason="market_closed", mode=None, polled=0 - ) - - monkeypatch.setattr(tc, "run_agent_cycle", spy_run) - # m -> menu; 3 -> Trading; 1 -> agent cycle (ARMED); poll; Esc closes; q quits. - stdscr = _drive( - repo, _config(), [ord("m"), ord("3"), ord("1"), -1, 27], monkeypatch - ) - assert runs == [] - painted = [call[2] for call in stdscr.calls] - assert any("ARMED" in t for t in painted) - - -def test_run_live_enter_runs_exactly_one_cycle_and_holds_the_rendered_result( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - runs: list[int] = [] - - def spy_run(*args: Any, **kwargs: Any) -> agent.LoopResult: - runs.append(1) - return agent.LoopResult( - ts=NOW_TS, - skipped=False, - skip_reason=None, - mode="paper", - polled=4, - products=["BTC-USD"], - paper_equity=Decimal("100.00"), - drawdown_total_pct=Decimal("0.0"), - drawdown_weekly_pct=Decimal("0.0"), - ) - - monkeypatch.setattr(tc, "run_agent_cycle", spy_run) - # m; 3 -> Trading; 1 -> cycle; Enter RUNS; poll repaints the held result; Esc; q. - stdscr = _drive( - repo, _config(), [ord("m"), ord("3"), ord("1"), 10, -1, 27], monkeypatch - ) - assert runs == [1] - painted = "\n".join(call[2] for call in stdscr.calls) - assert f"[{NOW_TS}] mode=paper" in painted - - -def test_run_live_a_closed_sessions_skip_renders_verbatim_through_the_loop( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """Session honesty, held honestly: a cycle that skips renders the skip's logged - reason VERBATIM -- `render_loop_result`'s own line, nothing re-worded.""" - monkeypatch.setattr( - tc, - "run_agent_cycle", - lambda *a, **k: agent.LoopResult( - ts=NOW_TS, skipped=True, skip_reason="market_closed", mode=None, polled=0 - ), - ) - stdscr = _drive( - repo, _config(), [ord("m"), ord("3"), ord("1"), 10, -1, 27], monkeypatch - ) - painted = "\n".join(call[2] for call in stdscr.calls) - assert f"[{NOW_TS}] skipped: market_closed" in painted - - -def test_run_live_the_monitor_poll_is_armed_until_enter( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - from keel.commands.monitor import MonitorCycle - - polls: list[int] = [] - - def spy_poll(*args: Any, **kwargs: Any) -> MonitorCycle: - polls.append(1) - return MonitorCycle( - line=f"[{NOW_TS}] polled 2 new candle row(s) across ['BTC-USD']", - session=None, - session_bound=False, - written=2, - ) - - monkeypatch.setattr(tc, "run_monitor_poll", spy_poll) - # m; 3 -> Trading; 2 -> monitor poll (ARMED); poll; Esc; q -- NOTHING ran. - stdscr = _drive( - repo, _config(), [ord("m"), ord("3"), ord("2"), -1, 27], monkeypatch - ) - assert polls == [] - # ...and Enter runs exactly one poll, holding the cycle's line - stdscr = _drive( - repo, _config(), [ord("m"), ord("3"), ord("2"), 10, -1, 27], monkeypatch - ) - assert polls == [1] - painted = "\n".join(call[2] for call in stdscr.calls) - assert "polled 2 new candle row(s)" in painted - - -def test_run_live_resume_refusal_writes_nothing_end_to_end( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """C3's withdrawals proof, applied to `resume`: driving the typed form through the - LIVE loop with the gate REFUSING must leave the kill-switch engaged -- zero state - writes, spy-proven -- and the cancellation toasts on the Trading menu.""" - import click as click_mod - - repo.set_state("kill_switch", True) - - def refusing_gate(action: str, detail: str) -> None: - raise click_mod.ClickException("aborted (confirmation not given).") - - monkeypatch.setattr( - "keel.commands._common._require_interactive_confirmation", refusing_gate - ) - # m; 3 -> Trading; 8 -> resume (the typed form runs at the terminal); poll; q. - stdscr = _drive( - repo, _config(), [ord("m"), ord("3"), ord("8"), -1, ord("q")], monkeypatch - ) - assert repo.get_state("kill_switch") is True # the halt was NOT released - painted = "\n".join(call[2] for call in stdscr.calls) - assert "not given" in painted or "aborted" in painted - # the suspend/restore dance ran around the form (the fake curses records it) - fake_curses = sys.modules["curses"] - assert "def_prog_mode" in fake_curses.calls - assert "reset_prog_mode" in fake_curses.calls - - -def test_run_live_resume_with_the_typed_yes_releases_the_halt( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - repo.set_state("kill_switch", True) - monkeypatch.setattr( - "keel.commands._common._is_interactive", lambda: True - ) - import click - - answers = iter(["yes"]) - monkeypatch.setattr(click, "prompt", lambda text, **kw: next(answers), raising=True) - stdscr = _drive( - repo, _config(), [ord("m"), ord("3"), ord("8"), -1, ord("q")], monkeypatch - ) - assert repo.get_state("kill_switch") is False - painted = "\n".join(call[2] for call in stdscr.calls) - assert "kill-switch disengaged: trading resumed." in painted - - -def test_run_live_resume_entries_refusal_writes_nothing_end_to_end( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - import click as click_mod - - repo.set_state("streak_halt_until", 2_000_000_000) - repo.set_state("consecutive_losses", 4) - - def refusing_gate(action: str, detail: str) -> None: - raise click_mod.ClickException("aborted (confirmation not given).") - - monkeypatch.setattr( - "keel.commands._common._require_interactive_confirmation", refusing_gate - ) - # m; 3 -> Trading; 6 -> resume-entries; poll; q. - stdscr = _drive( - repo, _config(), [ord("m"), ord("3"), ord("6"), -1, ord("q")], monkeypatch - ) - assert repo.get_state("streak_halt_until") == 2_000_000_000 - assert repo.get_state("consecutive_losses") == 4 - painted = "\n".join(call[2] for call in stdscr.calls) - assert "not given" in painted or "aborted" in painted - - -def test_run_live_kill_engages_from_the_menu_with_no_ceremony( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """kill's CLI contract is one key and NO confirmation -- selecting the entry - dispatches immediately; the toast is the CLI's own line.""" - repo.set_state("kill_switch", False) - # m; 3 -> Trading; 7 -> kill (engages, no prompts at all); poll; q. - stdscr = _drive( - repo, _config(), [ord("m"), ord("3"), ord("7"), -1, ord("q")], monkeypatch - ) - assert repo.get_state("kill_switch") is True - painted = "\n".join(call[2] for call in stdscr.calls) - assert "kill-switch ENGAGED: all trading halted." in painted - - -def test_run_live_the_trading_menu_scrolls_banner_aware( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """Banner-aware scrolling: `End` on the Trading menu reaches the LAST entry's rows - (the banner is part of the scrolled list, so the total must count it).""" - from tests.commands.test_tui import _fake_curses - - fake = _fake_curses() - # m; 3 -> Trading; End; poll; q. - stdscr = _drive( - repo, _config(), [ord("m"), ord("3"), fake.KEY_END, -1, ord("q")], monkeypatch - ) - painted = [call[2] for call in stdscr.calls] - after = painted[painted.index(next(t for t in painted if "keel console -- trading" in t)) :] - assert any("resume" in t for t in after) - - -def test_run_live_the_cycle_dispatch_suspends_curses_around_the_mid_cycle_confirm_gate( - repo: Repository, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - """[review #405, major] On a confirm-mode profile the cycle itself asks at the - terminal MID-RUN: the CLI's own `_interactive_confirm` echo's the order preview and - reads a y (confirm.py: "the rendered string is the contract"). Dispatched inside - the curses loop that prompt garbles the screen and the y is typed BLIND under - noecho -- so the dispatch must ride the SAME suspend/restore dance the terminal - forms use, spy-proven BY ORDER on the fake curses' one timeline: the suspend pair - strictly before the gate asks, the restore strictly after it answers, and the held - result still renders cleanly through the loop.""" - from keel.commands import confirm as confirm_mod - - real_cycle = tc.run_agent_cycle - - def scripted_run(*args: Any, confirm_fn: Any = None, **kwargs: Any) -> agent.LoopResult: - # The executor's own mid-cycle ask: the gate the CLI hands `agent.run_once`. - assert confirm_fn is not None - answered = confirm_fn({"order_total": "10.00"}) - assert answered is True # the y went through, cleanly - return agent.LoopResult( - ts=NOW_TS, skipped=False, skip_reason=None, mode="confirm", polled=1 - ) - - monkeypatch.setattr( - tc, "run_agent_cycle", lambda *a, **k: real_cycle(*a, **k, run_fn=scripted_run) - ) - - answers = iter(["y"]) - - def stdout_gate(preview: Any) -> bool: - # The CLI gate's own shape: WRITES the preview, READS the y -- at the terminal, - # which is only sane while curses is suspended. Recorded on the fake curses' - # call timeline so the order vs. the dance is assertable. - print("Rails PASSED. Order preview:") - print(f" order_total: {preview['order_total']}") - sys.modules["curses"].calls.append("gate:asked") - return next(answers).strip().lower() == "y" - - monkeypatch.setattr(confirm_mod, "_interactive_confirm", stdout_gate) - - # m; 3 -> Trading; 1 -> cycle (ARMED); Enter RUNS (the gate asks mid-run); poll; - # Esc; q. - stdscr = _drive( - repo, _config(), [ord("m"), ord("3"), ord("1"), 10, -1, 27], monkeypatch - ) - - timeline: list[str] = sys.modules["curses"].calls - gate_at = timeline.index("gate:asked") - # the suspend pair ran BEFORE the gate asked -- and nothing sits between the - # suspend and the ask (the screen was handed over before the first write) - assert timeline[:gate_at][-1] == "endwin" - assert "def_prog_mode" in timeline[:gate_at] - # ...and the restore ran only AFTER the gate answered - assert "reset_prog_mode" not in timeline[:gate_at] - assert "reset_prog_mode" in timeline[gate_at:] - # the gate's write reached a sane stdout, and the cycle's held result renders - assert "Order preview" in capsys.readouterr().out - painted = "\n".join(call[2] for call in stdscr.calls) - assert f"[{NOW_TS}] mode=confirm polled=1" in painted - - -def test_run_live_re_entering_the_trading_menu_resets_the_cursor_to_the_top( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """[review #405] A remembered cursor is a loaded one: `trading_cursor` used to - persist across sub-menu re-entries, so leaving Trading with the row on kill and - re-entering meant a replayed Enter engaged the halt with no ceremony. The shared - reset point puts every console sub-menu back on its TOP row on (re)entry -- so the - second Enter of a double-tap lands on the first entry (the ARMED cycle view), never - on kill. The one-key contract itself is unchanged (kill still engages immediately - when its row is chosen on purpose -- tested above).""" - from tests.commands.test_tui import _fake_curses - - down = _fake_curses().KEY_DOWN - # m -> menu; 3 -> Trading; j x6 -> cursor on kill (row 7); q -> back to the menu; - # 3 -> RE-ENTER Trading (cursor must reset); poll; Enter -> the TOP entry's - # destination; poll; Esc -> back to Trading; then out and quit. - stdscr = _drive( - repo, - _config(), - [ - ord("m"), - ord("3"), - *([down] * 6), - ord("q"), - ord("3"), - -1, - 10, - -1, - 27, - ], - monkeypatch, - ) - painted = [call[2] for call in stdscr.calls] - marked = [(i, t) for i, t in enumerate(painted) if t.startswith(">")] - kill_marked = [i for i, t in marked if "kill" in t] - cycle_marked = [i for i, t in marked if "agent cycle" in t] - assert kill_marked # the cursor really did reach kill before leaving - assert cycle_marked - # after re-entry the TOP row is the marked one again -- never kill - assert max(cycle_marked) > max(kill_marked) - # ...so the replayed Enter opened the cycle's ARMED view, not kill's halt - after_reentry = painted[max(kill_marked) :] - assert any("ARMED -- nothing has run yet." in t for t in after_reentry) - assert not any("kill-switch ENGAGED" in t for t in after_reentry) - assert repo.get_state("kill_switch") is False diff --git a/tests/commands/test_tui.py b/tests/commands/test_tui.py deleted file mode 100644 index 3173a63c..00000000 --- a/tests/commands/test_tui.py +++ /dev/null @@ -1,4369 +0,0 @@ -"""Tests for `keel tui` -- the live, read-only, full-screen operator dashboard. - -`keel tui` is a *view* over `keel status`'s own report: it must not re-derive Rail 11, -freshness, or autonomy logic, only style `StatusReport` into `ScreenLine`s. Mirrors -`tests/commands/test_status.py`'s fixture style (in-memory `Repository`, `_config` helper, -`NOW_TS` constant), plus the pure `build_screen`/`_freshness_style`/`render_plain`/`_paint`/ -`run_once` seams that make the interactive `run_live` loop thin, untested I/O. -""" - -from __future__ import annotations - -import json -import os -import sqlite3 -import sys -import time -from decimal import Decimal -from types import SimpleNamespace -from typing import Any - -import click -import pytest -from click.testing import CliRunner - -import keel.commands.tui as tui_mod -from keel.cli import cli -from keel.commands.activity import ( - ACTIVITY_HEADER, - ActivityFeed, - apply_scope, - feed_from_lines, - scope_start_ts, -) -from keel.commands.admission import DiscoverReport -from keel.commands.insights import ( - AccountSummary as InsightsAccountSummary, -) -from keel.commands.insights import ( - GateDistance, - InsightsReport, - JournalEntry, - JournalReport, - RuleTrackRecord, -) -from keel.commands.status import ( - AutonomyStatus, - MarketSessionStatus, - OpenPositionStatus, - ProductFreshness, - RuleSummary, - StatusReport, - SubscriptionStatusRow, - WithdrawalAttestationStatus, -) -from keel.commands.tui import ( - _BALANCE_TIMEOUT_SEC, - _CYCLE_RUN_NOTICE, - _DISCOVER_TIMEOUT_SEC, - _FETCH_RUN_NOTICE, - _MONITOR_RUN_NOTICE, - _REFRESH_MESSAGE, - _SHORT_VERSION, - CTRL_C_DISCLOSURE, - AvailableBalance, - ScreenLine, - _activity_cursor, - _activity_lines, - _admission_line_style, - _available_lines, - _confirm_arm_autonomy, - _follow_cursor, - _footer_lines, - _freshness_style, - _guarded, - _human_dt, - _message_style, - _paint, - _refresh_balance, - _run_notice_lines, - _scroll_offset, - _short_version, - _stdio_is_interactive, - _style_attrs, - _visible_slice, - build_activity_overlay, - build_admission_screen_overlay, - build_discover_overlay, - build_help_screen, - build_insights_screen, - build_propose_overlay, - build_screen, - render_plain, - run_live, - run_once, - toggle_autonomy, - tui_cmd, -) -from keel.compliance import screen as screen_mod -from keel.config import ( - AutoTradeConfig, - Caps, - Config, - DcaConfig, - LoggingConfig, - MarketDataConfig, - MoneyMgmtConfig, -) -from keel.data.db import connect, migrate -from keel.data.repository import Repository -from keel.types import Candle, Granularity - -NOW_TS = 1_800_000_000 - - -@pytest.fixture -def repo() -> Repository: - conn = connect(":memory:") - migrate(conn) - r = Repository(conn) - r.set_state("kill_switch", False) - return r - - -def _config(**overrides: Any) -> Config: - base: dict[str, Any] = dict( - allowlist=["BTC", "ETH"], - target_weights={}, - risk_pct=Decimal("0.01"), - caps=Caps( - max_per_order_usd=Decimal("100000"), - max_per_day_usd=Decimal("300000"), - max_exposure_usd=Decimal("1000000"), - max_per_asset_pct=Decimal("1"), - ), - market_data=MarketDataConfig( - granularities=[Granularity.ONE_DAY, Granularity.ONE_HOUR], history_days=365 - ), - auto_trade=AutoTradeConfig(mode="paper", interval_sec=900), - money_mgmt=MoneyMgmtConfig( - max_total_dd_pct=Decimal("0.20"), max_weekly_dd_pct=Decimal("0.08") - ), - dca=DcaConfig(budget_usd=Decimal("50"), cadence_days=7), - ) - base.update(overrides) - return Config(**base) - - -def _base_report(**overrides: Any) -> StatusReport: - base: dict[str, Any] = dict( - now_ts=NOW_TS, - mode="paper", - kill_switch_engaged=False, - autonomy=AutonomyStatus( - live=False, - autonomous=False, - autonomous_until=None, - updated_ts=None, - profile_readable=True, - ), - equity_state_mode="paper", - high_water_mark=Decimal("10000"), - drawdown_total_pct=Decimal("0.05"), - drawdown_weekly_pct=Decimal("0.01"), - max_total_dd_pct=Decimal("0.20"), - max_weekly_dd_pct=Decimal("0.08"), - rail11_status="ok", - withdrawal_attestation=WithdrawalAttestationStatus( - state="attested", - enabled=True, - attested_at=NOW_TS - 86400, - expires_in_sec=6 * 86400, - expired_for_sec=None, - ), - paper_cash_usdc=Decimal("955.25"), - open_positions=[], - rule_counts={}, - live_rules=[], - data_freshness=[], - subscriptions=[], - ) - base.update(overrides) - return StatusReport(**base) - - -# -- build_screen: sections present ------------------------------------------------------------ - - -def test_build_screen_includes_mode_and_now() -> None: - report = _base_report(mode="paper") - lines = build_screen(report, NOW_TS) - texts = [line.text for line in lines] - assert any("paper" in t for t in texts) - - -def test_build_screen_includes_kill_switch() -> None: - report = _base_report(kill_switch_engaged=True) - lines = build_screen(report, NOW_TS) - texts = [line.text.lower() for line in lines] - assert any("kill" in t and "engaged" in t for t in texts) - - -def test_build_screen_includes_hwm_and_drawdown() -> None: - report = _base_report(high_water_mark=Decimal("12345.6")) - lines = build_screen(report, NOW_TS) - texts = " ".join(line.text for line in lines) - assert "12345.6" in texts - assert "drawdown" in texts.lower() - - -def test_build_screen_includes_paper_cash_in_paper_mode() -> None: - report = _base_report(mode="paper", paper_cash_usdc=Decimal("42.00")) - lines = build_screen(report, NOW_TS) - texts = " ".join(line.text for line in lines) - assert "42.00" in texts - - -def test_build_screen_omits_paper_cash_outside_paper_mode() -> None: - report = _base_report(mode="confirm", paper_cash_usdc=None) - lines = build_screen(report, NOW_TS) - texts = " ".join(line.text.lower() for line in lines) - assert "paper_cash" not in texts - - -def test_build_screen_includes_each_open_position() -> None: - pos = OpenPositionStatus( - id=1, - product_id="BTC-USD", - rule_name="turtle_breakout", - qty=Decimal("0.01"), - entry_price=Decimal("65000"), - opened_at=NOW_TS - 3600, - has_bracket=True, - ) - report = _base_report(open_positions=[pos]) - lines = build_screen(report, NOW_TS) - texts = " ".join(line.text for line in lines) - assert "BTC-USD" in texts - assert "turtle_breakout" in texts - assert "0.01" in texts - assert "65000" in texts - - -def test_build_screen_includes_rule_counts_and_live_rules() -> None: - rule = RuleSummary( - id=7, kind="turtle_breakout", status="live", product_id="BTC-USD", params={"lookback": 20} - ) - report = _base_report(rule_counts={"live": 1, "candidate": 2}, live_rules=[rule]) - lines = build_screen(report, NOW_TS) - texts = " ".join(line.text for line in lines) - assert "live=1" in texts - assert "candidate=2" in texts - assert "turtle_breakout" in texts - - -def test_build_screen_includes_each_freshness_row() -> None: - freshness = [ - ProductFreshness("BTC-USD", "ONE_HOUR", NOW_TS - 3600, 3600), - ProductFreshness("ETH-USD", None, None, None), - ] - report = _base_report(data_freshness=freshness) - lines = build_screen(report, NOW_TS) - texts = " ".join(line.text for line in lines) - assert "BTC-USD" in texts - assert "ETH-USD" in texts - - -def test_build_screen_includes_subscriptions() -> None: - sub = SubscriptionStatusRow( - venue="coinbase", - tier_name="Preferred", - pacing="opportunistic", - stored_status="active", - effective_status="active", - effective_cap=Decimal("1000"), - ) - report = _base_report(subscriptions=[sub]) - lines = build_screen(report, NOW_TS) - texts = " ".join(line.text for line in lines) - assert "coinbase" in texts - assert "Preferred" in texts - - -def test_build_screen_footer_is_present_and_interval_independent() -> None: - """The footer is now TWO lines (`_footer_lines`); `build_screen` appends both as its last two - rows, and both must carry the original keybinding hints -- the second line does not replace - the first, it adds the admission keys the first line had no room for.""" - report = _base_report() - lines = build_screen(report, NOW_TS) - footer = lines[-2:] - assert all(line.style == "muted" for line in footer) - joined = " ".join(line.text.lower() for line in footer) - assert "quit" in joined - assert "help" in joined - - -def test_footer_lines_contains_keybinding_hints() -> None: - """The FIRST footer line is kept byte-for-byte as it was before the admission overlays - existed (see `_footer_lines`'s docstring) -- every hint that used to live in the single line - must still be found there, not merely somewhere across the two lines.""" - lines = _footer_lines() - assert len(lines) == 2 - first = lines[0] - assert first.style == "muted" - text = first.text.lower() - for hint in ("quit", "help", "refresh", "autonomy", "fetch", "insights"): - assert hint in text - - -def test_footer_lines_second_line_documents_admission_keys() -> None: - lines = _footer_lines() - second = lines[1] - assert second.style == "muted" - text = second.text.lower() - for hint in ("screen", "propose", "discover"): - assert hint in text - - -# -- available-to-buy balance (v3) --------------------------------------------------------------- - - -def test_available_lines_none_is_empty() -> None: - assert _available_lines(None) == [] - - -def test_available_lines_with_amount_is_ok_style_and_informative() -> None: - available = AvailableBalance(Decimal("10234.5"), "USDC", NOW_TS, None) - lines = _available_lines(available) - assert len(lines) == 1 - line = lines[0] - assert line.style == "ok" - assert "live account" in line.text - assert "10,234.50" in line.text - assert "USDC" in line.text - - -def test_available_lines_without_updated_ts_says_unknown_rather_than_now() -> None: - """Same silent-"now" hazard as the autonomy line, on a FRESHNESS stamp. - - `updated_ts` is a separate field from `amount` and can be absent while the amount is - present. A staleness marker that quietly reports the current instant is worse than one that - admits it does not know, because an operator reads it as "just refreshed". - """ - available = AvailableBalance(Decimal("10234.5"), "USDC", None, None) - lines = _available_lines(available) - assert len(lines) == 1 - line = lines[0] - assert line.style == "ok" - assert "10,234.50" in line.text - assert "unknown" in line.text - assert _human_dt(NOW_TS) not in line.text - - -def test_available_lines_with_error_is_warn_style() -> None: - available = AvailableBalance(None, "USDC", NOW_TS, "no USDC balance") - lines = _available_lines(available) - assert len(lines) == 1 - line = lines[0] - assert line.style == "warn" - assert "unavailable" in line.text - assert "no USDC balance" in line.text - - -def test_build_screen_includes_available_line_after_equity_before_positions() -> None: - report = _base_report() - available = AvailableBalance(Decimal("500.00"), "USDC", NOW_TS, None) - lines = build_screen(report, NOW_TS, available=available) - texts = [line.text for line in lines] - available_idx = next(i for i, t in enumerate(texts) if "live account" in t) - rail11_idx = next(i for i, t in enumerate(texts) if "rail11" in t.lower()) - positions_idx = next(i for i, t in enumerate(texts) if "open positions" in t.lower()) - assert rail11_idx < available_idx < positions_idx - - -def test_build_screen_without_available_has_no_available_line() -> None: - """Default (no `available` kwarg) renders exactly as before -- protects `--once`.""" - report = _base_report() - lines = build_screen(report, NOW_TS) - texts = " ".join(line.text.lower() for line in lines) - assert "live account" not in texts - - -def test_refresh_balance_returns_amount_on_success() -> None: - config = _config() - - def open_state() -> tuple[Any, Config]: - return object(), config - - result = _refresh_balance(open_state, lambda: NOW_TS, lambda cfg: Decimal("123.45")) - - assert result.amount == Decimal("123.45") - assert result.quote == config.quote_currency - assert result.updated_ts == NOW_TS - assert result.error is None - - -def test_refresh_balance_none_amount_is_error_mentioning_quote_not_false_no_balance() -> None: - """`balance_fn` returning `None` covers BOTH "no matching account" and a swallowed - broker/auth/network error -- the message must not assert "no balance", which would wrongly - tell an operator a deposit never landed when the real cause could be unrelated.""" - config = _config() - - def open_state() -> tuple[Any, Config]: - return object(), config - - result = _refresh_balance(open_state, lambda: NOW_TS, lambda cfg: None) - - assert result.amount is None - assert result.quote == config.quote_currency - assert result.updated_ts == NOW_TS - assert result.error is not None - assert config.quote_currency in result.error - assert "unreadable" in result.error - assert "no " + config.quote_currency + " balance" != result.error - - -def test_refresh_balance_raising_balance_fn_is_contained() -> None: - def open_state() -> tuple[Any, Config]: - return object(), _config() - - def _raise(cfg: Config) -> Decimal | None: - raise RuntimeError("boom") - - result = _refresh_balance(open_state, lambda: NOW_TS, _raise) - - assert result.amount is None - assert result.updated_ts == NOW_TS - assert result.error is not None - assert "boom" in result.error - - -def test_refresh_balance_raising_balance_fn_truncates_long_error() -> None: - """A stray huge (or sensitive) exception message must not be painted full-screen verbatim -- - `.error` is bounded, and the loop this feeds never crashes either way.""" - config = _config() - - def open_state() -> tuple[Any, Config]: - return object(), config - - long_message = "x" * 5000 - - def _raise(cfg: Config) -> Decimal | None: - raise RuntimeError(long_message) - - result = _refresh_balance(open_state, lambda: NOW_TS, _raise) - - assert result.amount is None - assert result.error is not None - assert len(result.error) <= 120 - - -# -- human-readable timestamps ------------------------------------------------------------------- - - -def test_human_dt_matches_strftime_localtime() -> None: - ts = 1_800_012_345 - assert _human_dt(ts) == time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) - - -def test_build_screen_title_shows_version_and_mode_without_datetime() -> None: - """The header is `keel v. · mode` -- version, no clock, no `now=` label. - (Event times like a position's `opened_at` and autonomy lapse still render via `_human_dt`.)""" - report = _base_report() - title = build_screen(report, NOW_TS)[0] - assert title.text == f"keel {_SHORT_VERSION} · paper mode" - assert "now=" not in title.text - assert _human_dt(NOW_TS) not in title.text - - -@pytest.mark.parametrize( - "raw,expected", - [ - ("0.1.0", "v0.1.0"), - ("0.2.5", "v0.2.5"), - ("0.5.2", "v0.5.2"), - # Build metadata rides on the PATCH segment (`0.5.2+79f35b9e73d5`), so a naive - # `parts[2].isdigit()` is False and the patch would silently vanish -- which is exactly - # the shape `keel --version` emits, i.e. the common case, not the edge case. - ("10.34.1+abc", "v10.34.1"), - ("0.5.2+79f35b9e73d5", "v0.5.2"), - # No patch segment at all: show what exists rather than inventing a `.0`. - ("2.0", "v2.0"), - # A non-numeric patch (pre-release) degrades to major.minor -- still useful -- rather - # than to `v?`, which would throw away the two segments we did parse. - ("0.5.2rc1", "v0.5"), - ("unknown", "v?"), - ("1", "v?"), - ("", "v?"), - ], -) -def test_short_version(raw: str, expected: str) -> None: - assert _short_version(raw) == expected - - -def test_open_position_lines_use_human_readable_opened_at() -> None: - pos = OpenPositionStatus( - id=1, - product_id="BTC-USD", - rule_name="turtle_breakout", - qty=Decimal("0.01"), - entry_price=Decimal("65000"), - opened_at=NOW_TS - 3600, - has_bracket=True, - ) - report = _base_report(open_positions=[pos]) - lines = build_screen(report, NOW_TS) - pos_line = next(line for line in lines if "BTC-USD" in line.text) - assert f"opened_at={pos.opened_at}" not in pos_line.text - assert _human_dt(pos.opened_at) in pos_line.text - - -def test_autonomy_lapsed_line_uses_human_readable_timestamp() -> None: - until = NOW_TS - 60 - autonomy = AutonomyStatus( - live=False, - autonomous=True, - autonomous_until=until, - updated_ts=NOW_TS, - profile_readable=True, - ) - report = _base_report(autonomy=autonomy) - lines = build_screen(report, NOW_TS) - lapsed_line = next(line for line in lines if "LAPSED" in line.text) - assert f"LAPSED at {until}" not in lapsed_line.text - assert _human_dt(until) in lapsed_line.text - - -def test_autonomy_lapsed_line_without_a_deadline_says_unknown_rather_than_now() -> None: - """A missing `autonomous_until` must not render as "lapsed this instant". - - `_human_dt` does not fail on `None` -- `time.localtime(None)` means "now" -- so without the - guard this branch printed the CURRENT time as though it were the recorded lapse. The second - assertion is the load-bearing one: absence has to read as absence, not as a fresh fact. - """ - autonomy = AutonomyStatus( - live=False, - autonomous=True, - autonomous_until=None, - updated_ts=NOW_TS, - profile_readable=True, - ) - report = _base_report(autonomy=autonomy) - lines = build_screen(report, NOW_TS) - lapsed_line = next(line for line in lines if "LAPSED" in line.text) - assert "unknown" in lapsed_line.text - assert _human_dt(NOW_TS) not in lapsed_line.text - - -def test_autonomy_lapses_at_line_uses_human_readable_timestamp() -> None: - until = NOW_TS + 3600 - autonomy = AutonomyStatus( - live=True, autonomous=True, autonomous_until=until, updated_ts=NOW_TS, profile_readable=True - ) - report = _base_report(autonomy=autonomy) - lines = build_screen(report, NOW_TS) - lapses_line = next(line for line in lines if "lapses at" in line.text) - assert f"lapses at {until}" not in lapses_line.text - assert _human_dt(until) in lapses_line.text - - -# -- style logic -------------------------------------------------------------------------------- - - -def test_kill_switch_engaged_is_alert_style() -> None: - report = _base_report(kill_switch_engaged=True) - lines = build_screen(report, NOW_TS) - kill_line = next(line for line in lines if "kill" in line.text.lower()) - assert kill_line.style == "alert" - - -def test_kill_switch_clear_is_ok_style() -> None: - report = _base_report(kill_switch_engaged=False) - lines = build_screen(report, NOW_TS) - kill_line = next(line for line in lines if "kill" in line.text.lower()) - assert kill_line.style == "ok" - - -@pytest.mark.parametrize( - "rail11_status,expected_style", - [("HALTED", "alert"), ("unknown", "warn"), ("ok", "ok")], -) -def test_rail11_style_matches_status(rail11_status: str, expected_style: str) -> None: - report = _base_report(rail11_status=rail11_status) - lines = build_screen(report, NOW_TS) - rail_line = next(line for line in lines if "rail11" in line.text.lower()) - assert rail_line.style == expected_style - - -def _attestation(state: str, **fields: Any) -> WithdrawalAttestationStatus: - defaults: dict[str, Any] = dict( - state=state, - enabled=True, - attested_at=NOW_TS - 86400, - expires_in_sec=6 * 86400, - expired_for_sec=None, - ) - defaults.update(fields) - return WithdrawalAttestationStatus(**defaults) - - -@pytest.mark.parametrize( - "attestation,expected_style", - [ - (_attestation("attested"), "ok"), - (_attestation("expired", enabled=True, attested_at=NOW_TS - 19 * 86400, - expires_in_sec=None, expired_for_sec=12 * 86400), "warn"), - (_attestation("unattested", enabled=None, attested_at=None, - expires_in_sec=None, expired_for_sec=None), "warn"), - (_attestation("suspended", enabled=False, expires_in_sec=None), "warn"), - ], - ids=["attested", "expired", "unattested", "suspended"], -) -def test_rail17_line_renders_with_halt_severity( - attestation: WithdrawalAttestationStatus, expected_style: str -) -> None: - """The TUI reuses `render_human`'s exact rail-17 text, colored by whether entries are - halted. `_base_report` is PAPER mode (the TUI's home turf), where rail 17 is not - evaluated and a stale attestation halts nothing -- so every halted-state reading is a - WARN there: a permanently-red paper dashboard is fatigue, not information (#340). The - live-mode alert is pinned by its own test below.""" - report = _base_report(withdrawal_attestation=attestation) - lines = build_screen(report, NOW_TS) - rail_line = next(line for line in lines if line.text.startswith("rail 17")) - assert rail_line.style == expected_style - - -def test_rail17_halted_states_are_alerts_when_the_rail_runs() -> None: - """In a mode where rail 17 actually evaluates (live/confirm), every state but - `attested` fails it closed -- an alert, the reading the 2026-08-14 veto deserved.""" - report = _base_report( - mode="confirm", - withdrawal_attestation=_attestation( - "expired", enabled=True, attested_at=NOW_TS - 19 * 86400, - expires_in_sec=None, expired_for_sec=12 * 86400, - ), - ) - lines = build_screen(report, NOW_TS) - rail_line = next(line for line in lines if line.text.startswith("rail 17")) - assert rail_line.style == "alert" - assert rail_line.text == ( - "rail 17 (withdrawal capability): EXPIRED 12d ago -- entries halted; " - "re-attest with keel withdrawals attest" - ) - - -def test_rail17_expired_line_names_the_halt_and_the_fix() -> None: - report = _base_report( - mode="confirm", - withdrawal_attestation=_attestation( - "expired", - enabled=True, - attested_at=NOW_TS - 19 * 86400, - expires_in_sec=None, - expired_for_sec=12 * 86400, - ), - ) - lines = build_screen(report, NOW_TS) - rail_line = next(line for line in lines if line.text.startswith("rail 17")) - assert rail_line.text == ( - "rail 17 (withdrawal capability): EXPIRED 12d ago -- entries halted; " - "re-attest with keel withdrawals attest" - ) - - -# -- market session (FR-9) ----------------------------------------------------------------------- -# -# The TUI reuses `render_human`'s exact session text (the `_rail17_line` discipline: two -# renderings of one state can never disagree). Unlike rail 17 there is NO paper-mode carve -# out: the session gate skips PAPER cycles too, so the same line is truthful in every mode. - - -def test_no_session_record_renders_no_session_line() -> None: - """Crypto unchanged: a 24/7 venue never writes the state keys, so the dashboard does - not grow a line that would only ever say 'open'.""" - lines = build_screen(_base_report(), NOW_TS) - assert not any(line.text.startswith("market session") for line in lines) - - -@pytest.mark.parametrize( - ("state", "expected_style"), - [ - ("open", "ok"), - ("closed", "muted"), - ("clock_unavailable", "warn"), - ], - ids=["open", "closed", "clock-unavailable"], -) -def test_session_line_styles_expected_severity(state: str, expected_style: str) -> None: - """A closed market is an EXPECTED state (every weekend) -- muted, never an alert, or - the dashboard would train its operator to ignore colour. An unreadable clock is a - degraded read worth a warn; an open market is simply ok.""" - report = _base_report( - market_session=MarketSessionStatus(state=state, recorded_ts=NOW_TS - 60) - ) - lines = build_screen(report, NOW_TS) - session_line = next(line for line in lines if line.text.startswith("market session")) - assert session_line.style == expected_style - - -def test_session_line_sits_directly_under_the_kill_switch() -> None: - report = _base_report( - market_session=MarketSessionStatus(state="closed", recorded_ts=NOW_TS - 60) - ) - lines = build_screen(report, NOW_TS) - kill_at = next(i for i, line in enumerate(lines) if line.text.startswith("kill_switch")) - session_at = next( - i for i, line in enumerate(lines) if line.text.startswith("market session") - ) - assert session_at == kill_at + 1 - - -def test_autonomy_live_is_alert_style() -> None: - autonomy = AutonomyStatus( - live=True, autonomous=True, autonomous_until=None, updated_ts=NOW_TS, profile_readable=True - ) - report = _base_report(autonomy=autonomy) - lines = build_screen(report, NOW_TS) - autonomy_line = next(line for line in lines if "autonomy" in line.text.lower()) - assert autonomy_line.style == "alert" - - -def test_autonomy_off_is_muted_style() -> None: - autonomy = AutonomyStatus( - live=False, autonomous=False, autonomous_until=None, updated_ts=None, profile_readable=True - ) - report = _base_report(autonomy=autonomy) - lines = build_screen(report, NOW_TS) - autonomy_line = next(line for line in lines if "autonomy" in line.text.lower()) - assert autonomy_line.style == "muted" - - -def test_autonomy_unreadable_profile_is_warn_style() -> None: - autonomy = AutonomyStatus( - live=False, autonomous=False, autonomous_until=None, updated_ts=None, profile_readable=False - ) - report = _base_report(autonomy=autonomy) - lines = build_screen(report, NOW_TS) - warn_line = next(line for line in lines if "unreadable" in line.text.lower()) - assert warn_line.style == "warn" - - -def test_bracketless_position_has_warn_line() -> None: - pos = OpenPositionStatus( - id=2, - product_id="ETH-USD", - rule_name="dca", - qty=Decimal("1"), - entry_price=Decimal("3000"), - opened_at=NOW_TS, - has_bracket=False, - ) - report = _base_report(open_positions=[pos]) - lines = build_screen(report, NOW_TS) - bracket_lines = [line for line in lines if "bracket" in line.text.lower()] - assert any(line.style == "warn" for line in bracket_lines) - - -# -- _freshness_style (pure, parametrised) ----------------------------------------------------- - - -@pytest.mark.parametrize( - "granularity,age_sec,expected", - [ - ("ONE_HOUR", 60, "ok"), - ("ONE_HOUR", 3600, "ok"), - ("ONE_HOUR", 3600 * 3, "warn"), - ("ONE_DAY", 86400, "ok"), - ("ONE_DAY", 86400 * 3, "warn"), - (None, 10, "warn"), - ("ONE_HOUR", None, "warn"), - (None, None, "warn"), - ], -) -def test_freshness_style(granularity: str | None, age_sec: int | None, expected: str) -> None: - assert _freshness_style(granularity, age_sec) == expected - - -def test_freshness_style_mutes_the_staleness_colour_under_a_closed_market() -> None: - """`market_closed` mutes only the AGE-based warn: a behind series during a (still - trusted) closure is the expected weekend shape. Fresh stays ok, and the no-data / - unknown-granularity cells keep their warn -- a closed venue still serves history, so a - cold cache is a pipeline problem, not a session artifact (the `fetch --check` rule).""" - assert _freshness_style("ONE_HOUR", 3600 * 3, market_closed=True) == "muted" - assert _freshness_style("ONE_HOUR", 60, market_closed=True) == "ok" - assert _freshness_style(None, None, market_closed=True) == "warn" - assert _freshness_style("NOT_A_GRANULARITY", 60, market_closed=True) == "warn" - - -def test_freshness_cells_render_muted_not_warn_while_the_market_is_closed() -> None: - """Finding: `_freshness_style` painted warn for age > 2x period even while the - dashboard's own session line said CLOSED -- two parts of one screen disagreeing about - the same weekend. The session record is the source of truth: closed AND inside its - trust window -> the behind series' cell is muted, like the session line itself.""" - report = _base_report( - market_session=MarketSessionStatus( - state="closed", recorded_ts=NOW_TS - 60, defused=True - ), - data_freshness=[ProductFreshness("BTC-USD", "ONE_HOUR", NOW_TS - 4 * 3600, 4 * 3600)], - ) - lines = build_screen(report, NOW_TS) - freshness_line = next(line for line in lines if line.text.startswith(" BTC-USD")) - assert freshness_line.style == "muted" - - -def test_freshness_cells_still_warn_once_the_closed_record_is_stale() -> None: - """`defused=False` (record outside its trust window) means the closure no longer - vouches for the quiet -- the staleness colour comes back with the alert.""" - report = _base_report( - market_session=MarketSessionStatus( - state="closed", recorded_ts=NOW_TS - 60, defused=False - ), - data_freshness=[ProductFreshness("BTC-USD", "ONE_HOUR", NOW_TS - 4 * 3600, 4 * 3600)], - ) - lines = build_screen(report, NOW_TS) - freshness_line = next(line for line in lines if line.text.startswith(" BTC-USD")) - assert freshness_line.style == "warn" - - -def test_no_data_freshness_cells_still_warn_while_the_market_is_closed() -> None: - """The `fetch --check` rule, carried into colour: MISSING stays actionable when closed - because a closed venue still serves history -- so 'no data' keeps the warning.""" - report = _base_report( - market_session=MarketSessionStatus( - state="closed", recorded_ts=NOW_TS - 60, defused=True - ), - data_freshness=[ProductFreshness("ETH-USD", None, None, None)], - ) - lines = build_screen(report, NOW_TS) - freshness_line = next(line for line in lines if line.text.startswith(" ETH-USD")) - assert freshness_line.style == "warn" - - -# -- render_plain ----------------------------------------------------------------------------- - - -def test_render_plain_matches_build_screen_text() -> None: - report = _base_report() - lines = build_screen(report, NOW_TS) - plain = render_plain(report, NOW_TS) - assert plain == [line.text for line in lines] - - -class _FakeCursesError(Exception): - pass - - -def _fake_curses(*, has_colors: bool = True) -> SimpleNamespace: - """A stand-in `curses` module -- distinct attribute-constant ints, `has_colors()`/ - `color_pair()`/`init_pair()` recorded on `.calls` (in call order), no real terminal - required. Installed via `monkeypatch.setitem(sys.modules, "curses", ...)` since both - `_style_attrs` and `run_live` do `import curses` lazily inside the function body, so the - patched module is what they bind.""" - calls: list[str] = [] - fake = SimpleNamespace( - A_BOLD=1 << 0, - A_DIM=1 << 1, - A_UNDERLINE=1 << 2, - A_REVERSE=1 << 3, - A_NORMAL=0, - COLOR_RED=1, - COLOR_YELLOW=2, - COLOR_GREEN=3, - KEY_UP=1001, - KEY_DOWN=1002, - KEY_PPAGE=1003, - KEY_NPAGE=1004, - KEY_HOME=1005, - KEY_END=1006, - KEY_ENTER=1007, - error=_FakeCursesError, - has_colors=lambda: has_colors, - start_color=lambda: calls.append("start_color"), - use_default_colors=lambda: calls.append("use_default_colors"), - init_pair=lambda n, fg, bg: calls.append(f"init_pair:{n}"), - color_pair=lambda n: 1 << (10 + n), - curs_set=lambda visibility: None, - def_prog_mode=lambda: calls.append("def_prog_mode"), - endwin=lambda: calls.append("endwin"), - reset_prog_mode=lambda: calls.append("reset_prog_mode"), - calls=calls, - ) - return fake - - -# -- _style_attrs (fake curses module, no real terminal) --------------------------------------- - - -def test_style_attrs_calls_use_default_colors_before_init_pair( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Regression guard: `curses.wrapper` never calls `use_default_colors()`, so an `init_pair` - background of `-1` is illegal and raises `curses.error` -- caught, but silently dropping ALL - colour. `_style_attrs` must call `use_default_colors()` itself, before the first - `init_pair`.""" - fake_curses = _fake_curses() - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - attrs = _style_attrs() - - assert "use_default_colors" in fake_curses.calls - assert fake_curses.calls.index("use_default_colors") < fake_curses.calls.index("init_pair:1") - assert attrs["alert"] & fake_curses.color_pair(1) - assert attrs["warn"] & fake_curses.color_pair(2) - assert attrs["ok"] & fake_curses.color_pair(3) - - -# -- _paint (fake stdscr, no real terminal) ----------------------------------------------------- - - -class _FakeStdscr: - def __init__(self, height: int, width: int) -> None: - self._height = height - self._width = width - self.calls: list[tuple[int, int, str, int]] = [] - self.refresh_calls = 0 - - def getmaxyx(self) -> tuple[int, int]: - return (self._height, self._width) - - def addstr(self, y: int, x: int, text: str, attr: int = 0) -> None: - self.calls.append((y, x, text, attr)) - - def erase(self) -> None: - pass - - def refresh(self) -> None: - self.refresh_calls += 1 - - -def test_paint_does_not_raise_on_tiny_window() -> None: - lines = [ - ScreenLine("keel · paper mode", "heading"), - ScreenLine("kill_switch: clear", "ok"), - ScreenLine("autonomy: off", "muted"), - ScreenLine("open positions: none", "normal"), - ScreenLine("q quit · read-only (no broker)", "muted"), - ] - stdscr = _FakeStdscr(height=3, width=10) - _paint(stdscr, lines) # must not raise even though window is smaller than content - - -def test_paint_records_addstr_per_visible_line() -> None: - lines = [ - ScreenLine("line one", "normal"), - ScreenLine("line two", "ok"), - ] - stdscr = _FakeStdscr(height=24, width=80) - _paint(stdscr, lines) - assert len(stdscr.calls) == 2 - ys = [call[0] for call in stdscr.calls] - assert ys == [0, 1] - - -def test_paint_truncates_to_window_width() -> None: - lines = [ScreenLine("x" * 200, "normal")] - stdscr = _FakeStdscr(height=24, width=20) - _paint(stdscr, lines) - assert len(stdscr.calls) == 1 - text = stdscr.calls[0][2] - assert len(text) <= 20 - - -def test_paint_applies_distinct_attrs_by_style() -> None: - lines = [ - ScreenLine("alert line", "alert"), - ScreenLine("normal line", "normal"), - ] - stdscr = _FakeStdscr(height=24, width=80) - _paint(stdscr, lines) - attrs = [call[3] for call in stdscr.calls] - # Not asserting exact bit values (curses colour init may be unavailable off a real terminal) - # -- just that the two differently-styled lines don't collapse to the same attr. - assert attrs[0] != attrs[1] - - -def test_the_loop_painted_run_notices_wrap_inside_the_80_column_clip() -> None: - """[review #405] The frozen-screen notices are painted straight by the loop, not - through a console builder -- so their fit needs its own proof. The raw bodies are - long (the cycle's is 144 chars) and `_paint` CLIPS at the window width: painted as - one line, the tail -- the part that says what happens to orders -- is exactly what - a 80-column terminal loses. The notice helper wraps every body to the builders' - 78-column budget, tail included, and carries the Ctrl-C line.""" - bodies = ( - _CYCLE_RUN_NOTICE, - _MONITOR_RUN_NOTICE, - _FETCH_RUN_NOTICE, - # the C4-era simulate notice rides the same helper since the C5 review - # flagged its identical clipping (91 chars, tail lost), - "simulating... please wait (this can take minutes; the " - "screen is frozen exactly like the CLI)", - ) - # the raw bodies genuinely need the wrapping this test exists to force - assert len(_CYCLE_RUN_NOTICE) > 80 - assert len(_FETCH_RUN_NOTICE) > 80 - for body in bodies: - lines = _run_notice_lines(body) - for line in lines: - assert len(line.text) <= 78, line.text - # nothing is lost to the wrap: every word of the body renders, tail included - rendered = " ".join(line.text for line in lines) - for word in body.replace("(", " ").replace(")", " ").replace(";", " ").split(): - assert word in rendered, word - # and the Ctrl-C disclosure rides every frozen notice - assert any("Ctrl-C" in line.text for line in lines) - assert "exits the whole console" in CTRL_C_DISCLOSURE - assert len(CTRL_C_DISCLOSURE) > 78 # the disclosure itself needs the wrap too - - -# -- run_once ------------------------------------------------------------------------------- - - -def test_run_once_captures_full_frame(repo: Repository) -> None: - repo.set_state("drawdown_total_pct", Decimal("0.05")) - repo.set_state("drawdown_weekly_pct", Decimal("0.01")) - config = _config() - - echoed: list[str] = [] - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_once(open_state, lambda: NOW_TS, echoed.append) - - assert echoed # a full frame was produced - joined = "\n".join(echoed) - assert "paper mode" in joined - assert "quit" in joined.lower() - assert "help" in joined.lower() - - -# -- run_live (fake curses module, no real terminal) --------------------------------------------- - - -class _ScriptedStdscr(_FakeStdscr): - """Like `_FakeStdscr`, but `getch()` returns `-1` (no key) until `quit_after` polls have - happened, then returns `q` so the loop under test terminates deterministically.""" - - def __init__(self, height: int, width: int, quit_after: int) -> None: - super().__init__(height, width) - self._quit_after = quit_after - self._polls = 0 - - def timeout(self, ms: int) -> None: - pass - - def getch(self) -> int: - self._polls += 1 - return ord("q") if self._polls >= self._quit_after else -1 - - -def test_run_live_survives_transient_read_error_and_keeps_polling( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - config = _config() - stdscr = _ScriptedStdscr(height=24, width=80, quit_after=2) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - opens: list[int] = [] - - def open_state() -> tuple[Repository, Any]: - opens.append(1) - # `run_live` calls `open_state()` at least twice on the first normal-mode iteration: once - # for the status report itself (call 1, the one this test targets to exercise the per-poll - # read-error safeguard -- the balance refresh was deliberately moved AFTER the paint, so - # it no longer blocks the first frame), and once for the slow-cadence balance refresh - # (call 2, which swallows its own errors and never reaches this test's assertions). - if len(opens) == 1: - raise sqlite3.OperationalError("database is locked") - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - # The first poll's read error didn't kill the loop -- a second poll (the happy path) ran too. - assert len(opens) >= 2 - painted_texts = [call[2] for call in stdscr.calls] - assert any("status read failed" in t for t in painted_texts) - assert any("paper" in t for t in painted_texts) - - -class _KeySequenceStdscr(_FakeStdscr): - """Like `_FakeStdscr`, but `getch()` replays a scripted sequence of keycodes, one per poll, - then returns `q` forever once exhausted -- so a test can drive the loop through an exact - sequence of mode transitions deterministically.""" - - def __init__(self, height: int, width: int, keys: list[int]) -> None: - super().__init__(height, width) - self._keys = list(keys) - - def timeout(self, ms: int) -> None: - pass - - def getch(self) -> int: - if self._keys: - return self._keys.pop(0) - return ord("q") - - -def test_run_live_i_opens_insights_overlay_and_esc_closes_it( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - config = _config() - # poll1: normal -> 'i' opens insights. poll2: insights (offset 0). poll3: Esc closes back to - # normal. poll4: normal -> 'q' quits (via the stdscr's post-exhaustion default). - keys = [ord("i"), -1, 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted_texts = [call[2] for call in stdscr.calls] - insights_idx = next(i for i, t in enumerate(painted_texts) if "keel tui -- insights" in t) - # Proves Esc (key 27) actually closed the overlay and returned control to the dashboard -- - # not just that the loop happened to end (which `q` would also produce, even if the Esc - # branch itself were deleted): a LATER frame, after the insights heading was painted, must - # paint the normal-mode dashboard's own title line again. - dashboard_after_idx = next( - i for i, t in enumerate(painted_texts) if i > insights_idx and "paper mode" in t - ) - assert dashboard_after_idx > insights_idx - - -def test_run_live_scrolling_keys_move_insights_offset( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - config = _config() - fake_curses = _fake_curses() - # poll1: normal -> 'i'. poll2: insights offset=0, KEY_DOWN -> offset=1. poll3: insights - # offset=1, paint recorded. Esc closes. poll4: normal -> quits (post-exhaustion default). - keys = [ord("i"), fake_curses.KEY_DOWN, 27] - stdscr = _KeySequenceStdscr(height=5, width=80, keys=keys) - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - # Each frame paints exactly `height` (5) addstr calls (the insights content is long enough - # to fill the window). Frame1=normal, Frame2=insights offset=0, Frame3=insights offset=1. - frames = [stdscr.calls[i : i + 5] for i in range(0, len(stdscr.calls), 5)] - assert len(frames) >= 3 - frame2_top = frames[1][0][2] - frame3_top = frames[2][0][2] - assert "keel tui -- insights" in frame2_top # offset 0 starts at the heading - assert frame2_top != frame3_top # scrolling down moved the visible window - - -def test_run_live_insights_survives_transient_read_error_and_keeps_polling( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """The insights branch has its OWN `try`/`except` (separate from normal mode's), guarding - `open_state`/`gather_status`/`build_insights_report`/`build_journal_report`. A transient - failure there (e.g. `database is locked` from a concurrent `keel agent` writer) must paint an - `insights read failed` alert line -- not crash or hang the loop -- and the loop must still be - able to close the overlay and keep running afterwards.""" - config = _config() - # poll1: normal -> open_state call #1 (status) + call #2 (balance refresh) both succeed; - # 'i' opens insights. poll2: insights -> open_state call #3 raises; Esc closes back to - # normal. poll3: normal -> open_state call #4 succeeds; 'q' quits (post-exhaustion default). - keys = [ord("i"), 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - opens: list[int] = [] - - def open_state() -> tuple[Repository, Any]: - opens.append(1) - if len(opens) == 3: - raise RuntimeError("database is locked") - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - # The loop returned (no hang/crash) and made further open_state calls after the failure. - assert len(opens) >= 4 - painted_texts = [call[2] for call in stdscr.calls] - failed_idx = next(i for i, t in enumerate(painted_texts) if "insights read failed" in t) - assert "database is locked" in painted_texts[failed_idx] - # ... and the loop kept going afterwards: Esc still closed the (failed) overlay and a later - # frame painted the normal dashboard again. - assert any( - i > failed_idx and "paper mode" in t for i, t in enumerate(painted_texts) - ) - - -# -- run_live: screen / propose overlays (offline, DB-only) --------------------------------------- - - -def test_run_live_s_opens_screen_overlay_and_esc_closes_it( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """Mirrors `test_run_live_i_opens_insights_overlay_and_esc_closes_it`: 's' opens the screen - overlay, Esc closes it back to the dashboard.""" - config = _config() - keys = [ord("s"), -1, 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted_texts = [call[2] for call in stdscr.calls] - screen_idx = next(i for i, t in enumerate(painted_texts) if "keel tui -- screen" in t) - dashboard_after_idx = next( - i for i, t in enumerate(painted_texts) if i > screen_idx and "paper mode" in t - ) - assert dashboard_after_idx > screen_idx - - -def test_run_live_p_opens_propose_overlay_and_esc_closes_it( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path -) -> None: - """Mirrors the 's' test above, for 'p'. `proposals_dir` points at a tmp_path subdirectory - (rather than the config default, `~/keel/proposals`) so this test never reads a real - deployment's proposals directory.""" - config = _config(proposals_dir=str(tmp_path / "proposals")) - keys = [ord("p"), -1, 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted_texts = [call[2] for call in stdscr.calls] - propose_idx = next(i for i, t in enumerate(painted_texts) if "keel tui -- propose" in t) - dashboard_after_idx = next( - i for i, t in enumerate(painted_texts) if i > propose_idx and "paper mode" in t - ) - assert dashboard_after_idx > propose_idx - - -def _seed_daily_history(repo: Repository, product: str, bars: int) -> None: - """Enough cached daily bars for `history` and `liquidity` to PASS the screen outright - (`volume * close` = 10,000,000 per bar, well over the 1,000,000 median floor), so a REJECT - from the gate can only be the shariah criterion the asset has no attestation for.""" - repo.upsert_candles( - product, - Granularity.ONE_DAY, - [ - Candle( - ts=i * 86400, - open=Decimal("100"), - high=Decimal("101"), - low=Decimal("99"), - close=Decimal("100"), - volume=Decimal("100000"), - ) - for i in range(bars) - ], - ) - - -def test_run_live_screen_overlay_paints_the_real_verdict_from_the_single_gate( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """Design constraint 4 -- every candidate routes through `keel.cli._screen_product`, "so - nothing drifts onto a laxer gate" -- was convention-only exactly where it is WIRED. Replacing - `_screen_product` with an always-ADMIT stub in `_do_screen_report` left the entire suite - green, because the s/p overlay tests only assert that a title paints and Esc closes; not one - of them ever looked at a verdict. - - So: BTC is seeded with ample history and liquidity but is never attested. The only thing that - can reject it is the shariah criterion, which only the real gate applies -- `screen_asset` - fails CLOSED on `attestation=None`. The overlay must therefore paint `REJECT` and name - `attestation: MISSING`. An always-ADMIT stub paints `ADMIT` with no failure lines and kills - both assertions.""" - config = _config(allowlist=["BTC"]) - _seed_daily_history(repo, "BTC-USD", 1500) - keys = [ord("s"), -1, 27] - stdscr = _KeySequenceStdscr(height=40, width=120, keys=keys) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted_texts = [call[2] for call in stdscr.calls] - overlay_idx = next(i for i, t in enumerate(painted_texts) if "keel tui -- screen" in t) - after = painted_texts[overlay_idx:] - assert any(t.startswith("REJECT") and "BTC" in t for t in after) - assert any("attestation: MISSING" in t for t in after) - # The premise: the data criteria really did pass, so REJECT above is the shariah gate's doing - # and not an incidental history/liquidity shortfall that any stub would also produce. - assert not any("✗ history" in t or "✗ liquidity" in t for t in after) - - -def test_run_live_propose_overlay_paints_the_real_verdict_from_the_single_gate( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path -) -> None: - """The `p` half of the same wiring gap the `s` test above closes -- `_do_propose_view` passes - `_screen_product` into `build_propose_view` on the same convention-only basis, and swapping it - for an always-ADMIT stub was equally invisible to the suite. - - SOL is shortlisted with ample cached history and liquidity but no attestation, so the only - thing that can reject it is `screen_asset` failing CLOSED on `attestation=None` -- something - only the real gate does.""" - proposals = tmp_path / "proposals" - proposals.mkdir() - (proposals / "shortlist.json").write_text( - json.dumps( - {"candidates": [{"asset": "SOL", "rationale": "r", "sources": ["https://x.invalid"]}]} - ) - ) - config = _config(proposals_dir=str(proposals)) - _seed_daily_history(repo, "SOL-USD", 1500) - keys = [ord("p"), -1, 27] - stdscr = _KeySequenceStdscr(height=40, width=200, keys=keys) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted_texts = [call[2] for call in stdscr.calls] - overlay_idx = next(i for i, t in enumerate(painted_texts) if "keel tui -- propose" in t) - after = painted_texts[overlay_idx:] - assert any(t.startswith("REJECT") and "SOL" in t for t in after) - assert any("attestation: MISSING" in t for t in after) - assert any("keel assets attest SOL" in t for t in after) # the next step, not just a verdict - # The premise: the data criteria really did pass, so REJECT is the shariah gate's doing. - assert not any("✗ history" in t or "✗ liquidity" in t for t in after) - - -def test_run_live_propose_overlay_reports_a_non_utf8_shortlist_calmly( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path -) -> None: - """The TUI half of the `UnicodeDecodeError`-is-not-an-`OSError` fix. A UTF-16LE+BOM shortlist - escaped `build_propose_view`'s fail-soft branches entirely and was caught only by the propose - branch's broad `except Exception`, which repainted `propose read failed: 'utf-8' codec can't - decode byte 0xff...` on every poll forever -- naming no file and offering no next step. It - must render as the same calm, actionable `unreadable` overlay a permissions error renders - as.""" - proposals = tmp_path / "proposals" - proposals.mkdir() - (proposals / "shortlist.json").write_bytes( - json.dumps( - {"candidates": [{"asset": "SOL", "rationale": "r", "sources": ["https://x.invalid"]}]} - ).encode("utf-16") - ) - config = _config(proposals_dir=str(proposals)) - keys = [ord("p"), -1, 27] - # Wide enough that `_paint`'s clip-to-window-width does not truncate the tmp_path before the - # filename this test is about -- the clipping is real terminal behaviour, not the bug here. - stdscr = _KeySequenceStdscr(height=40, width=400, keys=keys) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted_texts = [call[2] for call in stdscr.calls] - assert any("could not read the shortlist file" in t for t in painted_texts) - assert any("shortlist.json" in t for t in painted_texts) # WHICH file, by name - assert not any("propose read failed" in t for t in painted_texts) - - -def test_run_live_screen_survives_transient_read_error_and_keeps_polling( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """The screen branch has its OWN try/except, mirroring insights' -- a transient failure (e.g. - `database is locked`) must paint a `screen read failed` alert line, not crash or hang, and the - loop must still be able to close the overlay and keep running afterwards.""" - config = _config() - # poll1: normal -> open_state call #1 (status) + #2 (balance refresh) both succeed; 's' opens - # screen. poll2: screen -> open_state call #3 (inside `_do_screen_report`) raises; Esc closes - # back to normal. poll3: normal -> open_state call #4 succeeds; 'q' quits (default). - keys = [ord("s"), 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - opens: list[int] = [] - - def open_state() -> tuple[Repository, Any]: - opens.append(1) - if len(opens) == 3: - raise RuntimeError("database is locked") - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - assert len(opens) >= 4 - painted_texts = [call[2] for call in stdscr.calls] - failed_idx = next(i for i, t in enumerate(painted_texts) if "screen read failed" in t) - assert "database is locked" in painted_texts[failed_idx] - assert any(i > failed_idx and "paper mode" in t for i, t in enumerate(painted_texts)) - - -def test_run_live_propose_survives_transient_read_error_and_keeps_polling( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path -) -> None: - """Same shape as the screen version above, for 'p'.""" - config = _config(proposals_dir=str(tmp_path / "proposals")) - keys = [ord("p"), 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - opens: list[int] = [] - - def open_state() -> tuple[Repository, Any]: - opens.append(1) - if len(opens) == 3: - raise RuntimeError("database is locked") - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - assert len(opens) >= 4 - painted_texts = [call[2] for call in stdscr.calls] - failed_idx = next(i for i, t in enumerate(painted_texts) if "propose read failed" in t) - assert "database is locked" in painted_texts[failed_idx] - assert any(i > failed_idx and "paper mode" in t for i, t in enumerate(painted_texts)) - - -def test_run_live_screen_and_propose_never_construct_a_broker( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path -) -> None: - """`screen`/`propose` are fully OFFLINE -- opening either, polling, and closing must never - reach `_build_broker` of their own accord. `_build_broker` IS still called once during this - run -- by `run_live`'s pre-existing, unrelated automatic "available to buy" balance refresh, - which (with a constant `now_fn`) fires exactly once, on the very first poll, and never again. - `len(calls) == 1` here is exactly that one call, proving screen/propose contributed zero - calls of their own -- the DIRECT proof (screen/propose never import `_build_broker` at all) - lives in `_do_screen_report`'s/`_do_propose_view`'s own source; this is the behavioural - cross-check.""" - config = _config(proposals_dir=str(tmp_path / "proposals")) - calls: list[Any] = [] - - class _FakeBroker: - def get_accounts(self) -> list[Any]: - return [] - - def _fake_build_broker(cfg: Any, timeout: int | None = None) -> _FakeBroker: - calls.append(cfg) - return _FakeBroker() - - monkeypatch.setattr("keel.commands._common._build_broker", _fake_build_broker) - - # poll1: normal -> 's'. poll2: screen, no key. poll3: Esc closes. poll4: normal -> 'p'. - # poll5: propose, no key. poll6: Esc closes. poll7: normal -> 'q' (post-exhaustion default). - keys = [ord("s"), -1, 27, ord("p"), -1, 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - assert len(calls) == 1 - - -# -- run_live: discover overlay (the network-gated one) -------------------------------------------- - - -def test_run_live_discover_opens_armed_and_never_touches_the_network_until_enter( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """THE most important test in this batch. Pressing 'd', polling several times, then closing - must never call `list_products` -- the ONE network call this whole overlay can ever make is - gated behind an explicit Enter keypress, not behind opening the overlay or an ordinary poll. - (`_build_broker` itself is still called once by the pre-existing automatic balance refresh, - unrelated to discover -- see `test_run_live_screen_and_propose_never_construct_a_broker`'s - docstring for why that call doesn't confuse this assertion; `list_products` is the call that - is unique to, and gated by, discover, and it is the one this test pins to zero.)""" - config = _config() - list_products_calls: list[int] = [] - - class _FakeBroker: - def get_accounts(self) -> list[Any]: - return [] - - def list_products(self) -> list[dict]: - list_products_calls.append(1) - return [] - - def _fake_build_broker(cfg: Any, timeout: int | None = None) -> _FakeBroker: - return _FakeBroker() - - monkeypatch.setattr("keel.commands._common._build_broker", _fake_build_broker) - - # poll1: normal -> 'd' opens discover, ARMED. poll2, poll3: no key -- repaint the armed state, - # no fetch. poll4: Esc closes. poll5: normal -> 'q' quits (post-exhaustion default). - keys = [ord("d"), -1, -1, 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - assert list_products_calls == [] - painted_texts = [call[2] for call in stdscr.calls] - assert any("ARMED" in t for t in painted_texts) - assert any("paper mode" in t for t in painted_texts) # closed back to the dashboard - - -def test_run_live_discover_enter_calls_list_products_once_then_holds_the_result( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """The counterpart to the gating test above: Enter DOES run the one network call, exactly - once -- and further polls while the overlay stays open repaint the HELD result rather than - re-fetching (no further `list_products` calls without another Enter).""" - config = _config() - list_products_calls: list[int] = [] - - class _FakeBroker: - def get_accounts(self) -> list[Any]: - return [] - - def list_products(self) -> list[dict]: - list_products_calls.append(1) - return [ - { - "product_id": "SOL-USD", - "quote_currency_id": "USD", - "status": "online", - "trading_disabled": False, - "is_disabled": False, - "view_only": False, - "quote_24h_volume": "9000000", - "base_name": "Solana", - } - ] - - def _fake_build_broker(cfg: Any, timeout: int | None = None) -> _FakeBroker: - return _FakeBroker() - - monkeypatch.setattr("keel.commands._common._build_broker", _fake_build_broker) - - # poll1: normal -> 'd'. poll2: discover ARMED -> Enter runs the one fetch. poll3, poll4: no - # key -- repaint the held result, no further call. poll5: Esc closes. - keys = [ord("d"), 10, -1, -1, 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - assert len(list_products_calls) == 1 - painted_texts = [call[2] for call in stdscr.calls] - assert any("SOL-USD" in t for t in painted_texts) - - -def test_run_live_discover_enter_raising_paints_readable_failure_and_keeps_polling( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """A broker/network/auth failure on Enter must paint a readable `discover failed` line, not - crash the loop -- and Esc still closes the overlay afterwards, repainting the dashboard.""" - config = _config() - - class _FakeBroker: - def get_accounts(self) -> list[Any]: - return [] - - def list_products(self) -> list[dict]: - raise RuntimeError("venue unreachable") - - def _fake_build_broker(cfg: Any, timeout: int | None = None) -> _FakeBroker: - return _FakeBroker() - - monkeypatch.setattr("keel.commands._common._build_broker", _fake_build_broker) - - keys = [ord("d"), 10, 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted_texts = [call[2] for call in stdscr.calls] - failed_idx = next(i for i, t in enumerate(painted_texts) if "discover failed" in t) - assert "venue unreachable" in painted_texts[failed_idx] - assert any(i > failed_idx and "paper mode" in t for i, t in enumerate(painted_texts)) - - -def test_run_live_discover_question_opens_the_discover_context_help( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """[review #406] `?` was dead in the discover overlay -- the one dispatched mode - without it. From discover (armed, no network call made), `?` must open the context - overlay rendering DISCOVER's own CONTEXT_HELP rows, and Esc must return to the - discover overlay (still armed -- `?` is not Enter, it reads nothing and runs - nothing), then close back to the dashboard.""" - config = _config() - - class _FakeBroker: - def get_accounts(self) -> list[Any]: - return [] - - def list_products(self) -> list[dict]: - raise AssertionError("? must never run the fetch -- only Enter does") - - def _fake_build_broker(cfg: Any, timeout: int | None = None) -> _FakeBroker: - return _FakeBroker() - - monkeypatch.setattr("keel.commands._common._build_broker", _fake_build_broker) - - # poll1: normal -> 'd' opens discover, ARMED. poll2: discover -> '?' opens the - # context overlay. poll3: overlay, no key -- the discover rows repaint. poll4: Esc - # returns to discover. poll5: Esc closes discover. poll6: normal -> 'q' (default). - keys = [ord("d"), ord("?"), -1, 27, 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted_texts = [call[2] for call in stdscr.calls] - overlay_idx = next(i for i, t in enumerate(painted_texts) if "help: discover" in t) - # the overlay renders DISCOVER's own rows, not the generic catalogue - assert any( - "discover (d) -- ARMED" in t for t in painted_texts[overlay_idx : overlay_idx + 8] - ) - # opened from the armed overlay, closed back to it, then to the dashboard - assert any("ARMED" in t for t in painted_texts[overlay_idx + 1 :]) - assert any("paper mode" in t for t in painted_texts[overlay_idx + 1 :]) - - -def test_run_live_discover_reopening_after_a_run_is_armed_not_stale( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """Reopening the overlay after a run must be ARMED again, never a silent repaint of the - previous run's stale candidates. - - NAMED for what it actually pins, which is a DISJUNCTION, not a single line. Two independent - clears stand between the held result and the reopened overlay -- the close branch's - (`run_live`, discover mode, `q`/`Esc`/`d`) and the normal-mode `d` branch's self-labelled - belt-and-braces one -- and reaching the reopened overlay necessarily runs BOTH. Deleting - either one alone leaves this test green. It was previously called - `..._closing_discards_the_held_result`, which claimed to pin the close branch specifically; - nothing observable from outside `run_live` can distinguish the two, because `mode` only ever - becomes `discover` via the normal-mode `d` branch that also clears. Keeping both clears is - deliberate defence in depth; this test guards the property they jointly provide.""" - config = _config() - - class _FakeBroker: - def get_accounts(self) -> list[Any]: - return [] - - def list_products(self) -> list[dict]: - return [ - { - "product_id": "SOL-USD", - "quote_currency_id": "USD", - "status": "online", - "trading_disabled": False, - "is_disabled": False, - "view_only": False, - "quote_24h_volume": "9000000", - "base_name": "Solana", - } - ] - - def _fake_build_broker(cfg: Any, timeout: int | None = None) -> _FakeBroker: - return _FakeBroker() - - monkeypatch.setattr("keel.commands._common._build_broker", _fake_build_broker) - - # poll1: normal -> 'd'. poll2: discover ARMED -> Enter fetches SOL-USD. poll3: Esc closes - # (discards). poll4: normal -> 'd' reopens. poll5: discover -- must be ARMED again, no - # candidates carried over. poll6: Esc closes. - keys = [ord("d"), 10, 27, ord("d"), -1, 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted_texts = [call[2] for call in stdscr.calls] - sol_idx = next(i for i, t in enumerate(painted_texts) if "SOL-USD" in t) - # Every frame painted AFTER the SOL-USD result must be the armed re-explanation, not a - # repaint of the stale candidate list. - reopened_armed_idx = next( - i for i, t in enumerate(painted_texts) if i > sol_idx and "ARMED" in t - ) - assert not any( - "SOL-USD" in t for t in painted_texts[reopened_armed_idx:] - ) - - -def test_run_live_discover_bounds_its_one_network_call_with_the_discover_timeout( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """`_DISCOVER_TIMEOUT_SEC` was correctly wired but had no test: dropping the `timeout=` kwarg - (or reusing `_BALANCE_TIMEOUT_SEC`) left the suite green. It exists because the operator waits - on this call with the screen frozen behind a "contacting venue" frame, so a hung connection - must fail and say so rather than freeze the dashboard until Ctrl-C. Pinned per-call, not - globally: the unrelated balance refresh in the same run uses its OWN, shorter bound, and this - test would not notice the two being collapsed into one if it only checked "some timeout was - passed".""" - config = _config() - timeouts: list[tuple[str, Any]] = [] - - class _FakeBroker: - def get_accounts(self) -> list[Any]: - return [] - - def list_products(self) -> list[dict]: - return [] - - def _fake_build_broker(cfg: Any, timeout: int | None = None) -> _FakeBroker: - timeouts.append(("build", timeout)) - return _FakeBroker() - - monkeypatch.setattr("keel.commands._common._build_broker", _fake_build_broker) - - # poll1: normal -> 'd'. poll2: discover ARMED -> Enter runs the one call. poll3: Esc closes. - keys = [ord("d"), 10, 27] - stdscr = _KeySequenceStdscr(height=24, width=80, keys=keys) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - passed = [t for _, t in timeouts] - assert _DISCOVER_TIMEOUT_SEC in passed - # The balance refresh (the other broker build in this run) keeps its own, distinct bound. - assert _BALANCE_TIMEOUT_SEC in passed - assert _DISCOVER_TIMEOUT_SEC != _BALANCE_TIMEOUT_SEC - - -def test_run_live_read_error_does_not_swallow_keyboard_interrupt( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """`except Exception`, not `except BaseException` -- Ctrl-C during the per-poll read must - still propagate out to `run_live`'s own `try/except KeyboardInterrupt`, which swallows it.""" - stdscr = _ScriptedStdscr(height=24, width=80, quit_after=100) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - raise KeyboardInterrupt - - run_live(open_state, lambda: NOW_TS, interval=0.01) # must not raise - - -# -- build_help_screen / _visible_slice (pure) --------------------------------------------------- - - -def test_build_help_screen_documents_every_key_and_safety_notes() -> None: - lines = build_help_screen() - text = " ".join(line.text.lower() for line in lines) - for word in ("autonomy", "fetch", "quit", "scroll", "refresh", "help"): - assert word in text - # v3: the admission workflow's three overlays; C3: attest as a TYPED console form. - for word in ("screen", "propose", "discover", "attest"): - assert word in text - assert lines[0].style == "heading" - - -def test_build_help_screen_documents_discover_network_gating_and_the_typed_attest_gate() -> None: - """The safety notes must be explicit about the two things that make `discover` - different from `screen`/`propose`, and must state attest's CURRENT contract honestly: - attest IS invokable from the console (the Compliance menu's form, the scout browser's - `a` step) -- what keeps it safe is the TYPED confirmation at the end of the form (type - the asset code back; withdrawals attest types its own CLI phrase), NOT a stale - "CLI-only, never a keypress" claim this dashboard outgrew in C3.""" - lines = build_help_screen() - text = " ".join(line.text.lower() for line in lines) - assert "third deliberate network exception" in text - assert "cli-only" not in text - assert "never a keypress" not in text - assert "keel assets attest" in text - assert "type the asset code" in text - - -def test_help_says_which_account_is_on_screen_and_how_to_switch() -> None: - """paper and live render in an IDENTICAL layout, so nothing on the dashboard itself says which - account a number belongs to -- and reading a paper figure as a live one is the most expensive - confusion this project has. The help must name the field that disambiguates, and the - `--config`/`--db` pair that changes it, including that `--db` defaults to the PAPER database: - an operator who omits it gets paper numbers under a live-looking command. Scoped to the - section, like `test_help_says_the_live_balance_line_is_itself_a_venue_call`, so a word three - sections away cannot satisfy it.""" - section: list[str] = [] - lines = build_help_screen() - start = next( - i for i, line in enumerate(lines) if line.text.strip() == "Which account is this?" - ) - for line in lines[start:]: - if not line.text.strip(): - break - section.append(line.text.lower()) - text = " ".join(section) - assert "separate" in text - assert "equity_state_mode" in text - assert "--config" in text - assert "--db" in text - assert "keel-live.db" in text - # The default is the trap, not a detail: omitting --db reads keel.db and reports on paper. - assert "defaults to keel.db" in text - - -#: Every operator-facing place that counts this dashboard's network touches. The count is a -#: SAFETY claim -- an operator deciding whether a keypress can reach the venue reads it and stops -#: looking -- so an undercount is a bug, not a typo, and it must be pinned wherever it is stated. -_NETWORK_COUNT_SURFACES = { - "module docstring": tui_mod.__doc__ or "", - "tui_cmd --help": tui_cmd.__doc__ or "", - "help overlay": "\n".join(line.text for line in build_help_screen()), - "ARMED discover overlay": "\n".join( - line.text for line in build_discover_overlay(None) - ), -} - - -@pytest.mark.parametrize("surface", sorted(_NETWORK_COUNT_SURFACES)) -def test_no_surface_undercounts_the_dashboards_network_touches(surface: str) -> None: - """`run_live` touches the network in exactly THREE places: the automatic ~30s live-balance - refresh (`_refresh_balance` -> `get_accounts`), `f` fetch, and `d`+Enter. Three surfaces -- - including the operator-facing ARMED overlay, read at the moment of deciding whether to make - a live call -- used to call discover "the SECOND deliberate network exception", silently - forgetting the balance refresh that had been firing every 30 seconds since v3. Only - `tui_cmd`'s own docstring had it right. An operator who trusts "second" concludes that - closing the overlay leaves the dashboard offline; it does not.""" - text = _NETWORK_COUNT_SURFACES[surface].lower() - assert "second deliberate network exception" not in text - # Either phrasing states the true count -- three surfaces frame it from discover's side - # ("the THIRD..."), `tui_cmd --help` from the dashboard's ("there are exactly three"). - assert "third deliberate network exception" in text or "exactly three" in text - # Naming the other two is what makes the count checkable rather than a bare number, and it is - # specifically the balance refresh that every undercount forgot. - assert "balance" in text - assert "fetch" in text - - -def test_help_says_the_live_balance_line_is_itself_a_venue_call() -> None: - """The help's own Safety notes tell the operator that screen and propose are offline and that - fetch/discover are the exceptions -- but the "Live balance" section only ever said the number - is "refreshed every ~30s", never that refreshing it CONTACTS THE VENUE. That omission is what - made "second deliberate network exception" read as plausible three sections later.""" - balance_section: list[str] = [] - lines = build_help_screen() - start = next(i for i, line in enumerate(lines) if line.text.strip() == "Live balance") - for line in lines[start:]: - if not line.text.strip(): - break - balance_section.append(line.text.lower()) - text = " ".join(balance_section) - assert "live call" in text or "network" in text - assert "get_accounts" in text - - -def test_build_help_screen_is_longer_than_a_small_terminal() -> None: - lines = build_help_screen() - assert len(lines) > 24 - - -def _help_section(heading_prefix: str) -> str: - """The lowercased body of one help section -- from the line starting `heading_prefix` up to - the next blank -- so a glossary assertion cannot be satisfied by a word appearing three - sections away. Mirrors `test_help_says_the_live_balance_line_is_itself_a_venue_call`.""" - lines = build_help_screen() - start = next(i for i, line in enumerate(lines) if line.text.startswith(heading_prefix)) - body: list[str] = [] - for line in lines[start:]: - if not line.text.strip(): - break - body.append(line.text.lower()) - return " ".join(body) - - -def test_help_screen_glossary_defines_every_field_name_the_dashboard_prints() -> None: - """`_equity_lines` and the activity overlay print keel's INTERNAL field names verbatim -- - `equity_state_mode`, `high_water_mark`, `rail11`, `paper_cash_usdc`, `sig blk ent exi err`. - Nothing on the dashboard explains any of them, so the help must, by name.""" - text = _help_section("Glossary") - for term in ( - "cycle", - "signal", - "sig / blk / ent / exi / err", - "paper_cash_usdc", - "equity_state_mode", - "high_water_mark", - "drawdown", - "rail11", - ): - assert term in text, term - - -def test_help_screen_glossary_distinguishes_no_setup_from_a_vetoed_setup() -> None: - """The distinction the whole glossary exists for: `sig 0` (found nothing) and `sig 1 blk 1` - (found something, a rail stopped it) look equally idle on a dashboard of zeroes, and an - operator who conflates them reads a correctly-declining deployment as a dead one.""" - text = _help_section("Glossary") - assert "`sig 1 blk 1`" in text - assert "`sig 0`" in text - assert "rail vetoes" in text - # A cycle that finds nothing is the normal case, not a fault -- said in those terms. - assert "one cycle per day" in text - assert "normal case, not a fault" in text - - -def test_help_screen_glossary_says_paper_cash_is_synthetic_and_paper_only() -> None: - """`paper_cash_usdc: 11000` is the single most mistakable number on the dashboard: it reads - like a broker balance. It is neither real nor present in live mode.""" - text = _help_section("Glossary") - assert "not a real broker balance" in text - assert "only in paper mode" in text - # The two equity accounts are separate histories, not two views of one account. - assert "separate accounts with separate histories" in text - - -def test_help_screen_glossary_sources_the_drawdown_ceilings_to_config() -> None: - """The parenthesised ceilings on the `drawdown:` line are config values, not live readings -- - an operator who thinks they are measurements has no idea where to change them.""" - text = _help_section("Glossary") - assert "come from config" in text - - -def test_visible_slice_clamps_too_large_offset() -> None: - lines = [ScreenLine(str(i), "normal") for i in range(50)] - result = _visible_slice(lines, offset=1000, height=10) - assert result == lines[40:50] - - -def test_visible_slice_height_covers_all_lines() -> None: - lines = [ScreenLine(str(i), "normal") for i in range(5)] - result = _visible_slice(lines, offset=0, height=100) - assert result == lines - - -def test_visible_slice_zero_height_returns_empty() -> None: - lines = [ScreenLine(str(i), "normal") for i in range(5)] - assert _visible_slice(lines, offset=0, height=0) == [] - - -def test_visible_slice_negative_offset_clamped_to_zero() -> None: - lines = [ScreenLine(str(i), "normal") for i in range(5)] - result = _visible_slice(lines, offset=-10, height=2) - assert result == lines[0:2] - - -def test_visible_slice_offset_past_end_returns_tail() -> None: - lines = [ScreenLine(str(i), "normal") for i in range(5)] - result = _visible_slice(lines, offset=4, height=2) - assert result == lines[3:5] - - -def test_visible_slice_empty_lines_never_raises() -> None: - assert _visible_slice([], offset=5, height=10) == [] - assert _visible_slice([], offset=0, height=0) == [] - - -# -- build_insights_screen (pure, reuses keel.commands.insights renderers) ---------------------- - - -def _insights_account(**overrides: Any) -> InsightsAccountSummary: - base: dict[str, Any] = dict( - mode="paper", - equity_state_mode="paper", - high_water_mark=Decimal("10000"), - drawdown_total_pct=Decimal("0.05"), - drawdown_weekly_pct=Decimal("0.01"), - max_total_dd_pct=Decimal("0.20"), - max_weekly_dd_pct=Decimal("0.08"), - rail11_status="ok", - paper_cash_usdc=Decimal("955.25"), - ) - base.update(overrides) - return InsightsAccountSummary(**base) - - -def _gate(**overrides: Any) -> GateDistance: - base: dict[str, Any] = dict( - rule_name="turtle_breakout", - promotion_class="default", - n_trades=12, - min_trades=30, - trades_remaining=18, - win_rate=0.55, - min_win_rate=0.5, - realized_rr=Decimal("1.8"), - min_rr=Decimal("1.5"), - expectancy=Decimal("12.5"), - min_expectancy=Decimal("0"), - passing=False, - blocking_reasons=["n_trades 12 < 30"], - ) - base.update(overrides) - return GateDistance(**base) - - -def _rule_track_record(**overrides: Any) -> RuleTrackRecord: - base: dict[str, Any] = dict( - rule_name="turtle_breakout", - status="paper", - promotion_class="default", - n_trades=12, - win_rate=0.55, - avg_win=Decimal("25.00"), - avg_loss=Decimal("-14.00"), - realized_rr=Decimal("1.79"), - expectancy=Decimal("12.5"), - profit_factor=Decimal("2.1"), - max_drawdown=Decimal("30.0"), - significant=False, - gate=_gate(), - ) - base.update(overrides) - return RuleTrackRecord(**base) - - -def _insights_report(**overrides: Any) -> InsightsReport: - base: dict[str, Any] = dict( - now_ts=NOW_TS, - account=_insights_account(), - rules=[_rule_track_record()], - closed_trade_count=12, - ) - base.update(overrides) - return InsightsReport(**base) - - -def _journal_entry(**overrides: Any) -> JournalEntry: - base: dict[str, Any] = dict( - closed_at=NOW_TS - 60, - opened_at=NOW_TS - 3600, - rule_name="turtle_breakout", - product_id="BTC-USD", - qty=Decimal("0.01"), - entry_fill=Decimal("64000"), - exit_fill=Decimal("65000"), - pnl_net=Decimal("10.00"), - fees=Decimal("0.50"), - r_multiple=Decimal("1.2"), - is_dca=False, - outcome="win", - ) - base.update(overrides) - return JournalEntry(**base) - - -def _journal_report(**overrides: Any) -> JournalReport: - base: dict[str, Any] = dict( - now_ts=NOW_TS, - mode="paper", - entries=[_journal_entry()], - total_count=1, - filters={"rule": None, "asset": None, "since_ts": None, "until_ts": None, - "limit": 5, "include_open": False}, - ) - base.update(overrides) - return JournalReport(**base) - - -def test_build_insights_screen_is_nonempty_and_titled() -> None: - report = _insights_report() - lines = build_insights_screen(report) - assert lines - assert lines[0].style == "heading" - assert "insights" in lines[0].text.lower() - - -def test_build_insights_screen_includes_rule_name_and_gate() -> None: - report = _insights_report() - lines = build_insights_screen(report) - texts = " ".join(line.text for line in lines) - assert "turtle_breakout" in texts - assert "gate:" in texts - - -def test_build_insights_screen_includes_account_summary_line() -> None: - report = _insights_report() - lines = build_insights_screen(report) - texts = " ".join(line.text for line in lines) - assert "mode: paper" in texts - assert "paper_cash_usdc" in texts - - -def test_build_insights_screen_handles_zero_trade_report_with_friendly_line() -> None: - """An empty-DB/zero-trade report must render a friendly explanatory line, not a blank.""" - report = _insights_report(rules=[], closed_trade_count=0) - lines = build_insights_screen(report) - texts = [line.text for line in lines] - assert any(t.strip() for t in texts) # not all-blank - joined = " ".join(texts).lower() - assert "no rule track record yet" in joined - - -def test_build_insights_screen_gate_passing_is_ok_style() -> None: - passing_gate = _gate(passing=True, blocking_reasons=[]) - report = _insights_report(rules=[_rule_track_record(gate=passing_gate)]) - lines = build_insights_screen(report) - gate_line = next(line for line in lines if line.text.strip().startswith("gate:")) - assert gate_line.style == "ok" - assert "PASSING" in gate_line.text - - -def test_build_insights_screen_gate_blocked_is_warn_style() -> None: - report = _insights_report(rules=[_rule_track_record(gate=_gate(passing=False))]) - lines = build_insights_screen(report) - gate_line = next(line for line in lines if line.text.strip().startswith("gate:")) - assert gate_line.style == "warn" - assert "blocked" in gate_line.text - - -def test_build_insights_screen_without_journal_report_omits_recent_trades() -> None: - report = _insights_report() - lines = build_insights_screen(report) - texts = " ".join(line.text.lower() for line in lines) - assert "recent trades" not in texts - - -def test_build_insights_screen_includes_journal_tail_when_provided() -> None: - report = _insights_report() - journal = _journal_report() - lines = build_insights_screen(report, journal) - texts = " ".join(line.text for line in lines) - assert "recent trades" in texts.lower() - assert "BTC-USD" in texts - - -def test_build_insights_screen_is_read_only_pure() -> None: - """Calling it twice on the same fixture reports must be idempotent -- it never mutates its - inputs (frozen dataclasses would raise on mutation anyway, but this guards intent).""" - report = _insights_report() - journal = _journal_report() - first = build_insights_screen(report, journal) - second = build_insights_screen(report, journal) - assert [line.text for line in first] == [line.text for line in second] - - -# -- _scroll_offset (pure, shared by help/insights/screen/propose/discover) ---------------------- - - -def test_scroll_offset_up_and_down_move_by_one() -> None: - fake_curses = _fake_curses() - assert _scroll_offset(fake_curses.KEY_UP, 5, height=10, total=50, curses_mod=fake_curses) == 4 - assert _scroll_offset(ord("k"), 5, height=10, total=50, curses_mod=fake_curses) == 4 - assert _scroll_offset(fake_curses.KEY_DOWN, 5, height=10, total=50, curses_mod=fake_curses) == 6 - assert _scroll_offset(ord("j"), 5, height=10, total=50, curses_mod=fake_curses) == 6 - - -def test_scroll_offset_page_up_and_down_move_by_almost_a_screen() -> None: - fake_curses = _fake_curses() - result = _scroll_offset(fake_curses.KEY_PPAGE, 20, height=10, total=50, curses_mod=fake_curses) - assert result == 11 - result = _scroll_offset(fake_curses.KEY_NPAGE, 20, height=10, total=50, curses_mod=fake_curses) - assert result == 29 - - -def test_scroll_offset_home_jumps_to_top() -> None: - fake_curses = _fake_curses() - result = _scroll_offset(fake_curses.KEY_HOME, 20, height=10, total=50, curses_mod=fake_curses) - assert result == 0 - - -@pytest.mark.parametrize( - ("height", "total", "expected"), - [ - (10, 50, 40), # the ordinary case: last full page - (10, 51, 41), # +1 line of content moves the floor by exactly 1 (catches an off-by-one) - (10, 10, 0), # content exactly fills the window -- nowhere to scroll - (10, 3, 0), # content SHORTER than the window -- End must not scroll past the top - (1, 50, 49), # a one-row terminal still lands on the true last line - ], -) -def test_scroll_offset_end_jumps_to_the_last_full_page( - height: int, total: int, expected: int -) -> None: - """`End` sets the offset to `total` and lets the shared clamp bring it back to the last full - page. - - Parametrized rather than asserted at a single point because one point does not pin the - RELATIONSHIP between window and content: the interesting cases are the boundaries, where - content exactly fills the window, is shorter than it (End must be a no-op, not a scroll into - blank space), or is one line longer than a page (the floor must move by exactly one). - - Worth knowing before "tightening" this: `offset = total` is not the only correct - implementation. The trailing clamp is `min(offset, max(0, total - height))`, so ANY value at - or above `total - height` is indistinguishable from any other -- `total - 1` included. That - is not an off-by-one waiting to be caught, it is the same function; a test asserting `total` - specifically would be pinning an implementation detail rather than the behaviour. What these - cases do catch is an End that lands BELOW the floor (e.g. `total // 2`, or a forgotten clamp - letting it run past the end).""" - fake_curses = _fake_curses() - result = _scroll_offset( - fake_curses.KEY_END, 0, height=height, total=total, curses_mod=fake_curses - ) - assert result == expected - - -def test_scroll_offset_clamps_negative_to_zero() -> None: - fake_curses = _fake_curses() - assert _scroll_offset(fake_curses.KEY_UP, 0, height=10, total=50, curses_mod=fake_curses) == 0 - - -def test_scroll_offset_clamps_past_the_last_full_page() -> None: - fake_curses = _fake_curses() - result = _scroll_offset(fake_curses.KEY_DOWN, 40, height=10, total=50, curses_mod=fake_curses) - assert result == 40 - - -def test_scroll_offset_unrecognized_key_is_a_noop() -> None: - """A no-key poll (`getch()` returns `-1` on timeout) or any other unmapped keycode must leave - the offset exactly where it was (still clamped) -- this is what makes it safe to route EVERY - keypress in a scrollable overlay through this function, not just the six scroll keys.""" - fake_curses = _fake_curses() - assert _scroll_offset(-1, 5, height=10, total=50, curses_mod=fake_curses) == 5 - - -# -- _admission_line_style (pure) ----------------------------------------------------------------- - - -@pytest.mark.parametrize( - "text,expected", - [ - ("ADMIT BTC bars=2000 median_daily_volume=2000000 on-allowlist attested", "ok"), - ("REJECT SOL bars=0 median_daily_volume=0 not-on-allowlist UNATTESTED", "warn"), - (" ✗ history: only 500 bars, need 1460", "warn"), - (" ! sector unknown -- treated as non-yielding until attested", "warn"), - ("INVALID missing asset: {'rationale': 'r'}", "warn"), - ("⚠️ These are PROPOSALS, not admissions. Nothing above has been screened.", "alert"), - ("some other plain line", "normal"), - ("shortlist: /home/user/keel/proposals/2026-08-01.json", "normal"), - ], -) -def test_admission_line_style_conventions(text: str, expected: str) -> None: - assert _admission_line_style(text) == expected - - -def test_admission_line_style_missing_history_line_is_muted_not_alert_or_warn() -> None: - """`! no local history` -- and its MISSING-DATA continuation line from `missing_history_ - lines` -- must NOT read as an alarm: `keel.compliance.screen.split_failures`'s whole reason - for existing is that "never fetched" is not a verdict about the asset (see - `render_screen_report`'s own docstring), so painting it `"warn"`/`"alert"`, the colours a - REAL rejection reason gets, would visually assert the opposite of what the text says.""" - missing = ( - " ! no local history for SOL-USD -- run `keel fetch --products SOL-USD` first, " - "then re-screen." - ) - style = _admission_line_style(missing) - assert style == "muted" - assert style not in ("alert", "warn") - - continuation = ( - " This is a MISSING-DATA verdict, not a verdict about the asset: it is not too " - "young, we have simply never fetched candles for it." - ) - style = _admission_line_style(continuation) - assert style == "muted" - assert style not in ("alert", "warn") - - -# -- build_admission_screen_overlay / build_propose_overlay / build_discover_overlay (pure) ------ - - -def _fake_screen_fn(*, admitted: bool = True): - """A minimal `ScreenFn` stub -- deliberately NOT `_screen_product` itself, since these tests - exercise `build_admission_screen_overlay`/`build_propose_overlay` (pure styling over an - already-built report), not the admission gate's own logic (covered by - `tests/commands/test_admission.py`).""" - - def _screen(repo: Repository, product: str, quote: str): - facts = screen_mod.MarketFacts( - asset=product.split("-")[0], - daily_bars=2000, - median_daily_volume=Decimal("2000000"), - quotable_in_settlement_currency=True, - product_id=product, - venue="coinbase", - ) - result = screen_mod.ScreenResult(asset=facts.asset, admitted=admitted) - return facts, result - - return _screen - - -def test_build_admission_screen_overlay_is_nonempty_titled_and_headed(repo: Repository) -> None: - from keel.commands.admission import build_screen_report - - config = _config(allowlist=["BTC"]) - report = build_screen_report(repo, config, _fake_screen_fn()) - - lines = build_admission_screen_overlay(report) - - assert lines - assert lines[0].style == "heading" - assert lines[0].text == "keel tui -- screen" - - -def test_build_propose_overlay_is_nonempty_titled_and_headed(repo: Repository, tmp_path) -> None: - from keel.commands.admission import build_propose_view - - config = _config(proposals_dir=str(tmp_path / "proposals")) - view = build_propose_view(repo, config, _fake_screen_fn(), directory=tmp_path / "proposals") - - lines = build_propose_overlay(view) - - assert lines - assert lines[0].style == "heading" - assert lines[0].text == "keel tui -- propose" - - -def test_build_discover_overlay_with_report_is_nonempty_titled_and_headed() -> None: - candidate = screen_mod.Candidate( - product_id="SOL-USD", asset="SOL", base_name="Solana", quote_24h_volume=Decimal("9000000") - ) - report = DiscoverReport( - quote="USD", - venue_product_count=900, - candidates=(candidate,), - min_quote_24h_volume=Decimal("5000000"), - excluded=screen_mod.DiscoveryExclusions(below_volume_floor=899), - ) - - lines = build_discover_overlay(report) - - assert lines - assert lines[0].style == "heading" - assert lines[0].text == "keel tui -- discover" - assert any("SOL-USD" in line.text for line in lines) - - -def test_build_discover_overlay_none_renders_armed_explanation_and_the_run_key() -> None: - """The state `build_discover_overlay(None)` renders is the proof that opening the discover - overlay makes NO network call -- it must say so plainly, name what Enter will do, and name - Enter itself, not just render a blank or "loading" screen.""" - lines = build_discover_overlay(None) - - assert lines[0].style == "heading" - assert lines[0].text == "keel tui -- discover" - text = " ".join(line.text for line in lines) - assert "ARMED" in text - assert "no network call" in text.lower() - assert "Enter" in text - - -def test_build_discover_overlay_with_error_renders_readable_failure_not_a_traceback() -> None: - lines = build_discover_overlay(None, error="could not reach coinbase.com") - - text = " ".join(line.text for line in lines) - assert "discover failed" in text.lower() - assert "could not reach coinbase.com" in text - failure_line = next(line for line in lines if "discover failed" in line.text.lower()) - assert failure_line.style == "alert" - - -# -- toggle_autonomy / _guarded (injectable actions, no curses/network) --------------------------- - - -class _FakeProfile: - def __init__(self, live: bool) -> None: - self._live = live - - def is_autonomous(self, now_ts: int) -> bool: - return self._live - - -class _FakeAutonomyRepo: - def __init__(self, live: bool) -> None: - self._profile = _FakeProfile(live) - self.set_autonomous_calls: list[tuple[bool, int]] = [] - - def get_profile(self) -> _FakeProfile: - return self._profile - - def set_autonomous(self, value: bool, now_ts: int, expires_ts: int | None = None) -> None: - self.set_autonomous_calls.append((value, now_ts)) - - -def test_toggle_autonomy_on_to_off_is_immediate_and_ungated() -> None: - repo = _FakeAutonomyRepo(live=True) - confirm_calls: list[bool] = [] - - result = toggle_autonomy(repo, NOW_TS, lambda: confirm_calls.append(True) or True) - - assert repo.set_autonomous_calls == [(False, NOW_TS)] - assert not confirm_calls # confirm_fn never consulted for de-risking - assert "off" in result.lower() - - -def test_toggle_autonomy_off_to_on_confirmed() -> None: - repo = _FakeAutonomyRepo(live=False) - - result = toggle_autonomy(repo, NOW_TS, lambda: True) - - assert repo.set_autonomous_calls == [(True, NOW_TS)] - assert "on" in result.lower() - - -def test_toggle_autonomy_off_to_on_declined() -> None: - repo = _FakeAutonomyRepo(live=False) - - result = toggle_autonomy(repo, NOW_TS, lambda: False) - - assert repo.set_autonomous_calls == [] - assert "cancelled" in result.lower() - - -def test_guarded_returns_fn_result_on_success() -> None: - assert _guarded("fetch", lambda: "ok") == "ok" - - -def test_guarded_returns_label_failed_message_on_exception() -> None: - def _raise() -> str: - raise RuntimeError("boom") - - result = _guarded("fetch", _raise) - assert result == "fetch failed: boom" - - -def test_guarded_does_not_catch_keyboard_interrupt() -> None: - def _raise() -> str: - raise KeyboardInterrupt - - with pytest.raises(KeyboardInterrupt): - _guarded("fetch", _raise) - - -# -- _message_style ------------------------------------------------------------------------------- - - -def test_message_style_arm_on_is_alert_not_ok() -> None: - """The dangerous ON transition must never be painted as reassuring green.""" - assert _message_style("autonomy -> ON (orders placed WITHOUT asking)") == "alert" - - -def test_message_style_off_is_ok() -> None: - assert _message_style("autonomy -> OFF (every order will ask first)") == "ok" - - -def test_message_style_failed_is_alert() -> None: - assert _message_style("fetch failed: boom") == "alert" - - -def test_message_style_cancelled_is_warn() -> None: - assert _message_style("autonomy unchanged (arming cancelled)") == "warn" - - -def test_message_style_a_live_profile_switch_is_alert_not_ok() -> None: - """The one switch that must never read as reassuring green: `profile -> LIVE` points - the whole console at REAL MONEY, so it carries the same weight the LIVE banner line and - arming autonomy ON get -- green there would be the toast colour saying 'all well' about - real-account data starting to answer from every screen.""" - assert ( - _message_style("profile -> LIVE (config.live-sandbox.yaml + keel-live.db)") - == "alert" - ) - - -def test_message_style_a_paper_profile_switch_stays_ok() -> None: - """Paper switches are the calm, ungated case -- they keep the reassuring green.""" - assert ( - _message_style( - "profile -> paper-hourly (config.paper-hourly.yaml + keel-paperhourly.db)" - ) - == "ok" - ) - - -def test_message_style_fetch_complete_is_ok() -> None: - assert _message_style("fetch complete (2 products, 5y history)") == "ok" - - -# -- _confirm_arm_autonomy (fake curses module, fail-closed) --------------------------------------- - - -def test_confirm_arm_autonomy_true_on_typed_yes_and_restores_screen( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_curses = _fake_curses() - monkeypatch.setitem(sys.modules, "curses", fake_curses) - monkeypatch.setattr( - "keel.commands._common._require_interactive_confirmation", - lambda action, detail: None, - ) - stdscr = _FakeStdscr(height=24, width=80) - config = _config() - - assert _confirm_arm_autonomy(stdscr, config) is True - assert stdscr.refresh_calls == 1 - - -def test_confirm_arm_autonomy_false_on_aborted_confirmation_and_restores_screen( - monkeypatch: pytest.MonkeyPatch, -) -> None: - import click - - fake_curses = _fake_curses() - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def _raise_aborted(action: str, detail: str) -> None: - raise click.ClickException("aborted") - - monkeypatch.setattr( - "keel.commands._common._require_interactive_confirmation", _raise_aborted - ) - stdscr = _FakeStdscr(height=24, width=80) - config = _config() - - assert _confirm_arm_autonomy(stdscr, config) is False - assert stdscr.refresh_calls == 1 - - -def test_confirm_arm_autonomy_fails_closed_when_endwin_raises( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_curses = _fake_curses() - - def _raise_endwin() -> None: - raise fake_curses.error("endwin failed") - - fake_curses.endwin = _raise_endwin - fake_curses.def_prog_mode = lambda: None - fake_curses.reset_prog_mode = lambda: None - monkeypatch.setitem(sys.modules, "curses", fake_curses) - stdscr = _FakeStdscr(height=24, width=80) - config = _config() - - assert _confirm_arm_autonomy(stdscr, config) is False - - -# -- CLI ---------------------------------------------------------------------------------------- - - -def _repo_at(db_path) -> Repository: - conn = connect(str(db_path)) - migrate(conn) - return Repository(conn) - - -def test_tui_once_command_exits_zero_and_prints_frame(tmp_path, valid_config_path) -> None: - db_path = tmp_path / "keel.db" - _repo_at(db_path).set_state("kill_switch", False) - - result = CliRunner().invoke( - cli, ["--db", str(db_path), "--config", str(valid_config_path), "tui", "--once"] - ) - - assert result.exit_code == 0, result.output - assert "paper mode" in result.output - assert "quit" in result.output.lower() - assert "help" in result.output.lower() - - -def test_tui_zero_interval_without_once_is_rejected(tmp_path, valid_config_path) -> None: - db_path = tmp_path / "keel.db" - _repo_at(db_path).set_state("kill_switch", False) - - result = CliRunner().invoke( - cli, - [ - "--db", - str(db_path), - "--config", - str(valid_config_path), - "tui", - "--interval", - "0", - "--once", - ], - ) - - assert result.exit_code != 0 - - -def test_tui_negative_interval_is_rejected(tmp_path, valid_config_path) -> None: - db_path = tmp_path / "keel.db" - _repo_at(db_path).set_state("kill_switch", False) - - result = CliRunner().invoke( - cli, - [ - "--db", - str(db_path), - "--config", - str(valid_config_path), - "tui", - "--interval", - "-1", - "--once", - ], - ) - - assert result.exit_code != 0 - - -# -- interactive-terminal guard (no curses under CliRunner / pipes) ---------------------------- - - -def test_stdio_is_interactive_requires_both_tty(monkeypatch: pytest.MonkeyPatch) -> None: - """True only when BOTH stdin and stdout are TTYs -- either being a pipe means the full-screen - loop cannot run.""" - monkeypatch.setattr(sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(sys.stdout, "isatty", lambda: True) - assert _stdio_is_interactive() is True - - monkeypatch.setattr(sys.stdout, "isatty", lambda: False) - assert _stdio_is_interactive() is False - - -def test_tui_without_once_needs_interactive_terminal(tmp_path, valid_config_path) -> None: - """`keel tui` (live) under CliRunner -- stdin/stdout are not TTYs -- must fail with a clean, - actionable message pointing at `--once`, NOT enter curses and dump a traceback.""" - db_path = tmp_path / "keel.db" - _repo_at(db_path).set_state("kill_switch", False) - - result = CliRunner().invoke( - cli, ["--db", str(db_path), "--config", str(valid_config_path), "tui"] - ) - - assert result.exit_code != 0 - assert "interactive terminal" in result.output.lower() - assert "--once" in result.output - - -def test_run_live_wraps_curses_error_as_clickexception( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Belt-and-braces: if `curses.wrapper` itself raises `curses.error` (e.g. `cbreak() - returned ERR` on a TTY that passes `isatty()` but can't be put into cbreak mode), `run_live` - turns it into a `click.ClickException` with a helpful message rather than a raw traceback.""" - import click - - fake_curses = _fake_curses() - - def _raise_wrapper(_fn: Any) -> None: - raise fake_curses.error("cbreak() returned ERR") - - fake_curses.wrapper = _raise_wrapper - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def _must_not_open() -> Any: - raise AssertionError("open_state must not be called -- wrapper raised first") - - with pytest.raises(click.ClickException) as excinfo: - run_live(_must_not_open, lambda: 0, 5.0) - - assert "--once" in str(excinfo.value) - - -def test_run_live_r_toasts_that_it_refreshed( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """`r` re-reads local state and forces the balance re-fetch, but set no message -- so the one - keypress that always "works" was indistinguishable from a dead key. Every other action key - (`a`, `f`) toasts; this one must too. - """ - config = _config() - # poll1: normal -> 'r'. poll2: the repaint that must carry the toast. poll3: 'q' (default). - stdscr = _KeySequenceStdscr(height=24, width=80, keys=[ord("r"), -1]) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted_texts = [call[2] for call in stdscr.calls] - assert any("refreshed" in t for t in painted_texts) - - -def test_the_refresh_toast_reads_as_ok_not_as_a_failure() -> None: - """It is a routine, successful action -- it must not paint in the alert/warn colours reserved - for a failure, a cancelled action, or arming autonomy.""" - assert _message_style(_REFRESH_MESSAGE) == "ok" - - -# -- activity overlay (v): the pure builder ------------------------------------------------------ - - -def _activity_line(event: str, ts: float, cycle_id: str | None = "cyc-1", **fields: Any) -> str: - """One JSONL record in `keel_core.telemetry.JsonFormatter`'s shape. A local copy of - `tests/commands/test_activity.py`'s helper on purpose: the exhaustive parsing/grouping - coverage lives over there, and these tests only need enough of a log to prove the OVERLAY - renders and scrolls it.""" - payload: dict[str, Any] = { - "ts": ts, - "level": fields.pop("level", "INFO"), - "logger": "keel.agent", - "event": event, - "venue": "coinbase", - } - if cycle_id is not None: - payload["cycle_id"] = cycle_id - payload.update(fields) - return json.dumps(payload) - - -#: A two-cycle log: an ordinary quiet cycle, then the real 2026-08-08 PAXG shape -- a setup, a -#: guard violation, and an entry that was not placed. -_ACTIVITY_TS = 1_786_194_006.0 - -#: The same fixture re-anchored onto NOW_TS's OWN local calendar day, for the `run_live` tests -- -#: which now build a TODAY-scoped feed, so a fixture stamped 2026-08-08 would (correctly) render -#: the "keel has not run yet today" empty state instead of the rows those tests are about. Both -#: cycles land inside the one local day, two hours apart, so they stay two rows in newest-first -#: order. Derived from `scope_start_ts` and the fixed `NOW_TS` rather than from a live clock, so -#: it is exactly as deterministic as the constant it is built from. -_ACTIVITY_TODAY_TS = (scope_start_ts("today", float(NOW_TS)) or 0.0) + 9 * 3600 - - -def _activity_log_lines(base: float = _ACTIVITY_TS, gap: float = 86400.0) -> list[str]: - """A quiet cycle at `base`, then a rail-vetoed one `gap` seconds later. `gap` is a parameter - so the same shapes can be laid out across two days (the default -- what the real deployment - does) or inside one (`_today_activity_log_lines`).""" - later = base + gap - return [ - _activity_line("agent.cycle_start", base, "quiet-1"), - _activity_line("agent.mode_resolved", base + 1, "quiet-1", mode="paper"), - _activity_line( - "agent.signals_evaluated", - base + 2, - "quiet-1", - product="BTC-USD", - rule_count=1, - signal_count=0, - ), - _activity_line("agent.cycle_start", later, "veto-1"), - _activity_line("agent.mode_resolved", later + 1, "veto-1", mode="paper"), - _activity_line( - "engine.setup_detected", - later + 2, - "veto-1", - rule="turtle_breakout", - product="PAXG-USD", - cts_score=5, - technique="signal_candle", - entry="4342.52", - stop="4197.09381782563408", - target="5215.07709304619552", - ), - _activity_line( - "agent.signals_evaluated", - later + 2, - "veto-1", - product="PAXG-USD", - rule_count=1, - signal_count=1, - ), - _activity_line( - "guards.check_failed", - later + 2, - "veto-1", - product="PAXG-USD", - side="BUY", - violation=( - "per_asset_concentration_cap: PAXG exposure 3284.671252850915628790264696 " - "exceeds 0.5 of max_exposure_usd (2500.0)" - ), - ), - _activity_line( - "agent.enter_evaluated", - later + 2, - "veto-1", - product="PAXG-USD", - rule="turtle_breakout", - technique="signal_candle", - cts_score=5, - placed=False, - reason="paper: vetoed by rails", - ), - ] - - -def _today_activity_log_lines() -> list[str]: - """Both cycles inside NOW_TS's local calendar day -- what the TODAY-scoped overlay shows.""" - return _activity_log_lines(base=_ACTIVITY_TODAY_TS, gap=7200.0) - - -def _write_activity_log(tmp_path: Any, lines: list[str] | None = None) -> str: - path = tmp_path / "logs" / "keel.log" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("\n".join(_today_activity_log_lines() if lines is None else lines) + "\n") - return str(path) - - -def _activity_feed() -> Any: - return feed_from_lines(_activity_log_lines(), source="/tmp/keel.log") - - -def test_build_activity_overlay_is_titled_and_lists_one_row_per_cycle_newest_first() -> None: - lines = build_activity_overlay(_activity_feed()) - texts = [line.text for line in lines] - - assert texts[0] == "keel tui -- activity" - veto_idx = next(i for i, t in enumerate(texts) if "rail veto" in t) - quiet_idx = next(i for i, t in enumerate(texts) if "quiet -- looked" in t) - assert veto_idx < quiet_idx # newest first - - -def test_build_activity_overlay_collapsed_does_not_list_individual_events() -> None: - texts = [line.text for line in build_activity_overlay(_activity_feed())] - - assert not any("engine.setup_detected" in t for t in texts) - - -def test_build_activity_overlay_expanded_lists_the_cycles_events_in_time_order() -> None: - feed = _activity_feed() - texts = [ - line.text - for line in build_activity_overlay(feed, cursor=0, expanded=frozenset({"veto-1"})) - ] - - setup_idx = next(i for i, t in enumerate(texts) if "engine.setup_detected" in t) - guard_idx = next(i for i, t in enumerate(texts) if "guards.check_failed" in t) - enter_idx = next(i for i, t in enumerate(texts) if "agent.enter_evaluated" in t) - assert setup_idx < guard_idx < enter_idx - # ...and the fields that carry the meaning, not just the event names. - assert any("entry=4342.52" in t for t in texts) - assert any("per_asset_concentration_cap" in t for t in texts) - assert any("paper: vetoed by rails" in t for t in texts) - - -def test_activity_overlay_styles_a_veto_cycle_differently_from_a_quiet_one() -> None: - """The at-a-glance requirement: a rail veto must not read the same as a quiet cycle.""" - lines = build_activity_overlay(_activity_feed()) - veto = next(line for line in lines if "rail veto" in line.text) - quiet = next(line for line in lines if "quiet -- looked" in line.text) - - assert veto.style != quiet.style - assert quiet.style == "muted" - - -def test_activity_overlay_tells_the_operator_how_to_use_it() -> None: - texts = [line.text for line in build_activity_overlay(_activity_feed())] - - footer = texts[-1] - assert "expand" in footer - assert "close" in footer - - -@pytest.mark.parametrize( - ("status", "expected_fragment"), - [ - ("missing", "No engine log found"), - ("empty", "is empty"), - ("unparseable", "could be parsed"), - ("oversized", "No complete record"), - ("unreadable", "could not be read"), - ], -) -def test_activity_overlay_explains_a_broken_log_instead_of_rendering_blank( - status: str, expected_fragment: str -) -> None: - """A blank overlay would be indistinguishable from the dead-looking dashboard this whole - feature exists to disprove -- so every failure mode renders words.""" - feed = ActivityFeed(status=status, source="/tmp/keel.log", detail="because reasons") - - lines = build_activity_overlay(feed) - texts = [line.text for line in lines] - - assert texts[0] == "keel tui -- activity" - assert any(expected_fragment in t for t in texts) - assert any("Press v or Esc" in t for t in texts) - - -def test_activity_overlay_on_a_readable_log_with_no_cycles_says_so() -> None: - feed = ActivityFeed(status="ok", source="/tmp/keel.log", cycles=()) - - texts = [line.text for line in build_activity_overlay(feed)] - - assert any("No cycles in the window" in t for t in texts) - - -# -- activity overlay: the cursor ---------------------------------------------------------------- - - -def test_activity_cursor_line_points_at_the_selected_row() -> None: - feed = _activity_feed() - - for cursor in range(len(feed.cycles)): - lines, cursor_line = _activity_lines(feed, cursor=cursor) - assert lines[cursor_line].text.startswith(">") - - -def test_activity_cursor_line_survives_a_row_above_it_being_expanded() -> None: - """The arithmetic that would drift if the cursor's screen position were computed by a second - copy of the layout: expanding the FIRST row pushes the second one down by its event count.""" - feed = _activity_feed() - - lines, cursor_line = _activity_lines(feed, cursor=1, expanded=frozenset({"veto-1"})) - - assert lines[cursor_line].text.startswith(">") - assert "quiet -- looked" in lines[cursor_line].text - - -def test_activity_cursor_line_on_an_empty_feed_is_in_range() -> None: - lines, cursor_line = _activity_lines(ActivityFeed(status="ok", source="x", cycles=())) - - assert 0 <= cursor_line < len(lines) - - -@pytest.mark.parametrize( - ("key", "start", "expected"), - [ - ("KEY_DOWN", 0, 1), - ("KEY_UP", 3, 2), - ("KEY_HOME", 4, 0), - ("KEY_END", 0, 4), - ("KEY_NPAGE", 0, 4), # a page is larger than this feed -- clamped to the last row - ("KEY_PPAGE", 4, 0), - ], -) -def test_activity_cursor_moves_by_rows_and_clamps(key: str, start: int, expected: int) -> None: - fake_curses = _fake_curses() - - assert _activity_cursor(getattr(fake_curses, key), start, 24, 5, fake_curses) == expected - - -def test_activity_cursor_accepts_the_same_vi_keys_the_other_overlays_scroll_with() -> None: - fake_curses = _fake_curses() - - assert _activity_cursor(ord("j"), 0, 24, 5, fake_curses) == 1 - assert _activity_cursor(ord("k"), 2, 24, 5, fake_curses) == 1 - - -def test_activity_cursor_never_leaves_the_feed() -> None: - fake_curses = _fake_curses() - - assert _activity_cursor(fake_curses.KEY_UP, 0, 24, 5, fake_curses) == 0 - assert _activity_cursor(fake_curses.KEY_DOWN, 4, 24, 5, fake_curses) == 4 - # An empty feed has no row to select at all. - assert _activity_cursor(fake_curses.KEY_DOWN, 0, 24, 0, fake_curses) == 0 - - -def test_activity_cursor_ignores_an_unrelated_key() -> None: - fake_curses = _fake_curses() - - assert _activity_cursor(ord("z"), 2, 24, 5, fake_curses) == 2 - assert _activity_cursor(-1, 2, 24, 5, fake_curses) == 2 # the no-key poll timeout - - -def test_activity_cursor_pages_by_the_rows_the_banner_leaves_free() -> None: - """A page leaves the title, blank and header rows in view -- and, when a console - binding is present, the two BANNER lines the feed is prepended to as well: paging used - to over-advance by exactly the banner, landing the selection further down than a screen - of rows the operator actually saw.""" - fake_curses = _fake_curses() - - # Without a banner: a page on a 24-row terminal is 21 rows. - assert _activity_cursor(fake_curses.KEY_NPAGE, 10, 24, 50, fake_curses) == 31 - assert _activity_cursor(fake_curses.KEY_PPAGE, 30, 24, 50, fake_curses) == 30 - 21 - # With the 2-line banner prepended: two fewer rows per page. - assert ( - _activity_cursor(fake_curses.KEY_NPAGE, 10, 24, 50, fake_curses, banner_lines=2) - == 29 - ) - assert ( - _activity_cursor(fake_curses.KEY_PPAGE, 30, 24, 50, fake_curses, banner_lines=2) - == 30 - 19 - ) - # The floor of 1 survives the banner: a tiny terminal still advances. - assert _activity_cursor(fake_curses.KEY_NPAGE, 0, 4, 50, fake_curses, banner_lines=2) == 1 - - -@pytest.mark.parametrize( - ("offset", "cursor_line", "height", "expected"), - [ - (0, 5, 24, 0), # already visible -- do not move the view at all - (10, 5, 24, 5), # above the window -- scroll up to it - (0, 30, 24, 7), # below the window -- scroll down the minimum - (0, 5, 0, 0), # a terminal mid-resize must not divide by anything - (3, 3, 1, 3), - ], -) -def test_follow_cursor_makes_the_smallest_change_that_reveals_the_cursor( - offset: int, cursor_line: int, height: int, expected: int -) -> None: - assert _follow_cursor(offset, cursor_line, height) == expected - - -# -- activity overlay: the live loop ------------------------------------------------------------- - - -def test_footer_lines_advertise_the_activity_key() -> None: - texts = [line.text for line in _footer_lines()] - - assert any("[v] activity" in t for t in texts) - - -def test_help_screen_documents_the_activity_overlay_and_its_keys() -> None: - texts = [line.text for line in build_help_screen()] - joined = " ".join(texts) - - assert any(t.strip().startswith("v ") for t in texts) - assert "Activity overlay (v)" in joined - assert "q / Esc / v" in joined - assert "Enter / Space" in joined - # The two claims the design rests on: it is offline, and the read is bounded. - assert "BOUNDED" in joined - assert "logging.file" in joined - - -def test_run_live_v_opens_activity_overlay_and_esc_closes_it( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - """Mirrors the 's'/'p' tests: 'v' opens, Esc closes back to the dashboard.""" - config = _config(logging=LoggingConfig(file=_write_activity_log(tmp_path))) - stdscr = _KeySequenceStdscr(height=24, width=200, keys=[ord("v"), -1, 27]) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted_texts = [call[2] for call in stdscr.calls] - activity_idx = next(i for i, t in enumerate(painted_texts) if "keel tui -- activity" in t) - dashboard_after_idx = next( - i for i, t in enumerate(painted_texts) if i > activity_idx and "paper mode" in t - ) - assert dashboard_after_idx > activity_idx - - -def test_run_live_activity_overlay_paints_the_real_cycles_from_the_configured_log( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - """The end-to-end claim: the path comes from `logging.file`, the rows come from that file, - and a rail-vetoed cycle is legible without expanding anything.""" - config = _config(logging=LoggingConfig(file=_write_activity_log(tmp_path))) - stdscr = _KeySequenceStdscr(height=40, width=240, keys=[ord("v"), -1]) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted_texts = [call[2] for call in stdscr.calls] - assert any("rail veto: per_asset_concentration_cap" in t for t in painted_texts) - assert any("quiet -- looked, nothing to do" in t for t in painted_texts) - - -def test_run_live_activity_enter_expands_the_selected_cycle( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - config = _config(logging=LoggingConfig(file=_write_activity_log(tmp_path))) - # poll1: normal -> 'v'. poll2: activity, Enter expands row 0 (the newest, vetoed cycle). - # poll3: activity, repainted expanded. poll4: 'q' (the stdscr's post-exhaustion default). - stdscr = _KeySequenceStdscr(height=40, width=240, keys=[ord("v"), 10, -1]) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted_texts = [call[2] for call in stdscr.calls] - assert any("guards.check_failed" in t for t in painted_texts) - assert any("paper: vetoed by rails" in t for t in painted_texts) - - -def test_run_live_activity_overlay_reports_a_missing_log_rather_than_crashing( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - config = _config(logging=LoggingConfig(file=str(tmp_path / "nowhere" / "keel.log"))) - stdscr = _KeySequenceStdscr(height=24, width=200, keys=[ord("v"), -1]) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted_texts = [call[2] for call in stdscr.calls] - assert any("No engine log found" in t for t in painted_texts) - - -def test_run_live_activity_survives_a_transient_read_error_and_keeps_polling( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - """`open_state()` itself failing (a locked DB from a concurrent `keel agent` writer) must - become a readable overlay, not a crash -- the same contract the insights/screen/propose - branches keep.""" - config = _config(logging=LoggingConfig(file=_write_activity_log(tmp_path))) - stdscr = _KeySequenceStdscr(height=24, width=200, keys=[ord("v"), -1, -1]) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - opens: list[int] = [] - - def open_state() -> tuple[Repository, Any]: - opens.append(1) - # Fail on the FIRST call made from inside the activity branch (calls 1 and 2 are the - # normal-mode status read and its balance refresh). - if len(opens) == 3: - raise sqlite3.OperationalError("database is locked") - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted_texts = [call[2] for call in stdscr.calls] - assert any("could not be read" in t for t in painted_texts) - # ...and it kept polling: a later frame rendered the real feed. - assert any("rail veto" in t for t in painted_texts) - - -def test_run_live_activity_overlay_never_builds_a_broker( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - """The overlay's whole admissibility argument is that reading a local file is neither a - broker nor the network. Opening it must add ZERO broker constructions on top of the ones the - normal-mode dashboard already makes (one slow-cadence balance refresh, here).""" - config = _config(logging=LoggingConfig(file=_write_activity_log(tmp_path))) - stdscr = _KeySequenceStdscr(height=24, width=200, keys=[ord("v"), -1, -1, 27]) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - built: list[Any] = [] - - def _recording_build_broker(cfg: Any, **kwargs: Any) -> Any: - built.append(cfg) - raise RuntimeError("no venue in tests") - - monkeypatch.setattr("keel.commands._common._build_broker", _recording_build_broker) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted_texts = [call[2] for call in stdscr.calls] - assert any("keel tui -- activity" in t for t in painted_texts) - # `now_fn` is constant, so the ~30s balance cadence fires exactly once, on the first poll. - # Any second construction could only have come from the activity branch. - assert len(built) == 1 - - -def test_run_live_activity_survives_a_log_record_whose_timestamp_cannot_be_rendered( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - """The renderer, not just the feed builder, runs on the repaint path. A `ts` no `strftime` - can format (a bad clock, a corrupted byte) must not escape `curses.wrapper` and kill the - dashboard -- which is why `run_live` wraps the RENDER, not only the build.""" - path = tmp_path / "keel.log" - path.write_text( - "\n".join( - [ - *_today_activity_log_lines(), - json.dumps({"ts": 1e20, "event": "agent.cycle_start", "cycle_id": "from-mars"}), - ] - ) - + "\n" - ) - config = _config(logging=LoggingConfig(file=str(path))) - stdscr = _KeySequenceStdscr(height=40, width=240, keys=[ord("v"), -1]) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) # must not raise - - painted_texts = [call[2] for call in stdscr.calls] - assert any("keel tui -- activity" in t for t in painted_texts) - assert any("rail veto" in t for t in painted_texts) - - -# -- activity overlay: day scoping --------------------------------------------------------------- -# -# The pure boundary/empty-state logic is exercised exhaustively in -# `tests/commands/test_activity.py`. These tests are about the OVERLAY and the live loop: that the -# scope is visible on screen, that `t` widens it, and that it is reset to `today` on every open. - - -def _yesterday_activity_log_lines() -> list[str]: - """One quiet cycle on the local day BEFORE NOW_TS's -- the history a `today` scope hides. - - The previous day's midnight is taken from `scope_start_ts` rather than by subtracting 86400 - from today's, so this lands on the intended civil day even across a DST transition.""" - ts = (scope_start_ts("today", float(NOW_TS) - 86400) or 0.0) + 9 * 3600 - return [ - _activity_line("agent.cycle_start", ts, "yesterday-1"), - _activity_line("agent.mode_resolved", ts + 1, "yesterday-1", mode="paper"), - # A gate rejection, so this cycle's COLLAPSED row names XLM-USD -- which is what makes - # "is yesterday on screen?" answerable from the painted text alone. A quiet cycle would - # render only "1 products" and be indistinguishable from today's. - _activity_line( - "engine.setup_rejected", - ts + 2, - "yesterday-1", - rule="turtle_breakout", - product="XLM-USD", - gate="choppy_regime", - ), - ] - - -def _scoped_activity_feed(scope: str = "today") -> Any: - """The two-cycles-today fixture plus one from yesterday, scoped as the overlay would.""" - return apply_scope( - feed_from_lines( - [*_yesterday_activity_log_lines(), *_today_activity_log_lines()], - source="/tmp/keel.log", - ), - scope, - now_ts=float(NOW_TS), - ) - - -def test_activity_overlay_header_states_the_scope_under_the_title() -> None: - """A one-row "today" view and a one-row log look identical without this line -- and the key - that would settle it would be invisible.""" - texts = [line.text for line in build_activity_overlay(_scoped_activity_feed())] - - assert texts[0] == "keel tui -- activity" - assert texts[1].startswith("scope: today ") - assert "1 older hidden" in texts[1] - assert "press t to widen" in texts[1] - - -def test_activity_overlay_scoped_to_today_hides_yesterdays_row() -> None: - texts = [line.text for line in build_activity_overlay(_scoped_activity_feed("today"))] - joined = " ".join(texts) - - assert "rail veto: per_asset_concentration_cap" in joined # today's vetoed cycle - assert "XLM-USD" not in joined # yesterday's, which only names XLM - - -def test_activity_overlay_widened_to_all_shows_the_earlier_day_again() -> None: - texts = [line.text for line in build_activity_overlay(_scoped_activity_feed("all"))] - - rows = [t for t in texts if t.startswith((" ▸", ">▸"))] - assert len(rows) == 3 - assert "all history in the window" in texts[1] - - -def test_activity_overlay_with_nothing_today_is_never_blank_and_names_the_last_run() -> None: - """The morning case, on screen: the panel must answer "is keel alive" without a single row.""" - feed = apply_scope( - feed_from_lines(_yesterday_activity_log_lines(), source="/tmp/keel.log"), - "today", - now_ts=float(NOW_TS), - ) - - lines = build_activity_overlay(feed) - texts = [line.text for line in lines] - joined = " ".join(texts) - - assert len(texts) > 3 - assert "keel has not run yet today." in texts - assert "Last cycle:" in joined - assert "yesterday" in joined - assert "Press t to widen the scope" in joined - # The column header is not painted over an empty day -- there are no columns to head. - assert ACTIVITY_HEADER not in texts - - -def test_activity_cursor_line_on_an_empty_scope_stays_in_range() -> None: - feed = apply_scope( - feed_from_lines(_yesterday_activity_log_lines(), source="/tmp/keel.log"), - "today", - now_ts=float(NOW_TS), - ) - - lines, cursor_line = _activity_lines(feed) - - assert 0 <= cursor_line < len(lines) - - -def test_activity_overlay_footer_advertises_the_scope_key() -> None: - footer = [line.text for line in build_activity_overlay(_scoped_activity_feed())][-1] - - assert "t scope" in footer - assert "expand" in footer - assert "close" in footer - - -def test_help_screen_documents_the_scope_and_the_t_key() -> None: - texts = [line.text for line in build_help_screen()] - joined = " ".join(texts) - - assert any(t.strip().startswith("t ") for t in texts) - assert "SCOPED TO TODAY" in joined - assert "today -> last 7 days -> all history in the window" in joined - assert "has not run yet" in joined - assert "reopened" in joined - - -def test_run_live_activity_opens_scoped_to_today( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - """The requirement, through the live loop: yesterday's cycle is in the log and is not shown.""" - config = _config( - logging=LoggingConfig( - file=_write_activity_log( - tmp_path, [*_yesterday_activity_log_lines(), *_today_activity_log_lines()] - ) - ) - ) - stdscr = _KeySequenceStdscr(height=40, width=240, keys=[ord("v"), -1]) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted = [call[2] for call in stdscr.calls] - assert any("scope: today " in t for t in painted) - assert any("rail veto: per_asset_concentration_cap" in t for t in painted) - assert not any("XLM-USD" in t for t in painted) # yesterday's, hidden - assert any("1 older hidden" in t for t in painted) - - -def test_run_live_activity_t_widens_the_scope_to_reveal_the_earlier_day( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - """`t` cycles today -> 7d -> all. Two presses reach `all`, and yesterday's cycle appears.""" - config = _config( - logging=LoggingConfig( - file=_write_activity_log( - tmp_path, [*_yesterday_activity_log_lines(), *_today_activity_log_lines()] - ) - ) - ) - stdscr = _KeySequenceStdscr( - height=40, width=240, keys=[ord("v"), ord("t"), ord("t"), -1] - ) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted = [call[2] for call in stdscr.calls] - assert any("scope: last 7 days from " in t for t in painted) - assert any("all history in the window" in t for t in painted) - assert any("XLM-USD" in t for t in painted) # yesterday's cycle, now in scope - - -def test_run_live_activity_reopens_at_today_after_being_widened( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - """A widened scope answers one question once; it must never become the next open's default.""" - config = _config( - logging=LoggingConfig( - file=_write_activity_log( - tmp_path, [*_yesterday_activity_log_lines(), *_today_activity_log_lines()] - ) - ) - ) - # open, widen to 7d, widen to all, close, reopen -- then repaint. - stdscr = _KeySequenceStdscr( - height=40, width=240, keys=[ord("v"), ord("t"), ord("t"), 27, ord("v"), -1] - ) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted = [call[2] for call in stdscr.calls] - all_scope_frame = max(i for i, t in enumerate(painted) if "all history in the window" in t) - reopened_frame = max(i for i, t in enumerate(painted) if "scope: today " in t) - - # The LAST activity frame painted is a `today` one, i.e. the reopen reset it. - assert reopened_frame > all_scope_frame - - -def test_run_live_activity_paints_the_empty_state_when_today_holds_no_cycle( - repo: Repository, monkeypatch: pytest.MonkeyPatch, tmp_path: Any -) -> None: - """The whole point of the empty state, through the live loop: a log with only older cycles - renders words, not a blank panel -- and those words name when keel last ran.""" - config = _config( - logging=LoggingConfig(file=_write_activity_log(tmp_path, _yesterday_activity_log_lines())) - ) - stdscr = _KeySequenceStdscr(height=24, width=200, keys=[ord("v"), -1]) - - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - def open_state() -> tuple[Repository, Any]: - return repo, config - - run_live(open_state, lambda: NOW_TS, interval=0.01) - - painted = [call[2] for call in stdscr.calls] - assert any("keel has not run yet today." in t for t in painted) - assert any("Last cycle:" in t for t in painted) - assert any("Press t to widen the scope" in t for t in painted) - - -# -- run_live: the console shell (issue #388 C2) --------------------------------------------------- - - -_MINIMAL_CONSOLE_CONFIG = ( - "allowlist: [BTC]\ncaps: {max_exposure_usd: 100, max_per_asset_pct: 0.5}\n" -) -_MINIMAL_CONSOLE_CONFIG_ALT = ( - "allowlist: [ETH]\ncaps: {max_exposure_usd: 100, max_per_asset_pct: 0.5}\n" -) - - -def _deployment_dir(tmp_path: Any) -> Any: - """A working directory holding every known deployment's config, the shape `discover_ - profiles` reads -- paper-forward is the pair the console starts bound to.""" - from keel.commands import console - - for profile in console.KNOWN_PROFILES: - (tmp_path / profile.config_path).write_text( - _MINIMAL_CONSOLE_CONFIG_ALT if profile.key == "paper-hourly" else ( - _MINIMAL_CONSOLE_CONFIG_ALT if profile.key == "live" else _MINIMAL_CONSOLE_CONFIG - ) - ) - return tmp_path - - -def _console_session( - tmp_path: Any, - monkeypatch: pytest.MonkeyPatch, - keys: list[int], - *, - height: int = 30, - start_config: str = "config.paperforward.yaml", - start_db: str = "keel.db", - build_broker: Any = None, -) -> tuple[_FakeStdscr, Any]: - """Run one scripted `run_live` session with a REAL console binding over the temp - deployment dir, and return (the stdscr with its recorded `addstr` calls, the binding). - The balance refresh's broker construction is stubbed (it fires on the first poll by - design) so no test touches the network; a test that needs to observe or count venue - calls (the Compliance menu's ARMED views) passes its own `build_broker`.""" - from keel.commands import console - - monkeypatch.chdir(tmp_path) - ctx = click.Context(click.Command("tui"), obj={}) - ctx.obj["config_path"] = start_config - ctx.obj["db_path"] = start_db - binding = console.ConsoleBinding(ctx, config_path=start_config, db_path=start_db) - - stdscr = _KeySequenceStdscr(height=height, width=120, keys=keys) - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - class _FakeBroker: - def get_accounts(self) -> list[Any]: - return [] - - monkeypatch.setattr( - "keel.commands._common._build_broker", - build_broker - if build_broker is not None - else (lambda cfg, timeout=None: _FakeBroker()), - ) - - run_live(binding.open_state, lambda: NOW_TS, interval=0.01, console_binding=binding) - return stdscr, binding - - -def _console_run( - tmp_path: Any, - monkeypatch: pytest.MonkeyPatch, - keys: list[int], - *, - start_config: str = "config.paperforward.yaml", - start_db: str = "keel.db", -) -> tuple[list[str], Any]: - """`_console_session` for every test that only needs the painted TEXTS (the common - case) -- kept so those callers read at a glance.""" - stdscr, binding = _console_session( - tmp_path, monkeypatch, keys, start_config=start_config, start_db=start_db - ) - return [call[2] for call in stdscr.calls], binding - - -def test_run_live_with_a_console_binding_paints_the_banner_on_the_dashboard( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """O9: the console header IS the banner -- the landing dashboard carries the active - profile's config+db pair and the venue session line on EVERY frame, with no key pressed.""" - painted, _binding = _console_run(_deployment_dir(tmp_path), monkeypatch, [-1]) - - assert any(t.startswith("console: paper-forward") for t in painted) - assert any("config.paperforward.yaml" in t and "keel.db" in t for t in painted) - # coinbase (the config default) is 24/7 -- the explicit always-open rendering. - assert any("24/7" in t for t in painted) - - -def test_run_live_without_a_console_binding_is_the_pre_c2_dashboard( - repo: Repository, monkeypatch: pytest.MonkeyPatch -) -> None: - """Every pre-C2 caller (and every existing test) passes no binding: the dashboard must - render byte-identically to before -- no banner, no menu key -- so the shell is an - addition, never a rewrite.""" - config = _config() - stdscr = _ScriptedStdscr(height=24, width=80, quit_after=2) - fake_curses = _fake_curses() - fake_curses.wrapper = lambda fn: fn(stdscr) - monkeypatch.setitem(sys.modules, "curses", fake_curses) - - class _FakeBroker: - def get_accounts(self) -> list[Any]: - return [] - - monkeypatch.setattr( - "keel.commands._common._build_broker", lambda cfg, timeout=None: _FakeBroker() - ) - - run_live(lambda: (repo, config), lambda: NOW_TS, interval=0.01) - - painted = [call[2] for call in stdscr.calls] - assert painted and not any(t.startswith("console:") for t in painted) - - -def test_run_live_m_opens_the_menu_and_esc_returns_to_the_dashboard( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """The shell's navigation contract: `m` opens the console menu, every PRD §3 entry is - on it, and Esc closes back to the landing dashboard (which stays the landing screen).""" - # poll1: normal -> 'm'. poll2: menu, no key. poll3: Esc closes. poll4: 'q' quits. - painted, _binding = _console_run( - _deployment_dir(tmp_path), monkeypatch, [ord("m"), -1, 27] - ) - - menu_idx = next(i for i, t in enumerate(painted) if "keel console" in t and "menu" in t) - menu_text = "\n".join(painted[menu_idx:]) - for label in ( - "Dashboard", - "Profile", - "Trading", - "Rules", - "Compliance", - "Data", - "Research", - "Account", - "Help", - ): - assert label in menu_text, label - # Esc returned to the dashboard: a LATER frame paints the dashboard's own title again. - assert any("paper mode" in t for t in painted[menu_idx:]) - - -def test_run_live_the_account_entry_opens_the_account_sub_menu( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """C6 (issue #392) landed the tree's last placeholder: 8 opens the Account sub-menu - (`keel.commands.account_console`), a read-only branch -- its two entries render with - descriptions and the console keys, and no 'lands in Cx' notice paints anywhere.""" - painted, _binding = _console_run( - _deployment_dir(tmp_path), monkeypatch, [ord("m"), ord("8"), -1, 27, 27] - ) - - assert any("keel console -- account" in t for t in painted) - assert any("pnl" in t for t in painted) - assert any("versions" in t for t in painted) - assert not any("lands in" in t for t in painted) - - -def test_run_live_a_future_placeholder_entry_lands_in_its_slice_notice( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """The placeholder MECHANISM stays for the next slice, dead today (C6 landed the - tree's last placeholder, Account): pinned over a synthetic future entry grafted onto - the tree -- selecting it renders the notice, not a dead click and not a feature; the - shell is navigation only for an entry no slice has landed.""" - from keel.commands import console as console_mod - - future = console_mod.MenuEntry( - ordinal=10, label="Future", lands_in="C8", description="what C8 will own" - ) - monkeypatch.setattr( - console_mod, "CONSOLE_MENU", (*console_mod.CONSOLE_MENU, future) - ) - # 'm' menu -> j j j j j j j j j to the grafted row (index 9 of 10) -> Enter opens the - # notice -> Esc returns to the menu -> Esc returns to the dashboard. - painted, _binding = _console_run( - _deployment_dir(tmp_path), - monkeypatch, - [ord("m"), *([ord("j")] * 9), 10, -1, 27, 27], - ) - - assert any("lands in C8" in t for t in painted) - assert any("navigation" in t.lower() for t in painted) - - -def test_run_live_profile_switch_rebinds_the_console_in_one_action( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """O4, pinned end to end: one Enter on the paper-hourly row rebinds config+db together, - and the very next dashboard frame's banner names the new pair -- the same loaders the - CLI uses, no relaunch.""" - # 'm' menu -> '2' profile -> 'j','j' to paper-hourly (row 3 of 4) -> Enter switches -> - # (mode returns to normal) 'q' quits. - keys = [ord("m"), ord("2"), ord("j"), ord("j"), 10] - painted, binding = _console_run(_deployment_dir(tmp_path), monkeypatch, keys) - - assert binding.config_path == "config.paper-hourly.yaml" - assert binding.db_path == "keel-paperhourly.db" - assert any("console: paper-hourly" in t for t in painted) - assert any("config.paper-hourly.yaml" in t and "keel-paperhourly.db" in t for t in painted) - # The switch is toasted like every other action. - assert any("profile" in t.lower() and "paper-hourly" in t for t in painted) - - -def test_run_live_live_switch_declined_keeps_the_binding( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """The live guard, through the loop: selecting LIVE asks (and here, the operator says - no) -- the binding keeps the paper pair, the banner still says paper-forward, and the - decline is toasted rather than silent.""" - from keel.commands import console as console_mod - - confirmations: list[str] = [] - - def _decline(stdscr: Any, profile: Any) -> bool: - confirmations.append(profile.key) - return False - - monkeypatch.setattr(tui_mod, "_confirm_live_profile", _decline) - # 'm' menu -> '2' profile -> 'j' to live (row 2 of 4) -> Enter asks, declined -> Esc - # (to menu) -> Esc (to normal) -> 'q'. - keys = [ord("m"), ord("2"), ord("j"), 10, 27, 27] - painted, binding = _console_run(_deployment_dir(tmp_path), monkeypatch, keys) - - assert confirmations == ["live"] - assert binding.config_path == "config.paperforward.yaml" - assert binding.db_path == "keel.db" - assert any("unchanged" in t.lower() for t in painted) - assert any("console: paper-forward" in t for t in painted) - # The live row's guard is stated where the operator selects it. - assert any("LIVE" in t for t in painted) - assert console_mod.KNOWN_PROFILES[1].requires_confirmation is True - - -def test_run_live_dashboard_entry_returns_to_the_landing_screen( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """Menu entry 1 is the dashboard itself: selecting it closes the menu, the same as Esc - -- the dashboard remains the console's landing screen.""" - painted, _binding = _console_run(_deployment_dir(tmp_path), monkeypatch, [ord("m"), ord("1")]) - - menu_idx = next(i for i, t in enumerate(painted) if "keel console" in t and "menu" in t) - assert any("paper mode" in t for t in painted[menu_idx:]) - - -@pytest.mark.parametrize( - ("open_key", "closing_line"), - [ - # `h` opens the Help MENU since C7 (the keys/safety screen is its fourth - # entry); the help-menu scrolls banner-aware like every other overlay, so End - # must land its own closing line on the bottom row. - (ord("h"), "up/k down/j move · Enter/Space select · 1-4 jump · q/Esc/m to the menu"), - (ord("i"), "Press i or Esc to return to the dashboard."), - (ord("s"), "Press s or Esc to return to the dashboard."), - (ord("p"), "Press p or Esc to return to the dashboard."), - (ord("d"), "Press d or Esc to return to the dashboard."), - ], -) -def test_run_live_end_scrolls_to_the_overlays_true_last_line_with_a_banner( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch, open_key: int, closing_line: str -) -> None: - """The banner is part of every scrolled list, so the scroll math must count it: with a - 2-line banner prepended, `End` on any of the five scrollable overlays must reach the - overlay's TRUE last line -- painted on the window's bottom row, not two rows short of - a help tail the clamp was hiding forever (`_scroll_offset` was clamped against the - banner-EXCLUDED length while `_visible_slice` sliced the combined list).""" - fake_curses = _fake_curses() - height = 6 - # poll1: normal -> the open key. poll2: overlay at offset 0, End pressed. poll3: the - # End-scrolled frame. Esc closes; the post-exhaustion 'q' quits. - stdscr, _binding = _console_session( - _deployment_dir(tmp_path), - monkeypatch, - [open_key, fake_curses.KEY_END, 27], - height=height, - ) - - bottom_row = [call for call in stdscr.calls if call[0] == height - 1] - assert any(call[2] == closing_line for call in bottom_row), ( - "End must land the overlay's own closing line on the bottom row -- with the banner " - "counted, not treated as free rows the clamp can spend" - ) - - -def test_run_live_m_in_the_profile_menu_returns_to_the_menu( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """`m` closes profile mode too, the same close-key consistency menu and placeholder - modes keep (q/Esc/m): profile mode used to close on q/Esc/p only, so the key that - OPENED the shell could not step back one level out of it. - - Asserted on the frame IMMEDIATELY after the `m` keypress (frames delimited by their - y=0 first row), not on "a menu frame appears later" -- the quit path also passes - through the menu, which would make a dead `m` look bound.""" - stdscr, _binding = _console_session( - _deployment_dir(tmp_path), monkeypatch, [ord("m"), ord("2"), -1, ord("m"), ord("q")] - ) - texts = [call[2] for call in stdscr.calls] - starts = [i for i, call in enumerate(stdscr.calls) if call[0] == 0] - frames = [ - texts[start : starts[j + 1] if j + 1 < len(starts) else len(texts)] - for j, start in enumerate(starts) - ] - - # poll4 (frame index 3) is the profile frame whose keypress is `m`; frame 4 is what - # that keypress did. - assert any("keel console -- profile" in t for t in frames[3]) - assert any("keel console -- menu" in t for t in frames[4]) - - -# -- run_live: the Compliance menu (issue #389 C3) ------------------------------------------------- - - -def test_run_live_compliance_entry_opens_the_sub_menu_and_esc_steps_back( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """The Compliance entry is C3's landing: selecting it opens the Compliance sub-menu - (every PRD §3 entry under Compliance visible), and Esc steps back to the console menu - -- the shell is a hierarchy, never a jump.""" - painted, _binding = _console_run( - _deployment_dir(tmp_path), monkeypatch, [ord("m"), ord("5"), -1, 27, -1, 27] - ) - - compliance_idx = next( - i for i, t in enumerate(painted) if "compliance" in t.lower() and "menu" in t.lower() - ) - compliance_text = "\n".join(painted[compliance_idx:]) - for label in ( - "screen", "propose", "attest", "attest-instrument", "exempt", "unexempt", - "holdings", "discover", "Scout results", "Shariah in force", "subscription show", - "subscription attest", "subscription set", "withdrawals attest", "purification", - ): - assert label in compliance_text, label - # Esc stepped back to the console menu, not the dashboard. - after = painted[compliance_idx:] - assert any("keel console -- menu" in t for t in after) - - -def test_run_live_compliance_screen_view_is_the_services_own_report( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """The screen view renders the SAME report `keel assets screen` computes - (`build_screen_report` over `screen_product`), offline: the verdict summary line is - painted, and no broker is ever constructed.""" - - def _no_broker(cfg: Any, timeout: Any = None) -> Any: - raise AssertionError("the screen view must never construct a broker") - - monkeypatch.setattr("keel.commands._common._build_broker", _no_broker) - painted, _binding = _console_run( - _deployment_dir(tmp_path), - monkeypatch, - [ord("m"), ord("5"), 10, -1, 27, -1, 27, ord("q")], - ) - - assert any("0/1 admitted" in t for t in painted) - view_idx = next(i for i, t in enumerate(painted) if "compliance" in t and "screen" in t) - assert any("REJECT" in t for t in painted[view_idx:]) - - -def test_run_live_compliance_attest_form_dispatches_and_suspends_curses( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """Selecting a form entry runs the FORM at the terminal (curses suspended for the - prompts, restored after -- the same dance `_confirm_arm_autonomy` keeps) and shows - the write's confirmation line on the Compliance menu. The form itself is - `compliance_console.run_form` -- the same seam the unit tests drive -- spied here to - prove the dispatch carries the loop's own repo/config/now.""" - from keel.commands import compliance_console as cc - - calls: list[dict[str, Any]] = [] - - def _spy_form(name: str, repo: Any, config: Any, prompt_fn: Any, now_ts: int) -> str: - calls.append({"name": name, "now_ts": now_ts}) - return "attested BTC: sector=payments backing=ayn pays_yield=False" - - monkeypatch.setattr(cc, "run_form", _spy_form) - - # m -> menu; 5 -> Compliance; j j -> cursor on 'attest' (index 2); Enter runs the - # form; then step back out and quit. - painted, _binding = _console_run( - _deployment_dir(tmp_path), - monkeypatch, - [ord("m"), ord("5"), ord("j"), ord("j"), 10, -1, 27, -1, 27, ord("q")], - ) - - assert calls == [{"name": "attest", "now_ts": NOW_TS}] - assert any("attested BTC: sector=payments" in t for t in painted) - # the suspend/restore dance ran around the form (the fake curses records it) - import sys as _sys - - fake_curses = _sys.modules["curses"] - assert "def_prog_mode" in fake_curses.calls - assert "reset_prog_mode" in fake_curses.calls - - -def test_run_live_scout_browser_lists_selects_and_screens_a_shortlist( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """O6 end to end through the live loop: the Scout results browser lists the - operator-local shortlists (path from config), Enter opens one, and the view renders - the admission services' own verdicts for THAT file -- propose → screen through - `build_propose_view`, no auto-attest anywhere.""" - deployment = _deployment_dir(tmp_path) - proposals = tmp_path / "proposals" - proposals.mkdir() - (proposals / "2026-08-15-shortlist.json").write_text( - '{"candidates": [{"asset": "FET", "rationale": "ai compute", ' - '"sources": ["https://example.com/fet"]}]}' - ) - # the config the console loads names the proposals dir (config.proposals_dir) - (deployment / "config.paperforward.yaml").write_text( - "allowlist: [BTC]\ncaps: {max_exposure_usd: 100, max_per_asset_pct: 0.5}\n" - f"proposals_dir: {proposals}\n" - ) - - keys = [ord("m"), ord("5")] - keys += [ord("j")] * 8 # cursor to 'Scout results' (index 8) - keys += [10] # Enter -> the scout list - keys += [-1] - keys += [10] # Enter -> the selected shortlist, screened - keys += [-1, 27] # Esc -> back to the list - keys += [-1, 27] # Esc -> back to Compliance - keys += [-1, 27, ord("q")] - painted, _binding = _console_run(deployment, monkeypatch, keys) - - list_idx = next( - i for i, t in enumerate(painted) if t.startswith("keel console -- compliance / Scout") - ) - assert "2026-08-15-shortlist.json" in "\n".join(painted[list_idx : list_idx + 8]) - view = "\n".join(painted[list_idx:]) - assert "REJECT" in view # the gate ran on the unattested candidate - assert "FET" in view - assert any("never" in t.lower() and "attest" in t.lower() for t in painted[list_idx:]) - - -def test_run_live_scout_attest_key_drives_the_typed_form_end_to_end( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """O6's acceptance, through the LIVE loop: a real proposal file, screened by the real - services, attested by the `a` key running the REAL typed form -- the prompt answered - by a scripted `click.prompt` stand-in, the write landing in the deployment's own - database, and a WRONG typed phrase on a second candidate writing nothing.""" - from keel.data.db import connect, migrate - from keel.data.repository import Repository as Repo - - deployment = _deployment_dir(tmp_path) - proposals = tmp_path / "proposals" - proposals.mkdir() - (proposals / "2026-08-15-shortlist.json").write_text( - '{"candidates": [' - '{"asset": "FET", "rationale": "ai compute", "sources": ["https://x.example/fet"],' - ' "shariah_hypothesis": "compute, not lending"},' - '{"asset": "ATOM", "rationale": "staking chain", "sources": ["https://x.example/atom"]}' - "]}" - ) - (deployment / "config.paperforward.yaml").write_text( - "allowlist: [BTC]\ncaps: {max_exposure_usd: 100, max_per_asset_pct: 0.5}\n" - f"proposals_dir: {proposals}\n" - ) - - # The form's terminal prompts, scripted: FET answered fully (typed phrase = the asset - # code); ATOM refused at the typed gate. - answers = iter( - [ - "payments", "ayn", "n", "https://x.example/fet-attest", "operator", "FET", - "payments", "native", "n", "https://x.example/atom-attest", "operator", "nope", - ] - ) - monkeypatch.setattr( - click, "prompt", lambda text, **kwargs: next(answers), raising=True - ) - - keys = [ord("m"), ord("5")] - keys += [ord("j")] * 8 # Scout results - keys += [10] # the list - keys += [10] # open the shortlist (screened by the services) - keys += [ord("a")] # attest candidate 0 (FET) -- the typed form runs - keys += [-1] - keys += [ord("j")] # candidate 1 (ATOM) - keys += [ord("a")] # the typed form runs, and the phrase is wrong - keys += [-1, 27, -1, 27, -1, 27, ord("q")] - painted, _binding = _console_run(deployment, monkeypatch, keys) - - conn = connect(str(deployment / "keel.db")) - migrate(conn) - rows = Repo(conn).get_asset_attestations() - assert [row["asset"] for row in rows] == ["FET"] # ATOM was refused at the gate - assert rows[0]["sector"] == "payments" and rows[0]["backing"] == "ayn" - assert any("attested FET" in t for t in painted) - assert any("cancelled" in t.lower() and "ATOM" in t for t in painted) - - -def _painted_frames(stdscr: _FakeStdscr) -> list[list[str]]: - """The painted texts split into FRAMES (one per paint -- delimited by each frame's - y=0 row), so a test can assert on what one keypress actually made the loop paint.""" - texts = [call[2] for call in stdscr.calls] - starts = [i for i, call in enumerate(stdscr.calls) if call[0] == 0] - return [ - texts[start : starts[j + 1] if j + 1 < len(starts) else len(texts)] - for j, start in enumerate(starts) - ] - - -def test_run_live_compliance_holdings_view_is_armed_until_enter( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """The Compliance menu's holdings view keeps the discover overlay's own gating story, - pinned through the LIVE loop: navigating to it, opening it, and polling it ARMED make - ZERO venue calls of its own -- the one `get_accounts` happens only on the explicit - Enter. `get_accounts` IS called once more during this run -- by the pre-existing - automatic first-poll balance refresh (see `test_run_live_screen_and_propose_never_ - construct_a_broker`'s docstring); with a constant `now_fn` it fires exactly once, - before the menu is even open, so a total of 2 is exactly one refresh plus one - holdings read -- any call from opening or polling the ARMED view would make it 3.""" - get_accounts_calls: list[int] = [] - - class _CountingBroker: - def get_accounts(self) -> list[Any]: - get_accounts_calls.append(1) - return [ - {"currency": "SOL", "available_balance": Decimal("3.5")}, - ] - - def list_products(self) -> list[dict]: - return [] - - # m -> menu; 5 -> Compliance; 7 -> holdings (ARMED); -1, -1 -> repaints, no call; - # 10 -> Enter runs the ONE read; -1 -> repaint the held result, no further call; - # 27 -> close to the menu; 27 -> back to the console menu (then the default q quits). - stdscr, _binding = _console_session( - _deployment_dir(tmp_path), - monkeypatch, - [ord("m"), ord("5"), ord("7"), -1, -1, 10, -1, 27, 27], - build_broker=lambda cfg, timeout=None: _CountingBroker(), - ) - frames = _painted_frames(stdscr) - - assert len(get_accounts_calls) == 2 # one balance refresh + exactly one holdings read - armed_frames = [f for f in frames if any("ARMED" in t for t in f)] - assert armed_frames, "the holdings view must open ARMED" - holdings_frames = [f for f in frames if any("SOL" in t for t in f)] - assert holdings_frames, "Enter's held result must paint" - - -def test_run_live_compliance_discover_view_armed_until_enter_and_enter_retries_after_a_failure( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """The discover console view, through the loop: ZERO `list_products` calls while - navigating/arming, and after a FAILED Enter the held error names the retry key (never - a "retrying..." nothing retries on its own) -- and that key ACTUALLY retries: a second - Enter runs the read again, so a transient venue failure costs one keypress, not a - closed screen. The successful run renders the CLI's own discover sweep (`run_discovery` - -> `render_discover`), so the held result paints the candidate the venue returned.""" - list_products_calls: list[int] = [] - - class _FlakyBroker: - def get_accounts(self) -> list[Any]: - return [] - - def list_products(self) -> list[dict]: - list_products_calls.append(1) - if len(list_products_calls) == 1: - raise RuntimeError("venue unreachable") - return [ - { - "product_id": "SOL-USD", - "quote_currency_id": "USD", - "status": "online", - "trading_disabled": False, - "is_disabled": False, - "view_only": False, - "quote_24h_volume": "9000000", - "base_name": "Solana", - } - ] - - # m -> menu; 5 -> Compliance; 8 -> discover (ARMED); -1 -> repaint, no call; 10 -> - # Enter FAILS (error held); -1 -> repaint the error, no call; 10 -> Enter RETRIES and - # succeeds; -1 -> repaint the held result, no further call; 27, 27 -> step back out. - stdscr, _binding = _console_session( - _deployment_dir(tmp_path), - monkeypatch, - [ord("m"), ord("5"), ord("8"), -1, 10, -1, 10, -1, 27, 27], - build_broker=lambda cfg, timeout=None: _FlakyBroker(), - ) - frames = _painted_frames(stdscr) - - assert len(list_products_calls) == 2 # armed: zero; each Enter: exactly one - error_frames = [f for f in frames if any("read failed" in t for t in f)] - assert error_frames, "the failed Enter must paint the failure" - error_text = "\n".join("\n".join(f) for f in error_frames) - assert "venue unreachable" in error_text - assert "retrying" not in error_text - assert "press Enter to retry" in error_text - assert any( - any("SOL-USD" in t for t in f) for f in frames - ), "the retried run's held result must paint" - - -def test_run_live_the_compliance_menu_scrolls_to_keep_the_cursor_visible( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """The Compliance tree is 15 entries (more rows than a small terminal once every - description fits the 80-column budget): the menu must SCROLL -- painted through - `_visible_slice`, the cursor row kept on screen by the `_follow_cursor` rule, and - End/PgDn reaching the true tail (banner-aware math), exactly like the other overlays. - Before this, the tail entries were painted nowhere -- below the fold with no way up.""" - fake_curses = _fake_curses() - # m -> menu; 5 -> Compliance (offset 0 -- purification is below the fold); j x14 -> - # walk the cursor down to 'purification'; -1 -> paint with the cursor followed; - # End -> the true last page; 27, 27 -> step back out (then the default q quits). - keys = [ord("m"), ord("5"), *([ord("j")] * 14), -1, fake_curses.KEY_END, -1, 27, 27] - stdscr, _binding = _console_session( - _deployment_dir(tmp_path), monkeypatch, keys, height=10 - ) - frames = _painted_frames(stdscr) - - first = next(f for f in frames if any("keel console -- compliance" in t for t in f)) - assert not any("purification" in t for t in first) # below the fold at offset 0 - followed = next( - f - for f in frames - if any(t.lstrip().startswith(">") and "purification" in t for t in f) - ) - # End reached the menu's true tail: the closing hint is on screen after it. - assert any( - any("to the Compliance menu" in t for t in f) - for f in frames[frames.index(followed) :] - ) - - -def test_run_live_the_scout_list_scrolls_to_keep_older_shortlists_reachable( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """O6's own promise, made scrollable: the Scout results browser lists EVERY shortlist - so an OLDER run stays reachable -- on a small window that means the cursor's row must - follow the cursor (`_follow_cursor`), not fall off the fold where the older files sit.""" - deployment = _deployment_dir(tmp_path) - proposals = tmp_path / "proposals" - proposals.mkdir() - names = [f"2026-08-{day:02d}-shortlist.json" for day in range(1, 13)] - for index, name in enumerate(names): - path = proposals / name - path.write_text("{}") - stamp = 1_800_000_000 + index * 3600 - os.utime(path, (stamp, stamp)) - (deployment / "config.paperforward.yaml").write_text( - "allowlist: [BTC]\ncaps: {max_exposure_usd: 100, max_per_asset_pct: 0.5}\n" - f"proposals_dir: {proposals}\n" - ) - - # m -> menu; 5 -> Compliance; 9 -> Scout results (newest first, oldest below the - # fold on a 10-line window); j x11 -> the cursor walks to the OLDEST shortlist; - # -1 -> paint with the cursor followed; 27, 27 -> back out (then q quits). - keys = [ord("m"), ord("5"), ord("9"), *([ord("j")] * 11), -1, 27, 27] - stdscr, _binding = _console_session(deployment, monkeypatch, keys, height=10) - frames = _painted_frames(stdscr) - - oldest = names[0] - first_list = next( - f for f in frames if any("compliance / Scout results" in t for t in f) - ) - assert not any(oldest in t for t in first_list) # below the fold on entry - # ...and by the time the cursor has walked to it, its row is ON screen, marked. - assert any( - any(t.lstrip().startswith(">") and oldest in t for t in f) for f in frames - ) - - -def test_run_live_the_shariah_view_pins_the_honesty_lines_at_every_scroll_offset( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """O10 through the live loop: the two standing honesty lines are a FIXED footer of - the shariah view -- painted on the frame at offset 0 AND after End scrolls the body - to its tail. Before this they rode the body's END, one viewport below the fold on - any real allowlist, and an operator who never scrolled never saw them.""" - fake_curses = _fake_curses() - # m -> menu; 5 -> Compliance; j x9 -> 'Shariah in force'; 10 -> open (offset 0); - # -1 -> repaint; End -> the body's true last page; -1 -> repaint; 27, 27 -> back out. - keys = [ - ord("m"), ord("5"), *([ord("j")] * 9), 10, -1, fake_curses.KEY_END, -1, 27, 27, - ] - stdscr, _binding = _console_session( - _deployment_dir(tmp_path), monkeypatch, keys, height=10 - ) - frames = _painted_frames(stdscr) - - # The view stays open for exactly three paints after it opens: offset 0, the offset-0 - # repaint during which End is pressed, and the End-scrolled repaint. Every one of - # them -- including the scrolled frame, whose title line has scrolled OFF -- must - # carry the pinned honesty lines. - first_idx = next( - i for i, f in enumerate(frames) if any("compliance / Shariah in force" in t for t in f) - ) - view_frames = frames[first_idx : first_idx + 3] - assert len(view_frames) == 3 - for frame in view_frames: - joined = "\n".join(frame) - assert "not a fatwa engine" in joined - assert "No scholarly review" in joined - # and the last frame really IS the scrolled one: the title is gone, the footer - # stayed -- that is the below-the-fold bug, inverted. - assert not any("compliance / Shariah in force" in t for t in view_frames[2]) - - -def test_run_live_m_closes_the_scout_list_and_the_scout_view( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """The q/Esc/m close-key consistency every other console mode keeps: `m` -- the key - that opened the shell -- steps back one level out of BOTH scout modes (view -> list, - list -> the Compliance menu), so an operator is never trapped two levels deep.""" - deployment = _deployment_dir(tmp_path) - proposals = tmp_path / "proposals" - proposals.mkdir() - (proposals / "2026-08-15-shortlist.json").write_text( - '{"candidates": [{"asset": "FET", "rationale": "ai compute", ' - '"sources": ["https://example.com/fet"]}]}' - ) - (deployment / "config.paperforward.yaml").write_text( - "allowlist: [BTC]\ncaps: {max_exposure_usd: 100, max_per_asset_pct: 0.5}\n" - f"proposals_dir: {proposals}\n" - ) - - # m -> menu; 5 -> Compliance; 9 -> the scout list; 10 -> the shortlist view; m -> - # back to the LIST; m -> back to the COMPLIANCE MENU; 27, 27 -> back out; q quits. - keys = [ord("m"), ord("5"), ord("9"), 10, ord("m"), ord("m"), 27, 27] - stdscr, _binding = _console_session(deployment, monkeypatch, keys) - frames = _painted_frames(stdscr) - - view_idx = next( - i for i, f in enumerate(frames) if any("shortlist:" in t for t in f) - ) - # `m` from the VIEW landed on the list, not a jump past it... - assert any( - "2026-08-15-shortlist.json" in t for t in frames[view_idx + 1] - ) - # ...and `m` from the LIST landed on the Compliance menu. - assert any( - any("keel console -- compliance" in t and "Scout results" not in t for t in f) - for f in frames[view_idx + 2 :] - ) - - -def test_cached_scout_view_rescreens_only_when_the_file_changes( - repo: Repository, tmp_path: Any -) -> None: - """The scout view repaints every poll, but the shortlist FILE does not change under a - held screen: the parsed-and-screened view is cached per (path, mtime), so a repaint - re-screens nothing, while a changed file (new mtime) refreshes. Without the cache - every poll re-read, re-parsed and re-SCREENED the same bytes through the admission - gate -- a DB read per candidate per poll, for a file that had not changed.""" - from keel.commands.tui import cached_scout_view - from keel.compliance.screen import MarketFacts, ScreenResult - - shortlist = tmp_path / "2026-08-15-shortlist.json" - shortlist.write_text( - '{"candidates": [{"asset": "FET", "rationale": "ai compute", ' - '"sources": ["https://example.com/fet"]}]}' - ) - os.utime(shortlist, (1_800_000_000, 1_800_000_000)) - config = _config(proposals_dir=str(tmp_path)) - screened: list[str] = [] - - def counting_screen_fn(r: Any, product: str, quote: str) -> Any: - screened.append(product) - facts = MarketFacts( - asset="FET", daily_bars=2000, median_daily_volume=Decimal("1000"), - quotable_in_settlement_currency=True, product_id="FET-USD", venue="coinbase", - ) - return facts, ScreenResult(asset="FET", admitted=True, failures=[], warnings=[]) - - cache: dict[tuple[str, int], Any] = {} - first = cached_scout_view(repo, config, counting_screen_fn, shortlist, cache) - assert first.status == "ok" - assert screened == ["FET-USD"] - - # unchanged mtime -> the cache answers; the gate does not run again - second = cached_scout_view(repo, config, counting_screen_fn, shortlist, cache) - assert second is first - assert screened == ["FET-USD"] - - # a changed file (new mtime) refreshes: the gate runs again on the new bytes - shortlist.write_text( - '{"candidates": [{"asset": "FET", "rationale": "compute, revised", ' - '"sources": ["https://example.com/fet-v2"]}]}' - ) - os.utime(shortlist, (1_800_000_100, 1_800_000_100)) - third = cached_scout_view(repo, config, counting_screen_fn, shortlist, cache) - assert third.status == "ok" and third is not first - assert screened == ["FET-USD", "FET-USD"] - - -# -- run_live: the Venues browser and the Help menu (issue #394 C7) -------------------------------- - - -def test_run_live_the_profile_menus_venues_row_opens_the_browser( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """O7's TUI half, behaviorally: the Profile menu's Venues row opens the venues - browser -- the SAME service payload `keel brokers list` prints, with the bound - deployment's venue marked [selected] and its config+db pair named.""" - from keel.commands import brokers - - stdscr, _binding = _console_session( - _deployment_dir(tmp_path), - monkeypatch, - # m -> menu; 2 -> Profile; j x4 -> the Venues row (below every deployment); - # Enter -> the browser; q -> back to the Profile menu; then quit out. - [ord("m"), ord("2"), ord("j"), ord("j"), ord("j"), ord("j"), 10, ord("q"), -1, ord("q")], - ) - texts = [call[2] for call in stdscr.calls] - venues_at = next(i for i, t in enumerate(texts) if "profile / venues" in t) - browser = texts[venues_at:] - # every installed adapter renders, from the one service payload - for info in brokers.list_installed_brokers(): - assert any(info.name in t for t in browser), info.name - # the bound deployment's venue is the marked one, and the pair is named - selected = [t for t in browser if "[selected]" in t] - assert len(selected) == 1 and "coinbase" in selected[0] - assert any("config.paperforward.yaml + keel.db" in t for t in browser) - - -def test_run_live_question_opens_the_screens_own_help_and_closes_back( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """O8's contextual overlay, behaviorally: `?` in the console MENU opens that mode's - own help (its subject rows, the registry's contribution), and q returns to the menu - -- the screen it was opened from, not the dashboard.""" - stdscr, _binding = _console_session( - _deployment_dir(tmp_path), - monkeypatch, - # m -> menu; ? -> contextual help; q -> back to the menu; then quit out. - [ord("m"), ord("?"), ord("q"), -1, ord("q")], - ) - texts = [call[2] for call in stdscr.calls] - overlay_at = next(i for i, t in enumerate(texts) if "help: menu" in t) - overlay = texts[overlay_at:] - assert any("the console menu" in t for t in overlay) - # closing returns to the MENU (the mode ? was pressed in), not the dashboard - after = texts[texts.index(overlay[-1]) + 1 :] - assert any("keel console -- menu" in t for t in after) - - -def test_run_live_the_help_menu_opens_the_glossary( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - """The Help entry is a real sub-menu since C7: it lists the glossary, the screens - catalog, the parameter help and the keys/safety notes; the glossary entry renders - docs/glossary.md's own terms.""" - # The glossary is read from the WORKING DIRECTORY (docs/glossary.md, the CLI's own - # relative-path convention); the temp deployment dir has none, so the real file is - # copied in to exercise the render (an absent one is the empty-state test's case). - import shutil - from pathlib import Path as _Path - - (tmp_path / "docs").mkdir(exist_ok=True) - shutil.copyfile( - _Path(__file__).resolve().parents[2] / "docs" / "glossary.md", - tmp_path / "docs" / "glossary.md", - ) - stdscr, _binding = _console_session( - _deployment_dir(tmp_path), - monkeypatch, - # m -> menu; 9 -> Help; Enter -> glossary (cursor 0); q -> back; then quit out. - [ord("m"), ord("9"), 10, ord("q"), -1, ord("q")], - height=120, # the glossary is longer than a viewport; assert on the WHOLE render - ) - texts = [call[2] for call in stdscr.calls] - menu_at = next(i for i, t in enumerate(texts) if "keel console -- help" in t) - assert any("glossary" in t for t in texts[menu_at:]) - glossary_at = next(i for i, t in enumerate(texts) if "help / glossary" in t) - glossary = texts[glossary_at:] - for term in ("rail", "qabd", "kill switch", "promotion gate"): - assert any(term in t for t in glossary), term diff --git a/tests/commands/test_update.py b/tests/commands/test_update.py index e3f36333..bbe886d2 100644 --- a/tests/commands/test_update.py +++ b/tests/commands/test_update.py @@ -15,8 +15,9 @@ superseded wheels are deleted ONLY after a verified success, backups are NEVER deleted, a failed verify is LOUD with the real state + the manual recovery, and the best-effort reinstall of the previous wheels when they are still on disk. -* **The relaunch closure** -- argv reconstruction (`build_relaunch_argv`) and the - execv closure (`relaunch_tui`, execv itself faked). +* **The relaunch closure** -- deleted at #541 with its only caller, the console's update + view. `keel/commands/update.py` records why in place, including that its fallback built + `[keel, "tui"]`, a command that no longer exists. * **The packaged story (D6, #439)** -- a packaged install NEVER self-updates: the check names the installer download, the plan's refusal stands alone in desktop vocabulary, and an unreachable check is calm, never an error. The uv-venv path above is untouched. @@ -946,93 +947,6 @@ def _partial(argv: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[str # -- the relaunch: argv reconstruction and the execv closure --------------------------------------- -def test_build_relaunch_argv_replaces_the_entry_and_keeps_the_tui_args() -> None: - venv = Path("/deployment/.venv/bin/python") - argv = up.build_relaunch_argv( - venv, ["/deployment/.venv/bin/keel", "tui", "--config", "config.live-sandbox.yaml"] - ) - assert argv == [ - "/deployment/.venv/bin/keel", - "tui", - "--config", - "config.live-sandbox.yaml", - ] - - -def test_build_relaunch_argv_preserves_flags_before_the_subcommand_verbatim() -> None: - """A deployment wrapper execs `keel --config X --db Y tui`: argv[0] is the WRAPPER, - not the subcommand -- the arguments after argv[0] must be carried VERBATIM, byte - for byte, no reordering and no prepending. (The old fallback prepended `tui` here, - producing `keel tui --config X --db Y tui` and a click usage error on every - relaunch of a wrapped deployment.)""" - venv = Path("/deployment/.venv/bin/python") - original = [ - "/deployment/keel-live", - "--config", - "config.live-sandbox.yaml", - "--db", - "keel-live.db", - "tui", - ] - assert up.build_relaunch_argv(venv, original) == [ - "/deployment/.venv/bin/keel", - "--config", - "config.live-sandbox.yaml", - "--db", - "keel-live.db", - "tui", - ] - - -def test_build_relaunch_argv_preserves_a_leading_subcommand_verbatim() -> None: - venv = Path("/deployment/.venv/bin/python") - original = ["/deployment/.venv/bin/keel", "tui", "--interval", "5"] - assert up.build_relaunch_argv(venv, original) == [ - "/deployment/.venv/bin/keel", - "tui", - "--interval", - "5", - ] - - -def test_build_relaunch_argv_falls_back_to_the_tui_command_when_the_argv_does_not_name_it() -> None: - venv = Path("/deployment/.venv/bin/python") - argv = up.build_relaunch_argv(venv, ["/deployment/keel-live"]) - assert argv[0] == "/deployment/.venv/bin/keel" - assert argv[1] == "tui" - - -def test_relaunch_tui_execvs_the_new_console_entry(tmp_path: Path) -> None: - venv = tmp_path / ".venv/bin/python" - recorded: list[tuple[str, list[str]]] = [] - - def _fake_execv(path: str, argv: list[str]) -> None: - recorded.append((path, argv)) - - relaunch = up.relaunch_tui(venv, [str(venv.parent / "keel"), "tui"], execv=_fake_execv) - with pytest.raises(up.UpdateError, match="relaunch"): - relaunch() # a real execv never returns; the fake does, and the closure says so - assert recorded == [(str(venv.parent / "keel"), [str(venv.parent / "keel"), "tui"])] - - -def test_relaunch_tui_maps_an_execv_oserror_to_the_manual_instruction(tmp_path: Path) -> None: - """An execv that RAISES (permissions, a missing interpreter) surfaces as the - service's honest UpdateError naming the manual `keel tui` -- never a bare OSError - for a front-end to crash on.""" - - def _raising_execv(path: str, argv: list[str]) -> None: - raise OSError(13, "permission denied") - - relaunch = up.relaunch_tui( - tmp_path / ".venv/bin/python", - ["/old/keel", "tui"], - execv=_raising_execv, - ) - with pytest.raises(up.UpdateError, match="keel tui") as excinfo: - relaunch() - assert "permission denied" in str(excinfo.value) - - # -- the gate: ONE wording, both front-ends -------------------------------------------------------- @@ -1187,7 +1101,7 @@ def _no_network(url: str, dest: Path) -> None: assert not list(tmp_path.glob("*.bak-before-*")) -def test_cli_full_update_runs_the_service_and_prints_the_relaunch_instruction( +def test_cli_full_update_runs_the_service_and_prints_the_restart_instruction( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: from keel.cli import cli @@ -1209,8 +1123,11 @@ def test_cli_full_update_runs_the_service_and_prints_the_relaunch_instruction( assert result.exit_code == 0, result.output # the whole procedure ran against the fake seams assert ops.events - # the CLI does NOT auto-relaunch -- it prints the instruction - assert "keel tui" in result.output + # The CLI does NOT auto-relaunch -- it prints the instruction. It named `keel tui` until + # #541 deleted that command; the long-running process an operator now has to restart is + # `keel serve`. + assert "keel serve" in result.output + assert "tui" not in result.output # -- the packaged story (D6, issue #439): the installer IS the update path ------------------------ diff --git a/tests/test_runbook_console.py b/tests/test_runbook_console.py index 76273154..c6aa1ebe 100644 --- a/tests/test_runbook_console.py +++ b/tests/test_runbook_console.py @@ -1,11 +1,16 @@ -"""Content pins for the operator runbook's TUI-console section (issue #392 C6; PRD §5 -C6 -- "docs (runbook TUI section)"): the section must exist where an operator looks for -it, and must carry the console's actual contracts -- the menu tree over the C1 -services, the profile switching + LIVE guard, the session banner, the typed-confirmation -contract, the ARMED/blocking surfaces, the Venues browser, the help system, and the -safety design notes. The same style as the other runbook pins -(`tests/test_paper_equities_profile.py` et al.): the runbook is procedure, and a -procedure that drifts from the code is worse than none.""" +"""Content pins for the operator runbook's console section (originally issue #392 C6). + +**This file pinned the TUI console until #541, and the subject changed rather than the job.** +The old pins required the runbook to carry the curses dashboard's actual contracts -- the menu +tree, profile switching and the LIVE guard, the session banner, the typed-confirmation contract, +the ARMED surfaces, the Venues browser, the help system. That console is deleted, so pinning its +documentation would pin a description of nothing. + +What has not changed is why the file exists: **the runbook is procedure, and a procedure that +drifts from the code is worse than none.** The console an operator opens today is a browser, so +these pins are about that section -- including the two facts most expensive to get wrong, which +are what the browser CANNOT do and where a headless operator goes instead. +""" from __future__ import annotations @@ -15,223 +20,81 @@ REPO_ROOT = Path(__file__).resolve().parent.parent RUNBOOK = REPO_ROOT / "docs" / "operator-runbook.md" +SECTION_HEADING = "## The operator console, in a browser" + def _section() -> str: text = RUNBOOK.read_text(encoding="utf-8") - match = re.search(r"^## The TUI console\n(.*)", text, flags=re.S | re.M) - assert match, "the runbook has no '## The TUI console' section" - # up to the next top-level (##) heading: the section's own text + match = re.search(rf"^{re.escape(SECTION_HEADING)}\n(.*)", text, flags=re.S | re.M) + assert match, f"the runbook has no '{SECTION_HEADING}' section" rest = match.group(1) next_section = re.search(r"^## ", rest, flags=re.M) body = rest[: next_section.start()] if next_section else rest - # The runbook wraps at ~95 columns: squash whitespace so a phrase pinned across a - # line break still reads as the phrase. + # The runbook wraps at ~95 columns: squash whitespace so a phrase pinned across a line break + # still reads as the phrase. return " ".join(body.split()) -def test_the_runbook_has_a_tui_console_section() -> None: - """The section exists, by its name, in Part 2 (after the deployment profiles, - before the money settings).""" - assert "## The TUI console" in RUNBOOK.read_text(encoding="utf-8") +def test_the_runbook_has_a_console_section() -> None: + """The section exists, by its name, where the TUI section used to be.""" + assert SECTION_HEADING in RUNBOOK.read_text(encoding="utf-8") def test_the_section_says_what_the_console_is_and_that_it_is_thin() -> None: - section = _section() - lowered = section.lower() - assert "keel tui" in lowered - assert "menu" in lowered - # thin by construction: the same services the CLI calls, pinned by a test - assert "same services" in lowered - assert "thin" in lowered - assert "no business logic" in lowered or "renders and dispatches" in lowered + """Thinness is the property that lets one set of services answer both front-ends, and it is + pinned by a test -- so the runbook says so rather than implying it.""" + section = _section().lower() + assert "keel serve" in section + assert "service layer" in section + assert "test_console_thinness" in section -def test_the_section_documents_profile_switching_and_the_live_guard() -> None: +def test_the_section_names_every_view() -> None: + """Seven views, named. An operator looking for one should find it here rather than by + clicking around.""" section = _section() - lowered = section.lower() - assert "profile" in lowered - assert "config+db" in section or "config + db" in lowered or "pair" in lowered - assert "live" in lowered - assert "confirm" in lowered - # the running agent keeps its own pair: the switch rebinds the CONSOLE only - assert "running agent" in lowered or "its own pair" in lowered - # direct binding via the CLI flags remains the wrapper's documented path - assert "--config" in section - - -def test_the_section_documents_the_session_banner() -> None: - section = _section().lower() - assert "banner" in section - assert "session" in section - assert "clock unavailable" in section + for view in ("Status", "Setup", "Activity", "Insights", "Rules", "Venues", "Gates"): + assert view in section, view -def test_the_section_documents_the_typed_contracts() -> None: +def test_the_section_documents_the_one_time_token() -> None: + """The single most confusing thing about this server on first contact: the URL carries a + token, it is new every run, and it is never written to disk.""" section = _section().lower() - assert "typed" in section - # never pre-filled - assert "pre-filled" in section - # the SIX CLI-own typed prompts run in-console; the two the console adds (attest's - # asset code, promote --force's typed yes) are stated as STRICTER than the CLI -- - # never as identical to it, because the CLI's own gates for those two do not exist - assert "the cli's own typed prompt" in section - assert "stricter" in section - for action in ("resume", "attest", "kill"): - assert action in section - # kill's one-key contract stated as its own - assert "one key" in section or "one-key" in section - - -def test_the_section_documents_the_armed_views_and_ctrl_c() -> None: - section = _section().lower() - assert "armed" in section - assert "enter" in section - assert "ctrl-c" in section or "ctrl+c" in section or "control-c" in section - # the code's own disclosure wording: Ctrl-C exits gracefully, discards held results, - # and the in-flight run does NOT complete (the handlers catch Exception only, so the - # interrupt propagates out of the run) -- pinned so "it does not abort a run in - # flight" cannot come back - assert "does not complete" in section - assert "gracefully" in section - - -def test_the_section_documents_the_venues_browser_and_brokers_list() -> None: - section = _section().lower() - assert "venues" in section - assert "keel brokers list" in section - + assert "token" in section + assert "loopback" in section + assert "never written to disk" in section -def test_the_section_documents_the_help_and_glossary_system() -> None: - section = _section().lower() - assert "glossary" in section - assert "?" in _section() +def test_the_section_says_what_the_browser_cannot_do() -> None: + """**The safety fact, and the reason this test is the sharpest one here.** -def test_the_section_documents_the_safety_design_notes() -> None: + Every capability-increasing action is a CLI command behind a typed confirmation at a + terminal; the browser can perform none of them. An operator who believes otherwise will go + looking in the wrong place for the button that arms autonomy, and -- worse -- an operator who + believes the browser CAN do it may leave the page open thinking it is a control surface. + """ section = _section().lower() - # cursor resets: a remembered row is a loaded one - assert "cursor" in section - assert "reset" in section - # the audit table's existence: the ceremony classes are pinned by tests - assert "audit" in section or "ceremony" in section + assert "capability-increasing" in section + assert "keel capabilities" in section + assert "typed confirmation" in section or "typed" in section + # The claim must be about the SERVER, not about what the page happens to draw: "a client that + # hides a button is not a gate" is the design spec's own sentence. + assert "server implements no verb" in section -def test_the_section_documents_the_account_menu() -> None: - section = _section().lower() - assert "account" in section - assert "pnl" in section - assert "versions" in section +def test_the_section_answers_the_headless_case() -> None: + """"But SSH" is the first objection to deleting a terminal UI, and the runbook must answer it + where an operator will be standing when they ask.""" + section = _section() + assert "ssh -L 8765:127.0.0.1:8765" in section + assert "secure context" in section.lower() -def test_the_section_is_honest_about_scope() -> None: - """The runbook's voice: state the limits, don't sell. The console runs no loop of - its own and the typed gates cannot be automated -- the same warnings the command - surface carries.""" +def test_the_section_is_honest_about_what_was_deleted() -> None: + """The TUI existed for years and operators will look for it. The section says plainly that it + is gone, and why -- not silently omits it.""" section = _section().lower() - assert "does not" in section or "doesn't" in section or "never" in section - assert "loop" in section # the console is not a scheduler - - -# -- the Self-update subsection (issue #415) ------------------------------------------------------- - - -def _self_update_section() -> str: - text = RUNBOOK.read_text(encoding="utf-8") - match = re.search(r"^### Self-update.*?\n(.*)", text, flags=re.S | re.M) - assert match, "the runbook has no '### Self-update' subsection" - rest = match.group(1) - next_section = re.search(r"^#{2,3} ", rest, flags=re.M) - body = rest[: next_section.start()] if next_section else rest - return " ".join(body.split()) - - -def test_the_runbook_has_a_self_update_subsection_under_deploying() -> None: - """The subsection lives under 'Deploying a new version' -- the manual procedure it - automates -- and cross-links the console section for the ceremony.""" - text = RUNBOOK.read_text(encoding="utf-8") - deploy = re.search(r"^## Deploying a new version\n(.*?)(?=^## )", text, flags=re.S | re.M) - assert deploy, "no '## Deploying a new version' section" - assert "### Self-update" in deploy.group(1) - assert "The TUI console" in _self_update_section() - - -def test_the_self_update_section_states_what_it_does() -> None: - section = _self_update_section() - lowered = section.lower() - # the FIVE production wheels, by path (#425: alpaca rides with the four, or an - # equities deployment cannot self-update); the fake/robinhood/kraken exclusion - # travels with it - for prefix in ( - "keel_core", - "keel_broker_api", - "keel_broker_coinbase", - "keel_broker_alpaca", - "keel_trader", - ): - assert prefix in section, prefix - assert "release/" in lowered - assert "back" in lowered and ".bak-before-" in section # backups FIRST, named - assert "migrate" in lowered - assert "keel versions" in lowered # the verify - assert "superseded" in lowered or "old wheels" in lowered - - -def test_the_self_update_section_states_the_typed_gate_and_never_automatic() -> None: - section = _self_update_section().lower() - assert "typed" in section - assert "never" in section and ("automatic" in section or "auto-update" in section) - - -def test_the_self_update_section_is_honest_about_what_the_gate_guarantees() -> None: - """The typed-gate claim names what keel SHIPS (both front-ends hand it the same - gate; the gate fails closed off a TTY, so a scheduled job cannot confirm) without - the old absolutism -- the service API is callable by an operator's own code, and - the CLI can be driven with scripted input on a real TTY; the section says so.""" - section = _self_update_section().lower() - assert "front-ends" in section - assert "fails closed" in section - assert "operator's own code" in section - assert "can ever" not in section # "no script can ever ..." absolutism removed - - -def test_the_self_update_section_counts_the_per_db_migrate_the_four_commands_skip() -> None: - """The four commands do not migrate; the updater does -- the section must say so - instead of claiming the four commands are the whole of it.""" - section = _self_update_section() - assert "plus the per-database" in section - - -def test_the_self_update_section_states_uv_is_a_deployment_dependency() -> None: - section = _self_update_section().lower() - assert "uv" in section - assert "dependency" in section or "required" in section - - -def test_the_self_update_section_keeps_the_manual_fallback() -> None: - section = _self_update_section().lower() - assert "manual" in section - # the manual fallback IS the four commands above -- the docs stay true - assert "gh release download" in section or "uv pip install" in section - - -def test_the_self_update_section_states_the_dev_checkout_refusal() -> None: - section = _self_update_section().lower() - assert "checkout" in section or "source" in section - assert "refus" in section - - -def test_the_self_update_section_states_backups_are_never_deleted() -> None: - section = _self_update_section().lower() - assert "backup" in section - assert "never deleted" in section or "not deleted" in section or "never be deleted" in ( - section - ) - - -def test_the_self_update_section_states_the_relaunch_split() -> None: - """The TUI relaunches itself (execv); the CLI prints the command and does NOT - auto-relaunch -- one honest rule, stated.""" - section = _self_update_section().lower() - assert "relaunch" in section - assert "execv" in section or "replaces itself" in section - assert "cli" in section and ("prints" in section or "does not" in section) + assert "keel tui" in section + assert "curses" in section + assert "#541" in section