Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions docs/operator-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,81 @@ 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: six of the CLI's own, two the console adds.** Six 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`, and `autonomy on` — 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 (pnl — the FIFO report over imported transactions, with
an honest empty state until `keel db import` has loaded any — and versions, the deploy check) is
read-only; there is no write path in it at all. 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.

## How much money moves

Four settings decide position size and how much can be spent. Three live in `config.yaml`; the
Expand Down
260 changes: 260 additions & 0 deletions keel/commands/account_console.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
"""The Account menu (issue #392 C6; PRD §3's Account branch: pnl + versions).

The console tree's LAST placeholder turned real, and the one area that is READ-ONLY
top to bottom: `pnl` and `versions` are both views -- there is no write path here at
all, which the C6 ceremony-audit table states as this module's whole 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.

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 dataclasses import dataclass
from typing import Any

from keel.commands.pnl import build_pnl_report, render_pnl_report
from keel.commands.tui import 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; both entries here are `"view"` -- the Account branch has
no form, no ARMED run and no immediate action, by its own inventory."""

ordinal: int
label: str
description: str
kind: str # "view"
target: str # the view name


#: PRD §3's Account branch in tree order. The descriptions are O8's plain-English "what
#: will this do" in miniature, and they say honestly what each view reads.
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",
),
)


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 read-only account area: the FIFO P&L report over imported "
"transactions, and the whole-install version check -- no action here writes "
"anything",
),
(
"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",
),
),
"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",
),
),
}


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-2 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
Loading