keel v0.5.6
Built from 9d7dede. Version binds to this hash:
keel --version reports keel 0.5.6+9d7dedeb5b64 [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.5.6-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.
Other changes
docs(config): correct the "unvalidated skip set" claim on the live allowlist (#185)
What
Re-assessment of a judgment carried in a live-money config, config.live-sandbox.yaml, directly above the real-money allowlist:
# The 5-trend Turtle set (same as the paper-forward + the walk-forward/PBO analysis).
# SOL/LTC/LINK are deliberately excluded -- they're the unvalidated skip set.
allowlist: [BTC, ETH, PAXG, ADA, XLM]Verdict: the decision is right; the stated reasons were mostly wrong. The comment is rewritten to say what's actually true, and the working is recorded in a new experiment doc. The allowlist is unchanged.
What the old comment got right
"Same as the paper-forward" is accurate at the rule level — keel.db's paper-status rules are exactly 10/11/12 (BTC/ETH/PAXG) + 22 (XLM) + 24 (ADA), i.e. the live 5. That's a coherent origin story for the exclusion and the PR now credits it.
What it got wrong
- "The walk-forward/PBO analysis" describes work that does not exist for these assets. Every walk-forward/PBO/ablation run in
docs/experiments/stops at BTC/ETH/PAXG.trials-ledger.jsonlsymbol census over 69 rows: BTC 32, ETH 33, PAXG 32, and 0 each for ADA, XLM, SOL, LTC, LINK. The walk-forward that exists tuned a parameter (turtle_breakout.py:113,entry_lookback20 → 40). - "Unvalidated" doesn't distinguish anything. None of the five live assets was validated either.
keel-live.dbrules 1–5 carrypromoted_at IS NULL— seeded straight tolivethrough the bypassrules seedwarns about, and left in place 14 days. - No compliance asymmetry. SOL/XLM/LTC/ADA were attested in the same second (2026-07-23 12:15:12); LINK 160s later under the same governing source. All three excluded assets ADMIT
keel assets screenand carry more daily history (1827 bars) than PAXG (456, on the list only via a documented waiver). - Wrong gate. The comment attaches a rule-level reason to an asset-level control; rail 1 (
guards.py:369) is a flat membership test consulting neither attestations nor rule status.
The measurement nobody had run
The old comment guessed. Running keel rules backtest against a copy of keel.db settles the direction:
| rule | asset | n_trades | win rate | expectancy | pf |
|---|---|---|---|---|---|
| 21 | SOL | 13 | 23.08% | −12.41 | 0.259 |
| 23 | LTC | 5 | 0.00% | −13.09 | 0.000 |
| 25 | LINK | 15 | 20.00% | −0.665 | 0.630 |
| 22 | XLM | 8 | 37.50% | +0.056 | 11.44 |
| 24 | ADA | 6 | 50.00% | +0.121 | 5.52 |
All three excluded assets are negative-expectancy; LTC won none of five. The exclusion is correct — now for a reproducible reason instead of an invented one.
Stated honestly: n = 5–15 against min_trades = 100, in-sample, one window, candles 6 days stale for the excluded three. A directional check, not validation. None of the five clears the gate — which is why the allowlist is unchanged, and why the new comment explicitly warns against reading a positive expectancy as licence to add an asset.
What this PR does NOT do
The allowlist is unchanged. No asset attested, no rule promoted, no production database written — backtests ran against a scratchpad copy; ~/keel/keel.db's mtime is untouched.
Verification
load_configyields an identical allowlist, caps and weights; a comment-stripped diff againstmainshows no non-comment differences.python -m pytest tests/ -q→ 2078 passed. (uv run pytestfails to importkeel_coreon a clean tree too — pre-existing, unrelated; this diff touches zero Python.)
Review corrections (second commit)
An independent adversarial review caught three false/unsupported claims in the first commit, two of them in the live config:
- "Nothing has traded" was FALSE —
keel-live.dbholdssignals=1, a filled live order (BTC-USD BUY 0.000778 @ 64267.30) and an open position from the properly-promoteddcarule 6. The deployment is not inert and carries exposure. Removed. - The
min_tradesargument used the wrong mechanism —rules promotegates on a candle backtest (commands/rules.py:180), nottrade_outcomes. Conclusion unchanged, now resting on ADA n=6 / XLM n=8. - "Never been backtested" was unprovable and self-contradicted the doc's own point that the
backteststable is dead schema (db.py:140, zero writers). Dropped.
Plus: credited the true paper-forward half, softened "same source" for LINK (its attestation deliberately classifies it a utility token), and made the entry_lookback quote verbatim.
Open operator items
Recorded in the doc, not actioned here:
- Deployment drift —
~/keel/config.live-sandbox.yamlstill has the old comment; sync it. keel-live.dbcarries almost no compliance state — screening the live allowlist against a copy, 0 of 5 admit; SOL is ironically the only asset it admits. Harmless (rail 1 reads config, not the DB) but inverts the comment's picture.- Live-seeded rules left in place 14 days, against
rules seed's own instruction. - Stale candles — re-run the backtests after
keel fetchbefore relying on them. - Two open attestation questions — LINK's staking floor is emissions-funded; LTC has carried MWEB (opt-in confidential transactions) since May 2022.
docs: correct the SOL attestation note -- the staking page supports pays_yield=0 (#186)
Follow-up to #185, correcting a claim I made there.
The correction
Open item 3 said the live-DB SOL attestation's source (solana.com/staking) "sits awkwardly against pays_yield=0". Fetching the page shows the opposite — it is direct evidence for the field:
"In order to earn staking rewards … the tokens in a stake account must be delegated to a validator."
No rebasing, no automatic distribution to holders. Bare holding earns nothing, which is exactly what pays_yield=0 asserts.
The gap that is actually there
It is the wrong kind of source, not a contradictory one. AssetAttestation.source is documented as "where this was established — a URL, a standard, a scholar's ruling." A vendor product page establishes the mechanical fact; it cannot establish the ruling that a native L1 coin is Mal Hukmi, lawful to own and trade.
Every keel.db attestation carries both — KB source-86, the three-layer screen reasoning, and an explicit "Attests bare unstaked spot only." This row carries only the mechanical half, and only implicitly. screen_asset cannot tell the difference: screen.py:249 checks source.strip() is non-empty and nothing more.
Provenance — closed, not left as "should be confirmed"
~/.zsh_history records the exact command at epoch 1786148982, one second before the row's attested_at:
./.venv/bin/keel --config config.live-sandbox.yaml --db keel-live.db \
assets attest --asset SOL --sector "layer-1 smart-contract blockchain infrastructure" \
--backing native --source "https://www.solana.com/staking" \
--attested-by "Elmehdi Aitbrahim"
Preceded by a placeholder attempt at 1786148907 and two keel fetch --products SOL-USD runs. Hand-typed at the terminal — not an agent, not the daemon, not the #185 investigation.
Also added
- SOL now has two attestations with different evidentiary bases; which one governs depends on which
--dba command runs against. - The page notes ~8% initial inflation decaying to 1.5%, so a bare holder is diluted rather than paid — not a riba yield, but whether that makes staking economically quasi-compulsory is a scholarly question, not one this file can answer.
- None of it is live-reachable: SOL is not in the live allowlist and has no rule in
keel-live.db, so rail 1 blocks any SOL intent regardless of the attestation.
Docs-only. No code, no config, no database touched.
docs: record the live-seeded rules as a deliberate, bounded standing exception (#187)
What
keel-live.db rules 1–5 (turtle_breakout on BTC/ETH/PAXG/ADA/XLM) have carried status = live with promoted_at IS NULL since 2026-07-24 — against rules seed's own printed instruction:
⚠️ seeded at LIVE status, bypassing the promotion gate … Do not leave live-seeded rules in place afterwards.
Reviewed 2026-08-08. Kept deliberately. Now recorded rather than silent.
This applies the standard the project already set for PAXG in docs/experiments/2026-07-20-allowlist-screen-first-run.md:
The one thing not to do is leave it silently exempt because it was already there.
The exception was already load-bearing — it just wasn't written down anywhere.
Why keep them
min_trades is 100 per rule, and this strategy cannot reach it. Measured by backtesting each rule over ~5.02 years of daily bars:
| rule | trades | rate | years to 100 trades |
|---|---|---|---|
| BTC | 13 | 2.59/yr | ~39 |
| ETH | 13 | 2.59/yr | ~39 |
| XLM | 8 | 1.59/yr | ~63 |
| ADA | 6 | 1.19/yr | ~84 |
| PAXG | 4 | 3.20/yr | ~31 |
Waiting for the gate is not a slower route to the same destination — it is no route. The sandbox exists to accumulate the live evidence the promotion gate demands and cannot itself generate. Demoting these rules would end that experiment without putting anything in its place.
What bounds the risk, since the gate didn't
The caps (max_exposure_usd 200 total at once, max_per_order_usd 100), the eighteen un-overridable guards.py rails, rail 1's allowlist, and rail 14's monthly allowance.
The bypass is of the evidence gate, not the safety rails — separate mechanisms, and only the first was skipped.
What it is not
Not a precedent for admitting assets. Not a reason to raise caps. Not a claim these rules are validated — no walk-forward or PBO run has ever covered ADA or XLM, and none of the five clears any promotion axis (see #185).
Revisit triggers
Named, rather than an open-ended pass: caps rising above this sandbox's few-dollars-of-damage scale; the rule set changing; a rule trading materially more often than the table above; or the sandbox being treated as evidence for anything beyond itself.
Where it's recorded
Two places an operator actually reads:
config.live-sandbox.yaml— a block beside the allowlist it applies to.docs/go-live-runbook.md§3 — a new "Standing exception" subsection. §3 previously documented the act of short-circuiting the gate but not the ongoing state.
Verification
load_configyields an identical allowlist, caps and weights; comment-stripped diff againstmainis empty.python -m pytest tests/ -q→ 2078 passed.- No database written, no rule status changed, no config value altered.
docs(experiments): build the §80.16 independence harness; measure cross-asset (#188)
What
research/independence.py has implemented §80.16's five measurements since PR #103, but nothing in the repo drove them over two real rules. The 2026-07-20 cross-horizon run was ad hoc and left no script behind, so every future between-family question started from zero. This adds the script, plus its write-up and three diagnostic_only ledger rows.
Three passes
| pass | varies | mean P&L corr (closed) | outcome |
|---|---|---|---|
between-family (turtle vs rsi_meanrev) |
rule family | — | degenerate, arm B never trades |
| cross-horizon (40/20 vs 80/40) | lookback | 0.508 | reproduces PR #103 exactly |
| cross-asset (turtle vs itself, other asset) | the underlying | 0.011 | the finding |
The between-family pass is degenerate, not low. On daily bars rsi_meanrev takes zero trades on all five allowlisted assets — oversold=20 on a daily RSI(14) is near-unreachable (PAXG and XLM never print one). Arm B is settled-refuted anyway (§74.3), so it could never have tested candidacy; it is a shakedown.
The calibration is why the rest is trustworthy. BTC 0.802, ETH 0.934, median entry gap 0 days — exact reproduction of 2026-07-20-horizon-independence.md. It also settles which P&L convention PR #103 used, which that write-up never states: closed reproduces the published figures; mark-to-market gives 0.813 / 0.759.
Cross-asset is the finding. 0.011 / 0.175 / 0.144 against cross-horizon's 0.508 / 0.585 / 0.510, same method, same assets. Asset breadth is roughly an order of magnitude better than horizon breadth as a source of independent evidence. §79.1 redirected the frequency plan to breadth without saying which; this says which. PAXG stands out — zero Jaccard against ETH, ADA and XLM.
This also tests an assumption 2026-07-20-minbtl-sizing.md's allowlist case rested on: that added assets multiply trades/year roughly linearly, true only if those trades carry independent information. At the measured correlations they largely do.
Confidence
Arm A reproduces the shipped trade counts exactly (13/13/4/6/8), so the harness is wired to the real rule. Correlations rest on 4–13 trades per asset with no confidence intervals — the direction is far more robust than any individual cell. Caveats are in the write-up, including that this window had crypto broadly trending together (§79.16).
Ledger
Three rows, all diagnostic_only / a_priori, keel trials verify → chain intact. M 69 → 72, N_decisions unchanged at 30, so MinBTL is untouched. The cross-asset ρ̄ is deliberately not substituted into §78.2's N̂, which corrects a trials count and is a different quantity.
Scope
docs/experiments/ only — no library code, no config, no rule, no gate." That was true when written and false at merge: a second commit was pushed to this branch before it squashed, so a code change shipped inside this PR. Corrected here rather than left to mislead, because scripts/release_notes.py inlines this body into the 0.5.6 release notes.
The research half is docs/experiments/ only. ruff clean; docs/ is outside the project's mypy scope (files = ["packages", "keel", "tests"]), matching the existing 2026-08-05-coinbase-asset-class-probe.py.
Also shipped in this PR — fix(assets): the discovery pre-filter now measures what the gate measures
assets discover pre-filtered on the venue's reported 24h quote volume; assets screen admits on the median of volume × close over cached history. Same units, different statistics, and the gap changes outcomes in both directions:
- BICO was shortlisted on a reported $12.81M/24h, then rejected by the screen at a median daily volume of 108,004 — 9× under the floor, a 227× gap.
- DOGE was nearly dropped for sitting 1.7% below the $5M discovery floor when, on the admission criterion, it is ~30× above it.
screen.median_daily_quote_volume is now the single definition of the statistic — cli._market_facts had it inline, and a second copy next to discover would drift, which is the bug. assets discover --probe-liquidity samples 180 days of daily candles per candidate (one extra request, same cost model as --probe-history) and marks LOW/ok against the same floor screen_asset applies.
Against the live venue it catches BICO (36,137) plus two the old filter passed through: IMU (71,713) and GWEI (440,864) — three of the top fourteen by 24h volume are sub-floor on the real criterion.
An estimator, not a verdict, and the output says so: 180 days recent vs the screen's full cached history. ZEC probes 33.1M against a full-history median of 1.23M — a 27× spread from a recent surge. ok means "worth pulling candles for", never "will be admitted". No behaviour change to the gate: same criterion, same floor, same verdicts. Tests: 2137 passed, ruff and mypy clean.
chore: bump to 0.5.6 for the discovery liquidity probe (#189)
Bumps pyproject.toml and all four workspace members 0.5.5 → 0.5.6, plus uv.lock. Same shape as #184.
Why now
assets discover --probe-liquidity and screen.median_daily_quote_volume landed on main in #188 but are in no release, so the deployment — running keel 0.5.5+7b02bfd8fbbc [release] — cannot run them. The keel-asset-scout skill has been updated to make the probe the first step of every sweep, which fails outright against 0.5.5. This closes that gap.
What 0.5.6 contains
The discovery pre-filter now measures what the admission gate measures. assets discover filtered on the venue's reported 24h quote volume while assets screen admits on the median of volume × close over cached history — same units, different statistics, and the gap changes outcomes in both directions:
- BICO was shortlisted on a reported $12.81M/24h and then rejected by the screen at a median daily volume of 108,004 — 9× under the floor, a 227× gap between the two numbers.
- DOGE was nearly dropped for sitting 1.7% below the $5M discovery floor when, on the admission criterion, it is ~30× above it.
screen.median_daily_quote_volume is now the single definition of the statistic (cli._market_facts had it inline; a second copy next to discover would drift, and that drift is the bug). --probe-liquidity samples 180 days of daily candles per candidate — one extra request, same cost model as --probe-history — and marks LOW/ok against the same floor screen_asset applies.
Against the live venue it catches BICO (36,137) plus two the old filter passed straight through: IMU (71,713) and GWEI (440,864). Three of the top fourteen by 24h volume are sub-floor on the real criterion.
It is an estimator, not a verdict, and the output says so: 180 days recent vs the screen's full cached history. ZEC probes 33.1M against a full-history median of 1.23M — a 27× spread from a recent surge. ok means "worth pulling candles for", never "will be admitted". A recent window also makes a false LOW unlikely for a currently-active asset, which is the safe direction for a pre-filter.
No behaviour change to the gate itself — same criterion, same floor, same verdicts.
Verification
2137 passed,ruffclean,mypyclean across 203 source fileskeel --version→0.5.6+9dcccadb6939on this branch
Note on #188
#188 squash-merged the code change together with the docs/experiments harness, so its title and body describe only the research work. Its body is what scripts/release_notes.py will inline for 0.5.6, so it has been edited to describe the code change that actually shipped in it.