Skip to content

keel v0.2.0

Choose a tag to compare

@github-actions github-actions released this 24 Jul 13:54
· 367 commits to main since this release
9e77a5f

Built from 9e77a5f. Version binds to this hash:
keel --version reports keel 0.2.0+9e77a5f00b49 [release].

Install

Download all wheels from this release into one directory, then install the
keel_trader wheel by path:

pip install --find-links . ./keel_trader-0.2.0-py3-none-any.whl
keel --version

⚠️ Never install by bare name. The distribution is keel-trader; the name
keel on PyPI belongs to an unrelated project, so pip install keel fetches
someone else's package. A build reporting DIRTY or [checkout] is not this
release and must not be run against live funds.

Configure

config.yaml is attached to this release: the production config, in
auto_trade.mode: confirm — keel previews every order and waits for your
approval. Drop it beside the install (or run keel init-config --live), put
your CDP key in a git-ignored .env, then:

keel migrate     # existing database: apply schema migrations
keel init        # fresh deployment: write config + seed candidate rules

Seeded rules start as candidate and trade nothing until you promote them.

Features

Place orders via interactive confirmation (no bypass/passphrase) (#117)

Option A, as agreed: placing a live order no longer needs bypass mode, the arm-bypass token, or the dangerous-action passphrase. In confirm mode the agent shows the previewed order and asks; it places only on an explicit yes. Bypass mode is untouched and stays the headless path for future autonomous use.

The rails are untouched — this is important

Every order still runs the 15 hard rails first. The confirmation is an additional human gate after they pass, never a replacement. There's a test pinning that a rail-vetoed order never reaches the prompt — the rails run first, the human second. So removing the bypass ceremony did not weaken any hard limit.

Why this was small

The executor was already built for it — execute takes confirm_fn(preview) -> bool; the agent just hardcoded it to None. This threads a real confirm_fn through run_once / loop / _handle_exits (all defaulting to None, so every existing caller and test is unchanged and still fails closed), and keel agent passes an interactive prompt in confirm mode.

_interactive_confirm renders the broker preview and asks; it fails closed on a non-TTY, so a script or cron never trades unattended.

Net effect

Placing an order goes from:

python3 -c "authz.set_passphrase(...)"   # no CLI for this
keel arm-bypass --passphrase ...
keel agent --bypass --passphrase ...

to:

keel agent
  Rails PASSED. Coinbase order preview:
    order_total: 5.00 ...
  Place this order? [y/N]: y

Arguably safer for supervised use — a human looks at the real order every single time instead of pre-authorizing a window.

Tests

approved → places the BUY (+ its OCO bracket); declined → nothing; no confirm_fn → still nothing (backward compat); confirm_fn sees the preview; a rail veto never reaches the prompt; _interactive_confirm yes/no/non-TTY; and the agent command wires the interactive prompt in confirm mode, None in bypass.

Follow-ups (not in this PR)

  • The go-live runbook (#116, unmerged) still describes the old bypass/passphrase dance and must be rewritten against this.
  • Still on your list: ship config.yaml in the release + a seed-the-db command — coming as a separate PR.

1266 tests pass (up from 1258), ruff clean.

feat(release): install scaffolding, PR-body release notes, a confirm-mode config asset, and a seed/migrate lifecycle (#118)

Everything a release ships, and how a fresh deployment comes up correctly.

Install scaffolding

A freshly installed wheel had no config to start from and an empty rules table — and with zero rules, the engine has no strategies to evaluate at all, however config.yaml is set.

  • keel init-config writes a config.yaml template shipped inside the wheel.
  • keel init scaffolds a working directory: config + seed the strategy (rules) library.
  • keel rules seed --status can seed at candidate (default), paper, or live; live bypasses the promotion gate and is for the supervised live-order test only, and says so loudly.

Release notes now carry each PR's content, not a link to it

The notes used to be GitHub's generate-notes output — * <title> by @author in #N — so learning what actually shipped meant clicking through every PR. Each entry now inlines the PR description under a ### <title> (#N) heading.

scripts/release_notes.py (repo-level; deliberately not shipped in the wheel) composes them, stripping the Claude Code footer and everything after it, HTML comments, and Co-Authored-By: trailers, and collapsing blank-line runs. A PR with an empty body renders _(no description)_ — visible, so it gets fixed rather than silently vanishing.

Grouping is unchanged and .github/release.yml remains its single source of truth: first matching category wins, norelease is dropped, unlabelled PRs fall to the catch-all.

Consequence worth internalising: the PR body is the release note. Write it for someone reading the release page.

config.yaml is now a release asset, in confirm mode

The Release attaches config.yaml — the production config: real allowlist and caps, in auto_trade.mode: confirm, so keel previews every order and waits for approval. It is ready for live use but cannot trade unattended off a fresh download; auto_trade.enabled also stays false.

It lives at keel/templates/config.live.yaml, committed and reviewed like any other code, and keel init-config --live writes the identical file locally. The dev template stays mode: paper (places nothing).

A release step fails loudly if that file is ever not confirm — shipping an armed config is precisely what this project refuses to do, so it is a build failure rather than a review question.

Seeding and migrating are separate, on purpose

keel init      # FRESH deployment: write config.yaml + seed the strategy (rules) library
keel migrate   # EXISTING database: apply outstanding schema migrations. Never seeds.

New keel migrate [--db PATH] is an idempotent, schema-only wrapper over the existing db.migrate(), reporting 0 -> 6 or already at 6, nothing to do. It is safe to re-run and safe against a live database.

It never seeds. Re-seeding on migrate would resurrect rules that were deliberately deleted or refuted — the dip-buyers removed after measuring them would come back. Seeded rules stay candidate and trade nothing until promoted.

New .github/workflows/migrate.yml is manual-only: give it a db_path and it migrates that database; leave it empty and it verifies the migration chain instead (a fresh DB and a downgraded DB both reach SCHEMA_VERSION).

Deliberately not done: wiring migration into release CI. keel.db is local, git-ignored and single-user, so CI has no database to reach. db_path is the seam for when the app is server-hosted; building the coupling now would mean a CI step with no target.

Verification

…truncated — full description in #118.

feat(security): delete the vault and the passphrase gate; autonomy becomes a profile choice (#119)

Removes both local security mechanisms from spec §14 and replaces them with one rule: dangerous actions need a human at a terminal; nothing needs a stored secret.

The rails are untouched. All 17 still run first, un-overridable, in every mode. This changes who is asked, never what is allowed. Every claim below has a test.

The vault is gone — and it was already dead code

keel/security/secrets.py (AES-GCM secrets.enc) is deleted, along with the now-unused cryptography dependency.

Worth stating plainly, because it changes how you should read the diff: the vault was never on the live path. data/cb_client.py and cli.py have always loaded credentials via config.load_secrets() from a git-ignored .env; the vault was reachable only through migrate_from_env. Deleting it removes a competing credential path rather than changing how credentials are actually loaded. (PR #115, which would have wired it up, was closed for this reason.)

The passphrase gate is gone

keel/security/authz.py is deleted. Of its four declared dangerous actions, raise_caps and unlock_vault were never used at all — caps are config-file-only, and the vault had no CLI surface. arm_bypass disappears with bypass mode.

That left four commands, all one idea — re-permitting trading after a safety halt: resume, resume-entries, record-flow, reset-hwm. Each now demands a typed yes (not a bare y) and fails closed off a TTY.

The reasoning is coherence. Since #117, placing a real, money-spending order needs only a typed confirmation. Requiring a remembered secret to reset a high-water mark is ceremony without a matching threat model — and the old gate's own docstring conceded it "does not stop an attacker who already holds the OS account". One rule is easier to audit than two.

⚠️ There is deliberately no env-var or flag override for the TTY check. I started to add one as a test seam and rejected it: any such seam is settable from cron and would defeat every fail-closed built on it. Tests patch the predicate instead.

Autonomy is a profile choice, not a config mode

New profile table (schema 6 → 7) holds the user's autonomous flag.

keel autonomy show
keel autonomy on     # typed "yes", terminal required
keel autonomy off    # always allowed, works from a script or cron

auto_trade.mode collapses to paper | confirm, so there are two independent switches rather than one enum conflating both:

mode autonomous result
paper (ignored) simulated, places nothing
confirm false (default) live, asks before every order
confirm true live, places without asking

Four properties, each tested:

  • Read live, never cached_effective_mode re-reads the profile every cycle, so keel autonomy off binds on the next order, not the next restart. (Mirrors rail 14's allowance, moved to the DB for the same reason.)
  • Fails closed — an absent or deleted profile row reads as not-autonomous. A database that never recorded a choice must not imply consent.
  • Enforced in-process — the check lives inside run_once, not only at the CLI, so a caller driving run_once directly cannot obtain autonomy the CLI would refuse.
  • Not scriptable to enableautonomy on requires a terminal. off deliberately does not: reducing risk must never be obstructed.
Autonomy does not clear safety halts

…truncated — full description in #119.

feat(assets): the user's broker holdings as a candidate source (#120)

Adds the missing source for allowlist candidates: the assets you actually hold at the broker.

keel assets holdings [--min-balance N] [--screen]

It answers "what do I already own that this system might trade?", where keel assets discover answers "what could anyone trade?".

A source, not a gate

Holding an asset is not a reason to trade it. This command admits nothing and writes nothing — no attestation, no allowlist change, no DB write. It is a read-only report, in the same family as assets discover.

The vetting itself is unchanged. screen_asset and its policy are untouched: if this work had needed to weaken the gate to let the user's own holdings through, that would have been evidence against the holdings, not against the gate. An unattested asset you hold is still REJECT — sector and backing cannot be derived from a balance any more than from a price.

2 holding(s) above 0, excluding USDC and fiat:

  BTC      balance=0.5      on-allowlist     UNATTESTED
      REJECT  (2000 daily bars cached)
      x attestation: MISSING. ...
  SOL      balance=12       not-on-allowlist UNATTESTED
      REJECT  (0 daily bars cached)
      ! no local history -- run `keel fetch --products SOL-USD` first, then re-screen.
        This is a MISSING-DATA verdict, not a verdict about the asset.

One gate, shared by construction

assets screen inlined the attestation lookup and the screen_asset call. That is extracted to _screen_product, and both commands now route through it — so a proposer cannot drift onto a laxer path. A test asserts the two commands reach the same verdict for the same asset.

This is also the seam the future LLM proposer must use. Recorded in the spec: per the project's asymmetry principle an LLM may propose and may veto, but may never admit. Admission still requires a human keel assets attest with a source, a passing screen, and a deliberate config.allowlist edit. Nothing here grants a proposer new authority.

Two things found while building it

  • Screening {asset}-{quote_currency} would have made the feature useless. Daily history is keyed -USD throughout this codebase (_default_sim_products, keel fetch, keel simulate), while quote_currency is USDC. Screening the settlement product would have found zero cached bars for every asset and reported "no local history" forever — which is worse than useless, because it reads as a verdict about the asset. The convention is now single-sourced in _history_product, and my own test caught this by asserting the two commands agree.
  • "No local history" is not "bad asset". The likeliest misreading of the whole feature, so it is handled in the output rather than left to the operator, and it names the keel fetch that fixes it.

Verification

1300 tests pass, ruff clean. Nine new tests: fiat/settlement-currency exclusion, dust filtering, held-but-unattested is still rejected, both commands agree on one asset, the missing-data message, the command writes nothing (asserted, not assumed), and a broker failure is an error rather than an empty result that would falsely read as "you hold nothing".

Design: docs/superpowers/specs/2026-07-21-holdings-as-candidate-source-design.md.

Fixes

fix(rails): rail 13 must guard the currency the order actually spends (#121)

Found during the first supervised live-order attempt. It looked like three separate quirks; it is one design defect with a live-money safety hole in it.

The defect

The codebase conflates two different things:

  • the product's quote leg — what an order actually spends (BTC-USD spends USD);
  • config.quote_currency — one global setting (default USDC).

Nothing derived the first from the product, so rail 13 guarded a balance the order never touches:

  • False veto (observed live): $49.01 USD available, $0.25 USDC, a $5 BTC-USD order → vetoed. Annoying, safe.
  • False pass (the serious one): ample USDC, no USD → the rail approves an order the account cannot fund. That is exactly the "never draw from a linked bank/ACH source" case rail 13 exists to prevent. A safety rail that can pass when it should veto is worse than no rail, because it is trusted.

The fix

One rule: the currency an order spends is a property of the PRODUCT, never of global config.

  • New keel_core.products.quote_currency_of(product_id) → the leg after the last -, uppercased; None for anything unresolvable.
  • The executor fetches the balance of that currency; rail 13's fail-closed semantics are unchanged, and an unresolvable product id yields None, which it already vetoes on.
  • The violation message names the currency actually required. Telling an operator "insufficient USDC" on a USD-settled order sends them to fund the wrong thing.
available USD 0     -> usdc_funding: available USD balance 0 is not greater than 0
available USD 1000  -> PASS
available UNKNOWN   -> usdc_funding: available USD balance is unknown/unavailable -- failing closed
malformed id        -> usdc_funding: cannot determine the settlement currency of 'BTCUSD' -- failing closed

Two consequences of the same root cause

The screen's settlement criterion was vacuous. quotable_in_settlement_currency = product.endswith(f"-{quote}") or bool(candles). Every screened product is -USD while quote was USDC, so it always fell through to bool(candles) — meaning require_settlement_quote re-checked "do we have bars", which the history criterion already covers. One of four admission criteria did nothing. The fallback is removed; the check is now real. (This was recorded as a known weakness in PR #120 and deliberately deferred — this is that follow-up.)

quote_currency now defaults to USD (repo config, both templates, Config, DiscoveryPolicy), because that is what this deployment actually trades — products, cached history, rules and the simulator are all -USD. With the settlement check real, leaving it USDC would reject every asset. A default has to describe reality.

Also

  • Stablecoins join fiat in the holdings exclusion. Cash held between positions is not a position. Closes a limitation flagged in #120's review (USDT/DAI surfacing as candidates), and prevents USDC being offered as tradable now that it is no longer the settlement currency.
  • The operator's working config moves to a gitignored config.local.yaml. config.yaml is the source of the shipped wheel template and should never carry one machine's live settings — I had committed mine by accident with git add -A.

Verification

…truncated — full description in #121.

Other changes

chore: require Python 3.14.4; move ruff config to ruff.toml (#122)

Raises the Python floor from >=3.12 to >=3.14.4 and moves ruff's config out of pyproject.toml into a root ruff.toml.

What changed

  • requires-python>=3.14.4 in the root project and all four workspace members. Leaving a member at >=3.12 would give it a laxer floor than the workspace actually resolves against.
  • ruff.toml (new) holds line-length, target-version = "py314", and the E,F,I,UP / ignore = ["UP042"] lint config. The [tool.ruff] block in pyproject.toml is deleted, not duplicated — a ruff.toml takes precedence over [tool.ruff], so keeping both would leave the pyproject copy silently dead.
  • [tool.mypy] python_version"3.14" to match.
  • .python-version and uv.lock both had 3.12 baked in and contradicted the new floor (uv refuses to sync in that state), so both are regenerated.

Notes for review

No source changes were needed — this is config only.

The uv.lock diff looks large but contains no version changes. 66 of its 137 removed lines are cp312 wheel URLs that are unreachable under the higher floor; the rest is the same pruning for other dropped interpreter tags. Every package version is byte-identical before and after.

Verification on 3.14.4

python --version  → Python 3.14.4
ruff check .      → All checks passed!
pytest -q         → 1322 passed in 8.36s
mypy              → Success: no issues found in 177 source files
keel --help       → CLI loads, broker entry points resolve

coinbase-advanced-py and cryptography==49.0.0 both ship 3.14 wheels, so nothing builds from source.

refactor(cli): decompose the 2627-line cli.py into a keel/commands package (#123)

Summary

keel/cli.py had grown to 2627 lines — seven click command groups plus ~15 top-level commands and their shared helpers in one file. This splits the broker-free groups into their own modules under a new keel/commands/ package, leaving cli.py a thin composition root.

keel/cli.py: 2627 → 1688 lines (−36%). No behavior change.

What moved

Module Purpose
commands/trials.py trials group (ledger-only, seam-free)
commands/rules.py rules lifecycle group (rules_seed still invoked by init)
commands/subscription.py subscription group (rail 14)
commands/autonomy.py autonomy group (+ its _AUTONOMY_HOURS constants)
commands/withdrawals.py withdrawals group (rail 17)
commands/db.py db import group
commands/_common.py Shared seams: disclaimer, confirmation gate, _open_repo/_load_cfg/_build_broker
commands/_products.py Shared product-id derivation

Each group is registered on the root CLI via cli.add_command(...).

Deliberately kept in cli.py

The assets group and the broker-touching top-level commands (fetch, agent, monitor, simulate) stay in the composition root so the _build_broker monkeypatch seam remains one coherent target rather than fragmenting across modules.

Seam preservation (the delicate part)

  • _build_broker / _open_repo / _load_cfg stay patchable as keel.cli.Xcli.py re-imports them and every caller of these remains in cli.py.
  • _is_interactive (called internally by the confirmation gate) is routed through _common._is_interactive() as a single patch point; three test files were retargeted to keel.commands._common._is_interactive.

Verification

  • ruff check . clean
  • mypy keel packages clean
  • pytest1322 passed
  • ✅ CLI smoke test: every subcommand still resolves

Each extraction step was reviewed by a sub-agent (zero defects), plus a final holistic review.

chore: pin python 3.14.4 in a repo-local .tool-versions (#124)

Pins python 3.14.4 in a repo-local .tool-versions so the interpreter is resolved from the checkout itself, keeping asdf/mise in lockstep with pyproject's requires-python = ">=3.14.4" regardless of a contributor's global config.

Previously the version was only pinned in ~/.tool-versions; a contributor without that global pin (or with a stale one) could land on the wrong interpreter.

chore: drop .tool-versions; standardize on .python-version + pyproject (#125)

Removes the repo-local .tool-versions added in #124. It duplicated the exact 3.14.4 pin already in .python-version, and two concrete pins can silently drift.

The project standardizes on .python-version (read by uv, the project's toolchain) alongside pyproject's requires-python = ">=3.14.4" range. asdf continues to resolve 3.14.4 from the user's global ~/.tool-versions; uv run continues to resolve it from .python-version (verified).

fix(agent): report paper cycles as mode=paper, not mode=confirm (#126)

Problem

Running keel agent with the default config.yaml (which is mode: paper) printed:

mode=confirm polled=0 products=[] stale=[] signals=0 entered=0 exited=0

The mode=confirm was misleading — it told the user a live confirm-mode run had happened when the cycle only ever hit the paper simulation path and never touched the broker. (This directly caused a support question: "why didn't it place the order in Coinbase?")

Root cause

_effective_mode returns the executor string "confirm" for any non-live config — paper included — because paper never reaches an executor (it routes to PaperTrader upstream). run_once surfaced that raw value in both the agent.mode_resolved log event and LoopResult.mode.

Fix

Split "what the executor uses" from "what we report":

  • mode (executor mode) is unchanged and still passed to executor.execute / _handle_exits on the live path.
  • A new reported_mode = "paper" if paper_trader is not None else mode drives the log event and LoopResult.mode.

Confirm and autonomous runs are unchanged.

Verification

  • New test test_paper_mode_is_reported_as_paper_not_confirm (written failing first).
  • Full suite: 1323 passed.
  • Manual: default config.yaml now prints mode=paper; --config config.local.yaml still prints mode=confirm.

docs(kb): source-84 — keeks bankroll-management (Kelly family) + sizing simulation (#127)

What

Enriches the trading knowledge base with the keeks library (v0.3.0) and its 9-part "Bankroll Management with Keeks" series — the Kelly Criterion family of capital-allocation sizers.

Halal framing: adopted strictly as the mathematics of optimal capital allocation, never betting (maysir). Educational only; nothing is wired into the live agent — the paper-proving gate + backtest floor still bind.

Contents

  • sources/source-84.md — disclaimer; per-strategy formulas (full / fractional / drawdown-adjusted Kelly, optimal f, Merton/CRRA, fixed fraction, CPPI, dynamic, naive); halal screen; keeks API reference; and educational commands to explore the rules & strategies.
  • README.md — sources-log row 84 + a new module-map row for execution/sizing.py.
  • analysis/bankroll_sizing/ — stdlib-only sizing formulas + Monte-Carlo (simulate.py) + 38 unit tests (all pass via uv run pytest).
  • reports/2026-07-22-bankroll-sizing-comparison.md — the simulation write-up.

Verdict (like §83: confirms the risk model, does not reshape it)

keel's fixed-fractional risk_pct=0.01 is the Fixed Fraction strategy. At keel's own promotion floor (win_rate 0.55, R:R 1.5), full-Kelly f* = (1.5·0.55−0.45)/1.5 = 0.25 → keel's 1% is ~4% of full Kelly — a 4th independent "use fractional f, never full" confirmation (§54.18/§83.5/§83.11). The simulation's estimation-error stress run (true p 5pts below estimate) collapses full-Kelly growth and lifts its ruin rate to 3.6% while the sub-Kelly levels stay at 0% ruin — defending keel's deeply-sub-Kelly posture, not a recommendation to change risk_pct.

Test plan

  • uv run pytest docs/superpowers/analysis/bankroll_sizing/test_sizing_strategies.py -q → 38 passed
  • uv run python docs/superpowers/analysis/bankroll_sizing/simulate.py regenerates the report

chore: add review-fix-merge PR review skills (#128)

Adds two project skills for reviewing + merging PRs, so they live with the repo instead of only in a local .claude/.

Note: .claude/ is gitignored (line 26), so these were force-added (git add -f). They stay tracked and their edits show normally; new files added inside these skill dirs later would need another git add -f.

Skills

  • review-fix-merge-pr — single-reviewer loop: review a PR's diff → fix findings → loop review↔fix until clean → squash-merge. Bundles a parameterized Workflow script (review-fix-loop.js).
  • review-fix-merge-pr-advanced — multi-lens variant: parallel reviewers each backed by a specialized skill (security-audit, performance, code-quality, testing-strategy, correctness), adversarial verification of each finding before fixing, loop, then merge (advanced-review-fix-loop.js).

Both scripts are syntax-checked; invoked via /review-merge-pr... or by asking to review-and-merge a PR.

docs(kb): explore source-84 leads — drawdown taper (kept) + Merton γ (promoted) (#129)

Measures the two candidate leads flagged in KB source-84 §84.16, in the KB's "settled by measurement" style. Adds a stdlib-only study (explore_leads.py, +18 tests → 56 in the dir) and a report, and folds the verdicts back into source-84 §84.4/§84.6/§84.16 + the README index row.

Halal/educational framing preserved — the Kelly family studied as the math of capital allocation, not betting; nothing is wired into keel's execution path.

Lead 1 — dynamic drawdown taper (1−d/D)·f*KEEP as ceiling/diagnostic (not built)

  • Barely engages on keel's 1% base (drawdown rarely reaches even D=0.15; breaker-trips 0% either way) — confirming its value isn't protecting the tiny base.
  • Its payoff is unlocking a higher base: tapered quarter-Kelly at D=0.15 (below keel's 20% hard breaker) dominates both flat-1% on growth (4.27× vs 2.08×, profile A) and untapered ¼-Kelly on safety (median DD 14.9% vs 22.75%; hard-breaker trips 99.8%→0%).
  • Strictly conditional: dominance needs D < the 20% breaker (fails at D≥0.25 and in the p-over-estimated world); a half-Kelly base is too large for any taper to rescue. Only pays off if the base is ever raised off 1% — which §58.11/§84.14 argue against. Parked, with a number.

Lead 2 — Merton γ μ/(γσ²)PROMOTE to diagnostic (not a risk_pct change)

  • keel's implied γ ≈ 24 (floor edge) / ≈ 34 (stronger edge) — 24–34× more risk-averse than full Kelly (γ=1), 12–17× past textbook γ≈2. A crisp "how sub-Kelly are we" number.
  • A single fixed γ is edge-and-variance-aware: γ=24.24 sizes the stronger/lower-variance edge at 1.39% vs the floor's 1.00%, automatically — which flat-1% cannot do.
  • Effective λ = 4.00% / 3.76% of full Kelly, matching keel-1%'s own ~4% figure; ruin stays 0% under the p-over-estimated stress (graceful, like fractional Kelly).

Test plan

  • uv run pytest docs/superpowers/analysis/bankroll_sizing/ -q → 56 passed
  • uv run ruff check docs/superpowers/analysis/bankroll_sizing/ → clean
  • uv run python docs/superpowers/analysis/bankroll_sizing/explore_leads.py regenerates the report (deterministic)

docs(readme): add 'How keel works' section (#130)

Adds a How keel works section to the root README, filling a real gap: the operating model (how orders actually get placed, why there's no manual trade command, DCA's calendar cadence) was previously only reconstructable from the design spec, runbooks, and code docstrings.

Covers, in a screenful:

  • The agent loop — poll → each live rule's detect()rails → preview → confirm/autonomy gateplace_order → log.
  • Rules-only, no manual order command — every order is rule output; the four rule kinds and that DCA is the one on a calendar cadence (the others fire on real market setups).
  • The un-overridable rails — allowlist, spend/exposure/concentration caps, kill-switch, drawdown & edge-decay breakers, no-martingale/no-stop-widening, feed/balance/attestation checks.
  • Confirm vs autonomy — changes who is asked, never what's allowed.
  • Halal + ships inert — long-only spot, no riba; nothing trades until a rule is live, attested, funded, and confirmed.

Verified against guards.py, agent.py, executor.py, and the runbooks so it doesn't drift. Docs-only.

docs(runbook): note the DCA cadence gotcha (signals=0, no preview) (#131)

The go-live runbook's step 4 promises an order preview, but a DCA test vehicle only fires on its calendar cadence(latest_daily_bar_epoch_day % cadence_days) == 0, i.e. Thursdays (UTC) for the weekly default. Run the agent on any other day and you get signals=0 with no prompt, which reads as a failure (it isn't).

Adds:

  • a pointer in step 4 for when you see signals=0 instead of a preview;
  • a "What can still go wrong" bullet explaining the cadence gate, how to fire it on demand (cadence_days=1, reverted after — a one-row rules update that places no order), and that the risk-defined rules fire only on real market setups, so DCA on a cadence day is the controllable path for a first live order.

Docs-only; verified against keel/strategy/rules/dca.py.

docs(kb): fix broken spec link in source-84 (halal-cb -> keel rename) (#132)

source-84's cross-references pointed at docs/superpowers/specs/2026-07-15-halal-cb-autotrade-design.md, a stale filename that doesn't exist after the halal-cb → keel rename. Repointed to the real 2026-07-15-keel-autotrade-design.md (matching the root README). Found via a session doc-drift audit; it was the only KB/runbook reference to the broken name.

docs(spec): fix stale spec link (halal-cb -> keel rename) (#133)

The security-simplification design's 'files touched' table pointed at docs/superpowers/specs/...-halal-cb-autotrade-design.md, stale after the halal-cb → keel rename. Repointed to the real 2026-07-15-keel-autotrade-design.md. This was the last remaining reference to the old name anywhere in the repo (verified by grep).

feat(compliance): documented allowlist-screen exceptions (waivers) (#134)

What & why

Adds a general, per-asset / per-criterion allowlist-screen exception mechanism, plus keel assets exempt / unexempt to record and revoke one.

Motivating case: PAXG passes shariah (ayn, KB §65.5/§72.4/§86) and liquidity screening but fails the 4-year daily-history floor (441 on-chain -USD bars < 1460). Gold has centuries of off-chain price history and PAXG is a thin tokenised wrapper, so a human wants to record a documented, auditable exception that waives only the history criterion — never a silent exemption.

Design — honest and tightly bounded

  • New table screen_exceptions (asset, criterion, rationale, granted_by, granted_at; PK (asset, criterion)). Schema v8→v9 via a documented no-op migration (the table DDL is in the schema block; migrate() creates it on existing DBs before the version loop — mirrors the v6/v7 attestation/profile pattern).
  • WAIVABLE_CRITERIA = frozenset({"history"}) — only a DATA/market criterion may ever be waived. The shariah core (missing attestation, haram_sector, riba_yield, dayn/unknown backing) and settlement can never be waived. Enforced two ways: the CLI --criterion is a click.Choice restricted to this set, and screen_asset only reads a waiver inside the history branch (structural isolation) and checks membership — belt-and-suspenders.
  • Surfaced loudly, never silent: a waived history failure becomes a ! ... WAIVED by documented exception: <rationale> warning and the verdict flips to ADMIT. Self-retiring: the waiver is only consulted when the check would otherwise fail, so once -USD bars ≥ 1460 it becomes inert automatically.
  • Ungated like assets attest (an exception cannot itself place an order; the 17 guards.py rails still enforce every trade). Revocable via unexempt (a de-risking action). Shown in assets list.

Tests (TDD, written first)

  • screen: waiver admits + warns loudly; self-retires when bars ≥ floor; a waiver for a non-waivable criterion fails closed; a waiver rescues only history (other failures still REJECT).
  • repository: upsert/get/list/delete round-trip, ON CONFLICT update, asset-scoping.
  • db: fresh + upgraded DBs reach v9 with the table; migrate idempotent.
  • cli: exemptscreen ADMITs with WAIVED; bad --criterion rejected; list shows it; unexempt reverts to REJECT.
  • Mutation-checked the WAIVABLE_CRITERIA guard (emptying it fails the positive-path tests).

uv run pytest -q1399 passed; uv run ruff check → clean.

feat(config): expand live allowlist to 8 compliance-cleared assets (#135)

What

Wires the compliance-screened assets into the operative trading allowlist (guards.py rail 1 enforces config.allowlist). Adds SOL, XLM, LTC, ADA, LINK to the existing BTC/ETH/PAXG → 8 assets.

All eight are screen-ADMITTED: attested (KB §85/§86; PAXG ayn + a documented history-floor exception) with ~5yr of cached daily+hourly data. keel assets screen → 8/8 admit.

Weights

target_weights rebalanced across the 8, summing to exactly 1.00:

BTC ETH PAXG SOL XLM LTC ADA LINK
.30 .20 .20 .06 .06 .06 .06 .06

~70% in the established BTC/ETH/PAXG core (PAXG the real-asset diversifier, §83.10), 30% spread evenly across the five alts. Weights drive the DCA benchmark reference, not live sizing (the Turtle sizes by ATR/risk_pct).

Scope

  • config.yaml (dev) + byte-identical keel/templates/config.yaml + keel/templates/config.live.yaml — updated in lockstep. Dev stays mode: paper, live stays confirm; top-level keys unchanged. No logic change.
  • Golden baselines and the parser fixture tests are independent (their own YAML) — untouched.

Safety

The 5 new assets have no live rules yet. keel rules seed (run locally, post-merge) creates candidate Turtle rules that must pass backtest → paper → promotion before any live trade. Expanding the allowlist makes them eligible, not live.

uv run pytest -q1410 passed; uv run ruff check → clean.

feat(paper): synthetic account + Rail 11 drawdown enforcement + sizing fix (#136)

Paper-mode fidelity: synthetic account + Rail 11 enforcement + sizing fix

Makes paper trading a faithful rehearsal of live so the paper-forward produces a trustworthy out-of-sample track record for the promotion gate.

Spec: docs/superpowers/specs/2026-07-23-paper-mode-fidelity-design.md
Plan: docs/superpowers/plans/2026-07-23-paper-mode-fidelity.md

What & why

Two coupled defects in the paper path, fixed together (Rail 11 needs an equity denominator, which needs real sizing, which needs per-position qty):

  1. Rail 11 (the drawdown circuit breaker) was inert in paper — the agent hard-set equity_now = None in paper mode, so update_drawdown never ran and the breaker read a frozen 0. A catastrophic drawdown could run a paper strategy into the ground uncapped, polluting the OOS record.
  2. Paper mis-sized its fills — it built a risk-sized intent only to gate the guard check, then filled a fixed 1 unit, with no cash balance. Drawdown-as-a-percentage was undefined.
How

A synthetic paper account (cash + qty-bearing positions, persisted in agent_state) is seeded once from real broker mark-to-market equity (fallback paper.starting_equity_usd), marked to market each cycle, and fed into the existing equity.update_drawdown producer — which writes the same global scalars guards.py's Rail 11 already reads. guards.py and the DB schema are unchanged. Fills are sized off account equity via _build_intent(equity_override=...) (live path unchanged). Mode-flip clears the shared HWM so a synthetic HWM can't poison live equity. Halt = veto new buys; open positions ride to their stops (mirrors live).

Design decisions
  • Paper sizes off its synthetic account equity (like the sim), not the $5k max_exposure proxy.
  • Seeded once from real equity at start; loop is broker-free thereafter.
  • Observability via LoopResult fields + _print_loop_result + an agent.paper_equity log event (a dedicated keel status command is deferred).
Testing

1447 tests pass, ruff check clean. Includes an end-to-end acceptance test: a paper account driven to −20% / −8% through the real run_once loop gets its buys vetoed by Rail 11.

Process

Built subagent-driven, 10 TDD tasks, each spec+quality reviewed. The final whole-branch review found and fixed a Critical the per-task reviews missed (an epoch cutoff that mixed bar-time and wall-clock, dropping first-cycle positions on rehydration and desyncing cash) — resolved with an id-based epoch and independently re-verified.

Deferred follow-ups (non-blocking)
  • Pre-live-arming prerequisite: the live-side mode-clear is asymmetric; a paper→live flip with an unreadable first cycle leaves stale paper scalars for one cycle (self-heals; live not yet armed). Gate the live clear on == "paper" and hoist before the broker read before arming live. TODO left in agent.py.
  • Non-goals (Phase-4): fix the live executor's $5k-proxy sizing to use real equity; unify SimAccount and the paper account.
  • Minor cleanups: dead paper_ledger_start_ts field, a few test-hygiene nits.

fix(agent): symmetric live-side equity-mode clear (pre-live-arming) (#137)

What

Makes the live-side equity-state mode clear in run_once symmetric with the paper-side clear (_seed_paper_account_if_needed). This is the pre-live-arming fix flagged by a TODO(pre-live-arming) in keel/agent.py — harmless today (live is never armed) but must land before any live execution.

The bug

The old live-side clear lived inside the equity-readable branch and was gated on != "live". On a paper→live flip whose first live cycle reads an unreadable broker, equity_now is None, the whole branch is skipped, and stale paper drawdown scalars survive one extra cycle before self-healing — arming Rail 11 on a phantom drawdown for a cycle.

The fix

  • New _clear_live_mode_if_needed(repo) mirrors the paper-side clear: gated on the prior mode being "paper", hoisted to the top of the live branch, before the broker-equity read, so it fires unconditionally on the flip regardless of broker readability.
  • Deleted the old inline clear + stale TODO.

Tests

  • New regression test test_paper_to_live_flip_clears_stale_scalars_even_when_broker_unreadable — confirmed failing before the fix, passing after.
  • Full suite: 1448 passed, ruff clean. All 5 adjacent drawdown/mode tests unchanged and green.

feat(cli): keel status — read-only operator dashboard (#138)

What

Adds keel status, a read-only operator dashboard — the interim of the TUI/keel status command explicitly deferred in the paper-mode-fidelity spec ("A dedicated keel status command is deferred as a follow-up"). Purpose: let an operator running a paper-forward see the agent's state at a glance, purely from the local DB + config — it never calls the broker.

Shows

  • mode (paper/confirm) · kill-switch (engaged/clear, fail-closed) · autonomy (mirrors keel autonomy show)
  • Rail 11: equity_state_mode, HWM, total/weekly drawdown vs config ceilings, and a computed HALTED / ok / unknown breaker status; paper_cash_usdc in paper mode
  • open positions · rule counts by status (+ live rules) · per-product data freshness (finest granularity, age)
  • subscription/allowance (rail 14) when any venue is attested

Design

  • Pure gather_status(repo, config, now_ts) -> StatusReport (no click, no broker) + render_human + a --json flag (stable machine-readable shape, forward-compat with the eventual TUI). Matches the codebase's logic/CLI separation.
  • Rail 11 display deliberately shows unknown for unwritten scalars rather than defaulting to 0/"ok" like guards.py's veto path — a dashboard must not misreport an uncomputed value as a confident all-clear. guards.py was read for reference only, not modified.

Tests

  • 18 pure-function tests + 2 CliRunner tests (incl. --json parses as JSON). Full suite 1467 passed (+20), ruff clean.

feat(rules,agent): funded paper-forward enablement (#139)

What

Two small, cohesive capabilities that together let us start a funded paper-forward of the 5-trend Turtle — the honest path to the n=100 out-of-sample evidence floor.

1. keel rules promote --force

The lifecycle is candidate→paper→live, and promote re-runs the backtest gate (min_trades=100). A low-frequency trend-follower's backtest yields far fewer than 100 trades, so it can never enter paper status via the gate — yet the whole point of a paper-forward is to accrue the out-of-sample trades the backtest can't. --force advances one lifecycle step directly (via repo.update_rule_status), with a loud warning + rules.promote_forced log event. Analogous to the existing seed --status live gate-bypass. No-op on live/disabled. Non-force path unchanged.

  • New public helper promotion.next_status(status).
2. starting_equity_usd as a funding override for the paper seed

_seed_paper_account_if_needed seeded the paper account from real mark-to-market equity, using starting_equity_usd only as a broker-failure fallback. Now: starting_equity_usd > 0 funds the paper account at that amount (a deliberately-funded rehearsal, no broker read needed); == 0 keeps the real-equity default. Committed config.yaml value stays 0 — only the comment/docstrings changed.

Tests (TDD)

12 new tests: force-promote candidate→paper with zero candles (backtest would never clear the floor), paper→live, no-op on live/disabled, the loud warning, non-force still gates; funding override wins over a broker reading real equity; starting_equity_usd==0 still seeds from real equity. Full suite 1480 passed (+12), ruff clean.

feat(cli): keel tui — live read-only operator dashboard (#140)

What

Adds keel tui — a full-screen, auto-refreshing, read-only terminal dashboard for watching a running agent / the funded paper-forward at a glance. It is built directly on the pure gather_status(...) -> StatusReport that keel status (PR #138) was deliberately left as the substrate for, and is strictly a view over that same report (it never re-derives Rail 11 / freshness / autonomy logic).

Shows: mode, kill-switch, autonomy, Rail 11 drawdown/equity state, open positions (bracket-less positions flagged), rule counts + live rules, per-product data freshness (stale series flagged), and subscriptions — colour-coded and refreshed on an interval.

Why now

The funded paper-forward (5-trend Turtle, $10k + $500/mo) is live and accrues trades toward the n=100 floor over months. Watching it meant re-running keel status by hand; this is the auto-refreshing dashboard the user asked for, and it's unblocked today — no monorepo split or keel-client protocol needed.

Design

  • Pure, testable core: build_screen/render_plain/_freshness_style are pure functions of the StatusReport; the curses I/O (_paint/run_live) is a thin layer tested against a fake stdscr.
  • No new dependency: stdlib curses only, in keeping with the project's hand-rolled ethos (no rich/textual). curses is imported lazily so the module stays importable and the core tests stay portable.
  • Strictly read-only: NEVER calls the broker or touches the network; cannot confirm/kill/arm anything (that's a separate, larger, gated feature).
  • Operational robustness: --once renders one frame to stdout (pipes/CI); --interval N sets cadence; the live loop re-opens the repo each poll (so it reflects a separate keel agent's committed writes) and survives a transient DB read error (e.g. database is locked from the writer) by painting an alert line instead of crashing.

Testing

  • 39 new tests (tests/commands/test_tui.py); full suite 1519 passed, ruff check clean.
  • Includes regression guards for two issues an independent review caught: colour was silently disabled (missing use_default_colors()-1 background illegal under curses.wrapper), and the live loop crashing on a transient SQLite lock.
  • keel tui --once verified against the real keel.db.

Design spec: docs/superpowers/specs/2026-07-24-tui-dashboard-design.md

fix(cli): keel tui fails gracefully without an interactive terminal (#141)

What

Follow-up to #140. Running keel tui (the live, full-screen path) without a real controlling TTY dumped a raw traceback:

_curses.error: cbreak() returned ERR

This surfaced when launching it through a pipe / a harness that captures stdio rather than owning a terminal. Now it fails with a clean, actionable message instead.

Fix

  • Pre-check _stdio_is_interactive() (both stdin and stdout are TTYs) in tui_cmd before entering curses; if not, raise a ClickException pointing the user at keel tui --once.
  • Belt-and-braces: run_live also catches curses.error raised by curses.wrapper itself — the case where a terminal passes isatty() but still can't be put into cbreak mode — and turns it into the same helpful message.

--once (the non-curses snapshot path, used by pipes/CI) is unaffected.

Testing

  • +3 tests (_stdio_is_interactive both-TTY logic; keel tui under CliRunner exits cleanly with a --once hint; run_live wraps a curses.error from wrapper as a ClickException).
  • Full suite 1522 passed, ruff check clean.
  • Verified: piped keel tui now prints Error: keel tui needs an interactive terminal … use \keel tui --once`instead of a traceback;--once` still renders a snapshot.

feat(cli): interactive keel tui — human-readable time, help menu, actions (#142)

What

Turns the read-only keel tui dashboard into an interactive operator console.

  1. Human-readable timestampsnow=, position opened_at=, and autonomy lapse times now render as YYYY-MM-DD HH:MM:SS local time (freshness keeps relative "4h ago").
  2. Keybinding hint bar + a browsable, scrollable help overlay (h/?; arrows / j/k / PgUp / PgDn / Home / End; Esc/h to close).
  3. Actions, each with the same asymmetric safety gating the CLI already enforces:
    • a toggle autonomy — turning OFF is instant (de-risk, ungated); turning ON suspends curses and runs the same typed-yes gate as keel autonomy on, showing mode + allowlist, and fails closed on any error (EOF, exception during suspend/restore, Ctrl-C) so it can never arm silently. Hard rails are untouched — autonomy only changes who is asked.
    • f fetch-all-data — money-safe (ensure_history pulls candles, places no orders); shows a progress frame + result toast.
    • r refresh-now.
  4. Action results show as a short-lived (12s TTL) styled toast; the dangerous ON-arm transition is styled alert, not green.

Safety

An independent review traced every path to set_autonomous(True, …) and confirmed arming is reachable only through a genuine typed-yes human confirmation (fail-closed on every error path). The arm prompt shows mode + allowlist so an operator can see whether they're arming paper or LIVE. Contract change from v1 ("strictly read-only") is documented in the spec's ## v2 section.

Design / testability

Action logic (toggle_autonomy, _guarded, build_help_screen, _visible_slice, _human_dt, _message_style, _confirm_arm_autonomy fail-closed) is pure/injectable and unit-tested; the curses loop + live network fetch stay thin I/O. --once remains a static, read-only snapshot.

Testing

  • +28 tests; full suite 1550 passed, ruff check clean.
  • keel tui --once verified against the real keel.db (human dates + keybinding footer).

Design: docs/superpowers/specs/2026-07-24-tui-dashboard-design.md (§ v2).

fix(cli): drop the 'now=' label from the keel tui header (#143)

Small cosmetic fix: the keel tui header showed keel · paper mode · now=<datetime>. Drops the now= label, keeping the human-readable clock:

keel · paper mode · 2026-07-24 06:08:13

Full suite 1551 passed, ruff clean.

fix(cli): drop the clock from the keel tui header (#144)

Follow-up to #143. Removes the live date/time from the keel tui header — it's now just:

keel · paper mode

Event timestamps elsewhere (a position's opened_at, autonomy lapse times) still render human-readably via _human_dt; only the header clock is gone. Full suite 1550 passed, ruff clean.

feat(cli): show short version in the keel tui header (#145)

The keel tui header now shows a short version tag:

keel v0.1 · paper mode

v<major>.<minor> derived once at import from the package version via the lightweight metadata reader (no git subprocess per repaint); falls back to v? for an unknown/unparseable version. Full suite 1557 passed, ruff clean.

feat(cli): live "available to buy" balance in keel tui (#146)

Stacks on #145 (branch is a descendant of feat/tui-version-header, so it merges cleanly after #145; until then the diff shows #145's one-line change too).

What

The keel tui dashboard now shows the real account's spendable quote-currency balance (e.g. USDC) available to fund buys, refreshed periodically so a deposit / sell / buy is reflected:

live account: 65.72 USDC available  (2026-07-24 06:20:11)

Reuses executor._fetch_available_quote — the exact live balance rail 13 funds a buy against — so the TUI and the rail never disagree.

How

  • AvailableBalance + pure _available_lines (an ok line, or a warn "unreadable" line). Rendered via build_screen's new keyword-only available param — default None, so --once and every existing caller stay network-free.
  • run_live refreshes the balance on a slow cadence (30s, not every repaint), on r, and after f (a fetch is a "refresh everything" gesture — a deposit that lands alongside new candles shows up at once).
  • Fail-soft: any broker/network/credentials error becomes a warn line, never crashes the loop; None (no readable balance) is shown as "unreadable", never a false $0.

Robustness (from independent review)

  • Bounded 10s broker timeout for this path (new optional _build_broker(timeout=) kwarg; default None → agent/executor path unchanged) so a hung get_accounts can't freeze the TUI.
  • Balance fetch runs after the first paint, so startup is never a blank screen.
  • Error text truncated (defense-in-depth) before painting.
  • Labeled "live account: … available" (not "available to buy") so it isn't confused with paper buying power in paper mode.

Testing

  • +9 tests (pure _available_lines, build_screen placement with/without balance, _refresh_balance value/None/raising/truncation). Full suite 1566 passed, ruff clean. keel tui --once verified network-free.

Design: docs/superpowers/specs/2026-07-24-tui-dashboard-design.md (§ v3).

chore(release): bump version 0.1.0 → 0.2.0 (#147)

Version bump to 0.2.0 across the root distribution and all workspace members (keel-core, keel-broker-api, keel-broker-coinbase, keel-broker-fake) + uv.lock, in lockstep. The release.yml workflow asserts pyproject.toml already equals the requested version, so this PR is the prerequisite for cutting v0.2.0.

After merge, keel --version reports 0.2.0 and the TUI header reads keel v0.2 · <mode> mode.

⚠️ This is a software version bump, not a go-live

It does not change trading behavior:

  • The 5-trend turtles remain status=paper (mid paper-forward proving).
  • No live rules are armed (DCA disabled; SOL/LTC/LINK candidate).
  • Going live still requires the paper-forward to clear the n=100 promotion gate → a deliberate keel rules promote … --to live → a supervised confirm-mode run. None of that is part of this PR.

What 0.2.0 marks (headline features since 0.1.0)

Paper-mode fidelity + funded paper-forward enablement; keel status; the interactive keel tui (live read-only dashboard → browsable help, gated autonomy toggle, fetch, refresh, human-readable time, short-version header, and a live "available to buy" account-balance line).

Release steps after this merges (operator)

  1. Run release.yml (workflow_dispatch, version 0.2.0) → tags v0.2.0, stamps _build_info.py (DIRTY=False), builds wheels.
  2. On the machine: git fetch && git checkout v0.2.0 && uv sync.
  3. keel migrate (idempotent, schema-only, safe on the live DB).
  4. Rules are already seeded — do not re-seed. Verify with keel rules list / keel status.
  5. Reload the paper-forward launchd job only if its wrapper/paths changed.

Full suite 1566 passed, ruff clean.

fix(release): grant pull-requests:read so the notes step stops 403ing (#148)

The failure

The v0.2.0 release run failed at Compose release notes (run):

repos/.../commits/<sha>/pulls → 403 "Resource not accessible by integration"
… then: Get "repos/.../pulls/{"message":"Resource not accessible…}" : unsupported protocol scheme ""

Root cause

The step reads merged PRs via gh api .../commits/<sha>/pulls and .../pulls/<n>, which require pull-requests: read. The workflow only granted contents: write, so the default GITHUB_TOKEN was denied (403). The 403 error body then got written into the PR-number list and fed back into a URL, producing the unsupported protocol scheme crash.

Fix

  • Add pull-requests: read to permissions (the root cause).
  • Defense-in-depth: only append bare numeric PR numbers to the list, and skip any non-numeric entry when fetching — so a future API hiccup can't crash the notes step the same way.

YAML validated locally. No code paths touched — workflow only.

⚠️ Recovery needed before re-running (the tag already exists)

The failed run's Tag step already pushed v0.2.0 (at the #147 merge commit), but no Release was published (the notes step died first). The workflow's own "tag already exists — immutable" guard will block a re-run until the orphan tag is deleted:

git push origin --delete v0.2.0     # remove the orphan tag (no Release was published)
git tag -d v0.2.0                    # local, if present

Then, after merging this PR, re-run the release workflow (workflow_dispatch, version 0.2.0).