From ce10a5045faaa6cb48629700902bc6994346b40d Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Tue, 11 Aug 2026 14:25:32 -0400 Subject: [PATCH] docs: explain the dashboard's field names and the four money settings Two gaps, split along what the operator SEES vs. what the operator EDITS. The TUI prints keel's internal field names verbatim -- `equity_state_mode`, `high_water_mark`, `drawdown`, `rail11`, `paper_cash_usdc`, and the activity overlay's `sig blk ent exi err` columns -- and nothing on screen explains any of them. A dashboard of zeroes reads identically whether keel found no setup or found one and a rail vetoed it, which is the difference between an idle deployment and a correctly-declining one. Add a Glossary section to the `h` overlay, in the existing `_row`/`_note` idiom, defining each term and making the `sig 0` vs. `sig 1 blk 1` distinction explicit. No new key binding; the widest new line is 85 columns against the help's existing 88, so nothing newly clips under `_paint`. The README documents the rails but not the four settings that decide how much money actually moves, nor how they interact. Add "How much money moves": - `paper.starting_equity_usd` is a ONE-TIME seed applied on the first paper run only (`agent.py`, the `get_cash() is None` branch). Editing it later resizes nothing, and no command clears the persisted `paper_cash_usdc`. - `paper.monthly_contribution_usd` compounds against that small base. - `caps.max_exposure_usd` is both the held-at-once ceiling AND the live sizing equity proxy, so `risk_pct` is a fraction of it rather than of real equity. - rail 14's allowance lives in `broker_subscriptions`, not config -- which is why it drifts out of step with the caps. Sizing scales with equity; the rail-14 allowance is a fixed dollar figure. When they drift apart every setup is vetoed and the system looks broken while working as designed -- recorded with the verified $11,000/$3,284.67-vs-$500 case, and with the mechanic that makes it counter-intuitive: a tighter stop yields a LARGER position, so a 1% risk became a 30% position. Documentation only -- no behaviour, default or config value changes. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 47 +++++++++++++++++++++++++++++ keel/commands/tui.py | 24 +++++++++++++++ tests/commands/test_tui.py | 62 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) diff --git a/README.md b/README.md index 8c8f77ee..4fc49e84 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,53 @@ rail — not even autonomy. time-boxed session: `keel autonomy on --for-hours N`. To stop trading immediately, use `keel kill`, not `keel autonomy off`. +### How much money moves + +Four settings decide position size and how much can be spent. Three live in `config.yaml`; the +fourth does not, which is most of why they drift apart. + +- **`paper.starting_equity_usd`** — the synthetic paper account's seed. **It is a ONE-TIME seed, + applied on the FIRST paper run only** (`keel/agent.py`, the `paper_trader.get_cash() is None` + branch). Editing it afterwards does nothing at all: the seeding branch is skipped whenever + `paper_cash_usdc` is already set, so an already-seeded account keeps its balance forever. + Resizing a running paper account means clearing that persisted `paper_cash_usdc` — a key in the + `agent_state` table, with **no command that clears it** (`keel reset-hwm` does not); a fresh + database is the clean way. `0` (the default) means "seed from real mark-to-market equity + instead"; any value above `0` overrides that and seeds at exactly that amount. +- **`paper.monthly_contribution_usd`** — a recurring top-up, applied once per UTC calendar month. + It compounds, and the base is small: a contribution comparable to the seed doubles the account + monthly, and every position size below grows with it. +- **`caps.max_exposure_usd`** — has **two jobs at once**. It is the ceiling on total notional held + at any one moment (rail 4, and rail 6's concentration cap is a percentage of it), *and* it is + the **equity proxy that sizes orders** on the live path + (`keel/execution/executor.py::_build_intent`). So live `risk_pct` is a fraction of THIS number, + not of real account equity — raising the cap raises the real dollars risked per trade. Set above + actual equity it stops binding before available cash does, and the refusal comes later and less + legibly from the funding check (rail 13). In paper mode the proxy is bypassed: sizing uses the + paper account's own equity. +- **rail 14's monthly allowance** — the fee-free monthly BUY volume. It lives in the **database, + not `config.yaml`**: the `broker_subscriptions` row written by `keel subscription attest --venue + coinbase --tier `, or set directly with `keel subscription set --free-volume-usd N`. + `config.yaml` only supplies the tier catalogue and the unattested fallback + (`subscription.unsubscribed_allowance_usd`). Being in a different place from the caps is exactly + why it drifts out of step with them. + +**The interaction is the point.** Position sizing scales with equity (or, on the live path, with +the `max_exposure_usd` proxy); the rail-14 allowance is a fixed dollar figure that scales with +nothing. Let the two drift apart and *every* setup is vetoed — keel looks broken while every +component is doing exactly what it was configured to do. + +The real case: at **$11,000** paper equity with `risk_pct: 0.01`, a PAXG setup with a 3.35%-wide +stop sized to **$3,284.67** — exactly 1% of equity ($110) at risk, the correct answer. Rail 14's +allowance was **$500/month**, so it was vetoed, as was every other setup. Not a bug in either +setting; the two were simply on different scales. Reseeding the paper account at $500 sizes the +same setup at **$149.30**, which fits. + +Note the counter-intuitive mechanic behind those numbers: **a tighter stop produces a LARGER +position**, because `size = risk ÷ stop-distance` (`keel/execution/sizing.py::size`). That is how +a 1% risk becomes a **30% position** — `risk_pct` bounds what you lose if the stop holds, not what +you spend. + ### Halal by construction, and ships inert Long-only spot only — no leverage, shorting, or derivatives; sizing uses actual cash, so no diff --git a/keel/commands/tui.py b/keel/commands/tui.py index 9ed0688a..d5901fea 100644 --- a/keel/commands/tui.py +++ b/keel/commands/tui.py @@ -483,6 +483,30 @@ def _note(text: str) -> None: _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") diff --git a/tests/commands/test_tui.py b/tests/commands/test_tui.py index 4af682c5..4c493f4e 100644 --- a/tests/commands/test_tui.py +++ b/tests/commands/test_tui.py @@ -1575,6 +1575,68 @@ def test_build_help_screen_is_longer_than_a_small_terminal() -> None: 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)