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)