keel v0.9.2
Built from f7a0cdf. Version binds to this hash:
keel --version reports keel 0.9.2+f7a0cdf721a0 [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.9.2-py3-none-any.whl
keel versions
keel versions — not keel --version — is the check: it reports every
keel distribution in the venv and exits non-zero if a sibling was left behind at
an older version, which --version cannot see. Upgrading an existing
deployment: see "Deploying a new version" in the README.
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
feat(paper-hourly): expand the universe 8->19, Tier-2 capped at 2% target weight (#358)
Refs #351.
What
config.paper-hourly.yaml's universe grows from 8 to 19 assets. The 11 Tier-2 additions — ZEC NEAR AVAX UNI FET ICP DOT CRV ALGO BCH DOGE — each passed a 15-minute data-health screen on 2026-08-17 (coverage >= 95.98%, zero zero-volume bars over 90 days; results recorded in the issue).
Why a superset, not a mirror
The hourly profile's job is to collect promotion-gate evidence fast; more health-screened assets means more rows per hour. Paperforward (the daily profile) deliberately stays at 8 so its evidence remains a like-for-like 8-asset series — hourly is now a strict superset of paperforward's universe. The universe test was rewritten (not deleted) to pin exactly that, with the rationale in its docstring.
Sizing: the 78/22 split
- Incumbents keep their relative shape scaled to 78% total: BTC
.230, ETH/PAXG.155, SOL/XLM/LTC/ADA/LINK.048each. - Each Tier-2 addition gets a flat
2%(11 x 0.020 = 0.220). Total exactly1.000. - The 2% Tier-2 cap is a deliberate guardrail: the sizing half of the spread protection whose live-path half is #350's spread gate.
- Incumbent rules/params are unchanged, so their evidence stays comparable across the expansion; only their paper sizing rescales 1.00 -> 0.78 total.
Also
market_data:comment recomputed: 19 products x 2 series = ~38 requests per cycle (57 in the UTC-boundary hour); header's "same universe as paperforward" claim updated to "superset".- Runbook: the bootstrap loop seeds all 19 tickers, and a new paragraph records the 2026-08-17 expansion (health-screen gate, 78/22 weighting, paperforward staying at 8).
- Net-negative caveat and cadence mechanics prose untouched.
Gates
uv run pytest -q tests/test_paper_hourly_profile.py— 15 passeduv run pytest -q— 2951 passed, 1 skippeduv run ruff check keel tests packages— cleanuv run mypy— clean (238 files)- Config loads:
19 1.000
Note: #351 also covers the operational seeding/warm/verify steps that happen after this PR merges (bootstrap commands are in the runbook section this PR updates).
feat(executor): routing-time max-spread gate for live BUY entries (#360)
Fixes #350.
What
A live BUY whose previewed book shows (best_ask − best_bid) / mid at or beyond execution.max_entry_spread_pct (new execution: config section, default 0.005 = 50bp) is refused after the preview and before the confirm gate and placement — so a thin book cannot be entered at a moment its spread alone makes the fill economics materially worse than the cost model assumes. The refusal is recorded in ExecutionResult.vetoed_by (tokens max_entry_spread / book_unreadable, the same one-legible-token shape rail violations use) and logged at WARNING as a structured event (executor.entry_spread_refused with the measured spread, threshold and product; executor.entry_book_unreadable for the fail-closed arm).
This is the live-path half of the spread guardrail whose sizing half (#358) caps every Tier-2 addition at a 2% target weight.
Design decisions
- Post-preview placement, deliberately.
guards.checkis broker-less by design, and the book exists only in thebroker.preview_orderresult — so this is a routing-time gate beside the eighteen rails, not a numberedguards.checkrail (no renumbering anywhere). It consumes the same preview #332's_warn_if_market_routing_overrides_entryreads: one helper (_preview_book— bid/ask read independently, missing-keys/NaN/non-finite/non-positive-safe), two consumers. The #332 warning keeps its exact behavior and position; the gate runs after it, before confirm/place. - BUY-only. Exits, exit brackets, stop rolls and scale-outs are never gated — the same principle that makes rail 17 halt entries, not exits. A spread gate that trapped an exit would strand a position in exactly the book conditions the rule said to leave.
- Paper mode never runs the gate.
_paper_enterfills synthetically without a preview, so the paper-hourly profile accrues no evidence about this gate — a reason it ships before any live resumption, not before paper expansion. - Fail-closed on an unreadable book. A live BUY whose preview carries no readable bid AND ask (missing keys, NaN, non-finite, non-positive, or spread arithmetic that overflows — the #336 extreme-exponent hazard, refused here rather than swallowed as telemetry) is refused with the distinct
book_unreadablereason. "Cannot know" is a different fact from "too wide". Verified the real venue's preview carries both sides:cb_client.preview_ordermapsbest_bid/best_asktoDecimal(tests/fixtures/cb_preview_order.json), and the port'sPreview.detailcarries them as strings. - 50bp default anchored to #334's
SLIPPAGE_CAP_PCT: the backtest never assumes more than 50bp per-leg slippage on even the thinnest book, so a spread AT the cap has consumed the model's entire worst-case cost estimate and the taker fee rides outside it — hence the boundary is >= (fail-closed), unlike #332's strictly-greater visibility threshold. Validated on load to (0, 0.10]; violations raiseConfigErrornamingexecution.max_entry_spread_pct.
Test-side consequence (honestly reported)
The shared test fakes' bookless default previews modelled a shape the real venue does not return, and under a fail-closed gate every "normal successful BUY" test through them would refuse. The fakes (tests/execution/test_executor.py, tests/test_agent.py, tests/test_cli.py, tests/execution/test_reconcile.py) now carry both book sides, and the one #332 test that borrowed the default preview as its degraded/bookless shape (test_a_preview_without_a_book_quote_is_silent_not_fatal) constructs that shape explicitly — meaning unchanged, sourcing changed. The golden config fixtures were regenerated via the documented script; the defaults golden now pins execution.max_entry_spread_pct = 0.005.
Docs
docs/operator-runbook.md: the Tier-2 guardrail story — sizing half (#358's 2% cap) + live-path half (this gate), and why paper accrues no evidence about it.README.md+docs/fiqh-basis.md: the gate described beside the rails enumeration as exactly what it is (BUY-only, live-only, fail-closed); the "eighteen rails" count is unchanged because this is not a numbered rail.- Shipped config templates (
keel/templates/config.yaml+ repoconfig.yaml,config.live.yaml) document the knob.
Gates
uv run pytest -q— 2965 passed, 1 skippeduv run ruff check keel tests packages— cleanuv run mypy— clean
chore(release): 0.9.2 (#361)
Version bump across the six pyprojects including the ==-pinned sibling dependencies (the 0.9.1 bump missed those first — not repeating that).
Ships since 0.9.1: #350 / #360 — the routing-time max-spread gate for live BUY entries (execution.max_entry_spread_pct, default 50bp anchored to #334's SLIPPAGE_CAP_PCT; BUY-only, live-path-only, fail-closed on an unreadable book with a distinct reason; one _preview_book helper shared with #332's warning). Reviewed adversarially (approved, zero blockers) with two mutation-verified hardening tests applied pre-merge. Paper profiles are structurally exempt — no behavioral change for the running hourly evidence pipeline.
Packaging pins verified locally: uv run pytest -q tests/test_packaging.py → 15 passed.