keel v0.2.0
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
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.yamlin 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-configwrites aconfig.yamltemplate shipped inside the wheel.keel initscaffolds a working directory: config + seed the strategy (rules) library.keel rules seed --statuscan seed atcandidate(default),paper, orlive;livebypasses 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.
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_modere-reads the profile every cycle, sokeel autonomy offbinds 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 drivingrun_oncedirectly cannot obtain autonomy the CLI would refuse. - Not scriptable to enable —
autonomy onrequires a terminal.offdeliberately 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-USDthroughout this codebase (_default_sim_products,keel fetch,keel simulate), whilequote_currencyisUSDC. 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 fetchthat 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-USDspends USD); config.quote_currency— one global setting (defaultUSDC).
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-USDorder → 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;Nonefor 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.yamlis the source of the shipped wheel template and should never carry one machine's live settings — I had committed mine by accident withgit 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.4in the root project and all four workspace members. Leaving a member at>=3.12would give it a laxer floor than the workspace actually resolves against.ruff.toml(new) holdsline-length,target-version = "py314", and theE,F,I,UP/ignore = ["UP042"]lint config. The[tool.ruff]block inpyproject.tomlis deleted, not duplicated — aruff.tomltakes precedence over[tool.ruff], so keeping both would leave the pyproject copy silently dead.[tool.mypy] python_version→"3.14"to match..python-versionanduv.lockboth 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_cfgstay patchable askeel.cli.X—cli.pyre-imports them and every caller of these remains incli.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 tokeel.commands._common._is_interactive.
Verification
- ✅
ruff check .clean - ✅
mypy keel packagesclean - ✅
pytest— 1322 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 toexecutor.execute/_handle_exitson the live path.- A new
reported_mode = "paper" if paper_trader is not None else modedrives the log event andLoopResult.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.yamlnow printsmode=paper;--config config.local.yamlstill printsmode=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;keeksAPI reference; and educational commands to explore the rules & strategies.README.md— sources-log row 84 + a new module-map row forexecution/sizing.py.analysis/bankroll_sizing/— stdlib-only sizing formulas + Monte-Carlo (simulate.py) + 38 unit tests (all pass viauv 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 passeduv run python docs/superpowers/analysis/bankroll_sizing/simulate.pyregenerates 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 parameterizedWorkflowscript (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 passeduv run ruff check docs/superpowers/analysis/bankroll_sizing/→ cleanuv run python docs/superpowers/analysis/bankroll_sizing/explore_leads.pyregenerates 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
liverule'sdetect()→ rails → preview → confirm/autonomy gate →place_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=0instead 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-rowrulesupdate 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--criterionis aclick.Choicerestricted to this set, andscreen_assetonly 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-USDbars ≥ 1460 it becomes inert automatically. - Ungated like
assets attest(an exception cannot itself place an order; the 17guards.pyrails still enforce every trade). Revocable viaunexempt(a de-risking action). Shown inassets 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:
exempt→screenADMITs with WAIVED; bad--criterionrejected;listshows it;unexemptreverts to REJECT. - Mutation-checked the
WAIVABLE_CRITERIAguard (emptying it fails the positive-path tests).
uv run pytest -q → 1399 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-identicalkeel/templates/config.yaml+keel/templates/config.live.yaml— updated in lockstep. Dev staysmode: paper, live staysconfirm; 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 -q → 1410 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):
- Rail 11 (the drawdown circuit breaker) was inert in paper — the agent hard-set
equity_now = Nonein paper mode, soupdate_drawdownnever ran and the breaker read a frozen 0. A catastrophic drawdown could run a paper strategy into the ground uncapped, polluting the OOS record. - 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_exposureproxy. - Seeded once from real equity at start; loop is broker-free thereafter.
- Observability via
LoopResultfields +_print_loop_result+ anagent.paper_equitylog event (a dedicatedkeel statuscommand 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 inagent.py. - Non-goals (Phase-4): fix the live executor's
$5k-proxy sizing to use real equity; unifySimAccountand the paper account. - Minor cleanups: dead
paper_ledger_start_tsfield, 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 computedHALTED / ok / unknownbreaker status;paper_cash_usdcin 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--jsonflag (stable machine-readable shape, forward-compat with the eventual TUI). Matches the codebase's logic/CLI separation. - Rail 11 display deliberately shows
unknownfor unwritten scalars rather than defaulting to0/"ok" likeguards.py's veto path — a dashboard must not misreport an uncomputed value as a confident all-clear.guards.pywas read for reference only, not modified.
Tests
- 18 pure-function tests + 2
CliRunnertests (incl.--jsonparses 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_styleare pure functions of theStatusReport; the curses I/O (_paint/run_live) is a thin layer tested against a fake stdscr. - No new dependency: stdlib
cursesonly, in keeping with the project's hand-rolled ethos (norich/textual).cursesis 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:
--oncerenders one frame to stdout (pipes/CI);--interval Nsets cadence; the live loop re-opens the repo each poll (so it reflects a separatekeel agent's committed writes) and survives a transient DB read error (e.g.database is lockedfrom the writer) by painting an alert line instead of crashing.
Testing
- 39 new tests (
tests/commands/test_tui.py); full suite 1519 passed,ruff checkclean. - Includes regression guards for two issues an independent review caught: colour was silently disabled (missing
use_default_colors()→-1background illegal undercurses.wrapper), and the live loop crashing on a transient SQLite lock. keel tui --onceverified against the realkeel.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) intui_cmdbefore entering curses; if not, raise aClickExceptionpointing the user atkeel tui --once. - Belt-and-braces:
run_livealso catchescurses.errorraised bycurses.wrapperitself — the case where a terminal passesisatty()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_interactiveboth-TTY logic;keel tuiunder CliRunner exits cleanly with a--oncehint;run_livewraps acurses.errorfromwrapperas aClickException). - Full suite 1522 passed,
ruff checkclean. - Verified: piped
keel tuinow printsError: 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.
- Human-readable timestamps —
now=, positionopened_at=, and autonomy lapse times now render asYYYY-MM-DD HH:MM:SSlocal time (freshness keeps relative "4h ago"). - Keybinding hint bar + a browsable, scrollable help overlay (
h/?; arrows /j/k/ PgUp / PgDn / Home / End;Esc/hto close). - Actions, each with the same asymmetric safety gating the CLI already enforces:
atoggle autonomy — turning OFF is instant (de-risk, ungated); turning ON suspends curses and runs the same typed-yesgate askeel autonomy on, showingmode+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.ffetch-all-data — money-safe (ensure_historypulls candles, places no orders); shows a progress frame + result toast.rrefresh-now.
- 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 checkclean. keel tui --onceverified against the realkeel.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(anokline, or awarn"unreadable" line). Rendered viabuild_screen's new keyword-onlyavailableparam — defaultNone, so--onceand every existing caller stay network-free.run_liverefreshes the balance on a slow cadence (30s, not every repaint), onr, and afterf(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
warnline, 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; defaultNone→ agent/executor path unchanged) so a hungget_accountscan'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_screenplacement with/without balance,_refresh_balancevalue/None/raising/truncation). Full suite 1566 passed, ruff clean.keel tui --onceverified 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)
- Run
release.yml(workflow_dispatch, version0.2.0) → tagsv0.2.0, stamps_build_info.py(DIRTY=False), builds wheels. - On the machine:
git fetch && git checkout v0.2.0 && uv sync. keel migrate(idempotent, schema-only, safe on the live DB).- Rules are already seeded — do not re-seed. Verify with
keel rules list/keel status. - 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: readtopermissions(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 presentThen, after merging this PR, re-run the release workflow (workflow_dispatch, version 0.2.0).