keel v0.4.0
Built from dc99601. Version binds to this hash:
keel --version reports keel 0.4.0+dc9960153fee [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.4.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.
Docs, CI & tooling
Make the live deployment's config and rule set reproducible (#161)
A rebuilt keel deployment would not come back the same. Two independent gaps, both silent.
The rule set is not in version control
A deployment's strategy library lives in the database, not in any config file. An in-place upgrade preserves it — the schema steps are additive (CREATE TABLE IF NOT EXISTS rules) and keel migrate never seeds. A fresh deployment does not: keel init seeds every rule at candidate from each rule kind's constructor defaults.
So the live DCA rule, deliberately sized to budget_usd: 25, comes back on a rebuilt box as the built-in 50, unpromoted — twice the money, on a machine that otherwise looks correctly provisioned. Nothing errors, and nothing in the repo records that 25 was ever the intent.
deploy/live-rules.json is now the committed snapshot of the live rule set. scripts/rule_manifest.py export regenerates it; apply rebuilds a database from it.
Two refusals, both deliberate:
- It never rewrites an existing rule's params. Matching is by
(kind, product_id)— the same keykeel rules seeduses — and a rule that exists with different params is reported as DRIFT with the run exiting non-zero. A manifest that could resize a live rule by file edit would route around exactly the review the promotion ladder exists to force. - It refuses to create
liverules without--allow-live, warning loudly per rule when it does. Seeding straight toliveis the same gate bypass askeel rules seed --status liveandrules promote --force, so it reads like them.
Dry-run is the default; --apply writes. Promotion stays keel rules promote's job.
Eight tests, including one that asserts the checked-in manifest still rebuilds and still says 25 — so a stale manifest fails CI rather than a future deployment.
The operator configs were git-ignored
config.live-sandbox.yaml, config.paperforward.yaml, both run scripts, both launchd plists and the keel-live/keel-paper wrappers were ignored (c564db7 and earlier) as "operational, never shipped". Both halves are still true — but "never shipped" is enforced by the packaging config, which ships only keel/, not by .gitignore. Ignoring them bought no shipping guarantee and cost the one that matters: the only copy of the running deployment's configuration lived in ~/keel on a single laptop. No history, no offsite copy. The nearest thing to a backup, ~/keel/.backup-20260728/, is already stale — it predates the 2026-07-29 switch to logging.verbose: true on the live side.
A fresh deployment cannot reconstruct this from the release either: the release asset config.yaml is keel/templates/config.live.yaml, a different file with a different allowlist and different caps than the sandbox actually in use.
They hold no credentials — those stay in .env, still ignored. Verified by grep across all eight before staging. The repo is private, so this exposes the caps and allowlist to nobody who cannot already read the code.
Also documented
Two things that each cost a failed command to discover, now in the go-live runbook:
- A DCA rule can never clear the promotion gate. It has no stop and no target, so
backtest()opens a position that never closes; every trade staysopenand the aggregates seen_trades=0againstmin_trades: 100. A DCA go-live isrules promote <id> --force, twice — structural, not a judgement call. - It needs
--granularity ONE_DAY, becauseDcanever setsself.granularitythe wayTurtleBreakoutdoes.
Verification
- Rebuilt a fresh DB from the committed manifest and read it back through the agent's own
_build_rule: all six rules, correct statuses,budget_usd = Decimal('25'). - Dry-run against the real
keel-live.db:in sync: 6 rule(s). - Full suite 1678 passed; mypy clean on 197 files; ruff clean.
The manifest currently records the DCA rule as candidate, which is its actual state — the --force promotions are still pending.
Record the BTC DCA rule as live in the deployment manifest (#162)
One-line manifest change, regenerated from keel-live.db rather than hand-edited:
"kind": "dca",
- "status": "candidate",
+ "status": "live",Rule 6 (dca, BTC-USD, budget_usd: 25, weekly cadence) was promoted candidate -> paper -> live on 2026-08-03.
Why it needed --force, twice
A DCA rule has no stop and no target. backtest() opens a position that never closes, so every trade stays open, the aggregates exclude it, and the promotion gate sees n_trades=0 against min_trades: 100. It cannot clear the floor — not with more history, not ever. That is the structural case promote --force documents, now also written down in the go-live runbook.
(It also needs --granularity ONE_DAY on the gated path, because Dca never sets self.granularity the way TurtleBreakout does. Moot under --force, which skips the backtest.)
Why this file matters
budget_usd: "25" is the entire point of the manifest. keel init on a rebuilt box seeds from constructor defaults and would bring the rule back at 50, unpromoted — twice the money, on a machine that otherwise looks correctly provisioned.
Context
This is keel's first live rule that fires on a schedule rather than on a market setup. The five turtles have been live since go-live and have never signalled once: price has sat 3–27% below the 40-day Donchian entry on every product, closest being PAXG at 3.09%.
Rail 17 (withdrawal capability) was attested --enabled the same day, and that is what actually unblocked BUYs. It had been failing closed since go-live and would have vetoed the first turtle breakout too — the rails had simply never been exercised against a real signal. That attestation expires after 7 days and is deliberately not automatable, so it is account state, not repo state, and does not belong in this file.
First order fires on the next cadence boundary, Thu 2026-08-06, and only at a terminal — the launchd job runs headless in confirm mode and fails closed without a TTY.
Other changes
docs(research): can keel trade Coinbase's new asset classes? (#163)
Question
Coinbase now lists futures, perp-style futures, equity/thematic indices, commodities and US equities alongside spot. Can keel trade all or some of them?
Answer
No — none of them today. Only the crypto-futures family is even a candidate for a future build.
The document records a live, read-only probe of GET /api/v3/brokerage/products and the market-data/preview endpoints, run against the deployment's own CDP key on 2026-08-05, plus a file-by-file audit of what keel assumes.
| Class | On the API? | Market data? | Order path? | Verdict |
|---|---|---|---|---|
| FX | ✗ none exist | — | — | Nothing to evaluate |
| US equities (venue CCM) | ✓ 1000 products | ✗ no candles, 500s on book/trades | ✗ preview refused by design | Blocked by the venue — not buildable |
| Crypto futures + perp-style | ✓ 99, all EXPIRING |
✓ verified OHLCV, book, trades | ⚠ 403 pending CFM onboarding | Buildable, ~35–65 eng-days, halal unresolved |
| Commodities / indices | ✓ same venue | ✓ | ⚠ same 403 | As above, plus session handling; worst halal position |
Three distinct blocker classes are separated deliberately, because they cost very different things: account paperwork (CFM onboarding), venue capability gaps (equities), and keel architecture gaps (13 areas — instrument identity, position/margin model, direction model, lot rounding, expiry/roll, 24-7 assumptions).
Incidental finding worth acting on
guards._asset("ADA-28AUG26-CDE") returns "ADA", which is allowlisted on the live config — so rail 1, the un-overridable halal gate, passes a futures contract today. Only rail 13 catches it, and rail 13 is BUY-only and skipped in paper mode. keel refuses these products by accident (it never constructs such an id), not by design. The doc recommends closing this regardless of whether anything else is ever built.
Scope
Documentation only. No code changed, no product admitted, allowlist unchanged. create_order was deliberately never exercised against equities — that would have placed a real order.
feat(guards): add rail 18, the settlement-currency hard rail (#164)
Implements R1 from docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md.
The hole
The study found by execution that keel's rails can be fooled by a non-spot product id:
guards._asset("ADA-28AUG26-CDE")returns"ADA"— which is allowlisted on the live config.- So rail 1, the un-overridable halal allowlist gate, passes a Coinbase futures contract.
- The only thing that stopped a live BUY was rail 13, incidentally (
quote_currency_ofreturns"CDE", no such balance exists, fails closed). But rail 13 is BUY-only and is skipped entirely in paper mode. - Verified: a SELL of
ADA-28AUG26-CDEpassed every rail on the real live config.
The fix
Rail 18 — settlement-currency. Vetoes any intent whose quote_currency_of(product_id) is not in config.settlement_currencies (new field, default {USD, USDC}).
It is the only rail that gates the instrument class rather than the trade, and it does so without keel needing an instrument model it doesn't have: quote_currency_of returns "CDE" for every futures contract and None for every equity id (a 64-char hash), so one comparison rejects both classes.
Deliberately not in LIVE_STATE_RAILS — it runs on both sides in every mode, because it needs no broker and no account state. That is the entire point: paper-exemption is what left the hole open.
Verified against the real ~/keel/config.live-sandbox.yaml:
| product | SELL/offline | SELL/live | BUY/offline | BUY/live |
|---|---|---|---|---|
ADA-28AUG26-CDE |
VETO | VETO | VETO | VETO |
GOL-25NOV26-CDE |
VETO | VETO | VETO | VETO |
| equity hash id | VETO | VETO | VETO | VETO |
ADA-USD |
passes | passes | passes | passes |
Accepted behaviour change
With the default set this also rejects ~120 non-USD/USDC spot pairs Coinbase lists (BTC-EUR, ETH-GBP, *-USDT). Nothing in the live deployment reaches one — every rule is BASE-USD and all three configs set quote_currency: USD — and settlement_currencies is the escape hatch. Flagged in the rail's comment so it doesn't read as an oversight.
Note on the venue reconciliation
_reconcile_settlement_currencies checks the configured set against the adapter's declared BrokerCapabilities.quote_currencies, but it is dormant on the live path today: the live broker is data.cb_client.CoinbaseClient, which declares no capabilities (the declaration lives on the not-yet-wired CoinbaseAdapter). It activates when the broker port migration lands. Rail 18 does not depend on it. This is stated in the docstring rather than left to be discovered.
Verification
1697 tests pass (1678 before + 19 new), ruff check . clean. TDD order held — new tests were run failing first.
chore: bump to 0.4.0 for the rail 18 release (#165)
Version bump only — no code change.
The release workflow deliberately refuses to set the version itself, so this is the reviewed PR that decision belongs in (docs/RELEASING.md).
Why minor, not patch: rail 18 (#164) is a new un-overridable hard rail, adds a settlement_currencies config field, introduces a load-time ConfigError for a config whose quote_currency falls outside that set, and changes admitted behaviour — non-USD/USDC spot pairs are now vetoed.
Why it matters now: ~/keel is still running 0.3.2. I verified directly against the deployment's venv:
deployment has settlement_currencies field? False
deployment guards has rail 18? False
So the futures/equity hole rail 18 closes is still open on the live path. Cutting 0.4.0 is what actually gets the rail there.
After merge: Actions → Release → Run workflow with 0.4.0, then install the wheels by path into ~/keel/.venv and keel migrate. No schema change in this release, and the three deployment configs need no edits — all set quote_currency: USD and pick up the new field's default.
chore: relock at 0.4.0 so the release build is not dirty (#166)
Fixes the failed 0.4.0 release (run 31032617294).
What failed
keel 0.4.0+eae6c0437fdd (DIRTY) [release]
::error::artifact reports a dirty tree
Lint, tests, stamp and build all passed. Only the self-identification check failed.
Why
#165 bumped all five workspace members to 0.4.0 but left uv.lock pinning 0.3.2 — my miss. CI's Sync dependencies step relocks to match, which modifies a tracked file. By the time Verify the artifact identifies itself runs keel --version inside the checkout, git status --porcelain is non-empty.
version.build_info() deliberately consults git even when a stamp is present:
⚠️ A STALE stamp in a working checkout would otherwise claim[release]and hide a dirty tree — which is precisely the misreport this module exists to prevent. If git is present and disagrees with the stamp, believe git.
So this was the guard working correctly. The stamp said DIRTY = False honestly; the tree genuinely was dirty. Worth noting keel/_build_info.py and dist/ are both gitignored, so neither the stamp nor the build output can trip this — the lock was the only tracked file involved.
Fix
uv lock, committed. Reproduced locally first: running it on main produced exactly M uv.lock, and it moves all five members 0.3.2 → 0.4.0. No 0.3.2 remains in the lock.
No tag was created (the workflow tags after verification), so re-running Release with 0.4.0 after this merges is clean — no tag to delete.
1698 tests pass.