Releases: CodeGateSoftware/keel
Release list
keel v0.10.0
Built from 24aa56f. Version binds to this hash:
keel --version reports keel 0.10.0+24aa56f2ef58 [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.10.0-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
docs(research): money-management and fiqh source review (#366)
Fixes #365 (deliverable: the review document itself).
docs/research/2026-08-18-money-management-and-figh-source-review.md — eleven sources (10-post keeks series, Fortune's Formula, the Abu Jib & Hashem 2019 Fiqh Academy paper) read and mapped against keel's architecture.
Executive summary: (1) the headline finding is confirmatory — Kelly run on keel's measured edges says bet zero, which is what keel already does; (2) keel's risk-based fixed fraction is the strongest member of the fixed-fraction family the series covers; (3) adoptions proposed are instrumentation around that posture: R1 Kelly diagnostic in promotion/simulate output (report-only, adoptable now), R2 quarter-Kelly live-path ceiling (spec now, implement only when a rule promotes), R3 drawdown-throttled sizing as an hourly-paper experiment via the trials ledger, R4 CPPI framing of rail 11 in the runbook; (4) explicit rejects recorded with reasons: Optimal-f, streak-driven sizing, a Merton γ knob; (5) the fiqh paper's 'define each instrument type precisely before ruling, through qualified bodies' is keel's attested fails-closed screening argued from the fiqh side — reading-list and AAOIFI/IFSB watch items only, no code.
Docs-only PR; no sizing, rail, gate, or attestation changes.
docs(specs): PRD — keel-broker-alpaca, US equities via the broker port (#373)
Docs-only: the PRD for keel-broker-alpaca, captured from the operator conversation (a contributor has volunteered for the adapter build).
Tracked in milestone Phase 12 — Stocks via Alpaca: #369 adapter + conformance suite (Phase A — the advertised dream first contribution), #370 equities paper profile + attestation semantics + runbook (Phase B), #371 cost-fidelity restatement + DCA benchmark (Phase C), #372 cash-account/no-margin posture. Lineage: Phase 5 broker port (milestone 8), interacting with #233 (capability-based venue visibility).
Hard constraints carried in the PRD: long-only spot; cash accounts only (no margin — riba, and it sidesteps PDT's margin threshold while T+1 settlement must be documented); per-(venue, instrument) attested screening with operator-supplied classifications; no live path in any phase — the promotion gauntlet applies unmodified, and commission-free is treated as not-cost-free (regulatory pass-throughs, spread, data tiers measured before any claim is believed).
docs(specs): refine the alpaca PRD against LEAN's adapter and Alpaca's product surface (#374)
Refines the Phase 12 PRD after exploring the two sources the operator named: QuantConnect/Lean.Brokerages.Alpaca and alpaca.markets.
New requirements: FR-9 session & calendar awareness (equities are not 24/7 — staleness rails must read market-closed on weekends/holidays; Alpaca's 24/5 overnight sessions explicitly off); FR-10 corporate actions (splits/dividends/tickers as first-class events, fail-closed on unreconcilable actions, adjusted-vs-raw candle policy); FR-11 rate limits + paper/live host selection under #233. FR-3 extended with fractional/notional sizing, native bracket/OCO, MOO/MOC. New §5: dividend purification as a recorded operator-policy obligation.
Non-objectives hardened against the venue's monetization surface: no stock lending (conflicts with qabd; interest-like income; opt-out operator-verified), no high-yield sweep (riba), no tokenized equities, no Alpaca crypto, no OAuth/FIX/MCP natural-language trading (the engine is deterministic and advertises it), Broker API recorded as the hypothetical future compliance-SaaS surface — subscription model, never trade commissions.
Docs-only; refines #369–#372 without changing their scope.
docs(fiqh): Lahlou speculation-risk scholarship analyzed and cited as method support (#377)
Fixes #376.
docs/research/2026-08-18-lahlou-speculation-risk-sources.md — analysis of Dr. Mohamed Talal Lahlou's scholarship (doctoral thesis Marchés financiers islamiques et risque de spéculation, 2020; 2019 definition-and-tree of speculative situations; 2014 Cahiers recovery-rule article), mapped to keel: his method (structural definition + classification tree; situations over intentions) is the same genus as the rails; the recovery rule is rail 17's French-language qabd pedigree.
Honesty guarantees baked in: no endorsement, review, or involvement by Dr. Lahlou is claimed — his skeptical position on trading stands unmodified; full texts were bot-gated so every inference is flagged against what was verifiable (titles, defense record, recension, citing theses); the works join fiqh-basis.md as method support, not attestation sources; the fiqh-basis subsection also adds the Abu Jib & Hashem 2019 Fiqh Academy taxonomy paper, completing the earlier source-review recommendation.
Docs-only; no screening, rail, or attestation changes.
docs: trademark and originality posture for venue adapters (#380)
Fixes #379.
- README gains a Trademarks paragraph under Disclaimers: venue names are nominative use; keel is a client of each venue, not a product of it; no affiliation/endorsement/sponsorship; adapters are original implementations against publicly documented APIs.
- Phase 12 PRD gains a Trademark and originality posture section: originality requirement, license+attribution rule for any ported third-party code, requirements-only reference study of Apache-2.0 projects (e.g. LEAN's adapter), no venue branding.
Also records on #379: #369 is now operator-driven (the prospective contributor stays welcome anytime).
Docs-only.
fix(tui): render and scope the activity feed on the UTC clock it runs on (#381)
The bug
The activity overlay stamped rows in local time while the deployment defines its unit of work in UTC.
keel-live-run.sh gates and stamps once per UTC date, firing at the first trigger at or after 01:00 UTC (the instant turtle_breakout._completed_days stops withholding the daily bar that closed at 00:00 UTC). So the day's only cycle lands at ~01:20 UTC. Rendered at UTC-4 that reads 21:20 on the previous date, and two things followed:
- The newest row showed yesterday's date all day — a deployment that had run correctly hours earlier looked a full day stale.
"today", being a local calendar day, excluded its own only cycle. Every day of the year, in every zone behind UTC. The default view of the overlay built to prove the agent is alive was permanently empty.
Reported from the live deployment: the feed showed 2026-08-17 21:20:06 as newest on 2026-08-18, and .keel-live-last-run said 2026-08-18.
The fix
The feed moves to the frame the work is defined in:
| before | after | |
|---|---|---|
_stamp / _clock |
local | UTC, under a when (UTC) heading |
scope_start_ts |
local calendar day | UTC calendar day |
scope_label |
today (2026-08-18) |
today 2026-08-18 UTC |
_day_phrase |
local calendar days | UTC calendar days |
_next_due_lines |
around 09:00 local |
around 09:00 UTC |
tui.py's _human_dt stays local on purpose, and _stamp now documents why: autonomy deadlines and position open times belong to the operator's day; a cycle does not.
Each row also gains an age column (47m, 22h, 3d), measured against ActivityFeed.now_ts so every row on one repaint agrees with the header above it. A UTC stamp cannot be compared to a local wall clock at a glance — that arithmetic is exactly what made a fresh feed look stale — so the row states the answer rather than leaving it to be computed.
scope_label drops the brackets around its date so that spelling UTC out in full keeps the default headline inside 80 columns (the existing test pins that).
Verified against the live log
today now shows the day's cycle; before this it showed nothing.
scope: today 2026-08-18 UTC · 1 cycle · 43 older hidden · press t to widen
when (UTC) age mode sig blk ent exi err what happened
▸ 2026-08-18 01:20:06 22h autonom 0 0 0 0 0 6 products / dca, turtle_breakout quiet
▸ 2026-08-17 01:20:04 46h autonom 0 0 0 0 0 6 products / dca, turtle_breakout quiet
Tests
Full suite: 2986 passed, 1 skipped. ruff check clean, mypy clean.
New covera...
keel v0.9.3
Built from 4923820. Version binds to this hash:
keel --version reports keel 0.9.3+492382043d4f [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.3-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
docs(readme): the evidence-cadence story — ~940 signals/yr on the hourly profile (#362)
Brings the README up to date with this week's news (discussion #359):
- New paragraph after the honest-result block: the cadence problem (100-trade floor at 2.15 signals/asset-yr = 31–84 years — "waiting is not a slower path; it is no path") and its solution — the same rules on
ONE_HOURbars (49.4/asset-yr, median n=268) running on the 19-asset paper-hourly profile behind the measured 15-minute health screen, ~940 signals/yr pooled, n=100 pooled review weeks away (#353, 2026-09-30). The hourly configuration's own net-negative caveat is stated as plainly as the daily one. Links: the hourly experiment, the announcement, the runbook, #353. ~2,800 tests→~3,000(suite is 2,967 passed + 1 skipped on main).- The runbook documentation-map line now names the three deployment profiles (daily paper, live, hourly evidence) instead of "two accounts".
Docs-only; no code touched.
perf(pullback): O(1)-per-bar running state replaces the full-series recompute (#352) (#363)
What
PullbackContinuation.detect()/exit_signal() are called once per bar by strategy.backtest, each time with the whole prefix. Every full-series read the rule made — regime.detect_phase (a pivot scan of the entire prefix), indicators.ema_fan, indicators.atr, levels.swing_highs/swing_lows — consumed only its last value, so the backtest was O(n²) in bars. This replaces the per-bar recompute with a _RunningState that extends bar-by-bar in O(1).
Measured (deterministic synthetic hourly fixtures, shipped defaults, market-fill model)
| fixture | pre-fix (f7a0cdf) | post-fix |
|---|---|---|
| 1y — 8,784 bars | 8.9s (8.2s in the original #352-era measurement) | 0.2s |
| 5y — 43,800 bars | 168.6s (233.5s original; the real-data 5y run that opened #352 was killed at 38+ CPU-minutes) | 2.6s |
18.9x (this repro) / 28.3x (original) runtime ratio for a 5.0x bar count — squarely quadratic. cProfile put 71% of the 1y run in regime.detect_phase's _swing_highs/_swing_lows and 26% in indicators.ema_fan. The residual 13x-vs-5x superlinearity post-fix is the engine's per-bar candles[: i + 1] slice (shared by every rule, C-level memcpy), not the rule's math.
Why not the turtle rule's tail-slice
TurtleBreakout solved the same shape by deciding on a bounded tail of history — acceptable there because Donchian is exact over its own window and ADX/ATR converge. Not here: EMA(50) seeded four bars back is a different number than EMA(50) seeded five years back, so a tail would change which setups fire and silently re-parameterize the rule.
The bit-identical contract
_RunningState.extend() performs the SAME floating-point operations, in the same order, as the pure functions applied to the whole prefix: indicators.ema's alpha*v + (1-alpha)*prev, indicators.atr's seed-mean then Wilder step (true-range expression copied verbatim), regime._swing_highs' radius-1 and levels.swing_highs' radius-2 strictly-beyond-neighbour pivots (extend confirms exactly the one candidate pivot the new bar completes, so it can never miss one a rescan would find), and detect_phase's exact reads/comparisons. Cache validation is length + first_ts + last_ts; any call that does not strictly extend the cached prefix rebuilds from the pure functions (cold start, slid/shrunk window, unrelated series). _sync() runs at the top of both detect() and exit_signal() before any gate can decline.
regime.detect_condition(candles) stays a direct call: it reads only the last lookback=20 bars, so it is O(1)-bounded per bar already — state-ifying it would change nothing.
How the contract is pinned (tests/strategy/test_pullback.py)
- bar-by-bar equivalence — every prefix of two deterministic series (one tie-heavy on purpose, to exercise the pivot predicates' strictness on equal neighbours): each EMA tail,
atr_last, both phase pivots, both target pivots compared float/Decimal-exact (==, not approx) against the pure recompute, plus_phase/_tail_alignedagainstdetect_phase/fan_aligned. - rebuild-vs-extend — the same final prefix reached by one-bar steps, a gappy walk, and a cold start lands on the identical state, equal to a full
_RunningState.build. - golden backtest — three deterministic windows (defaults; ATR stop + swing target; band zone + fib target — every state-read path flows into trade prices) captured by running the pre-fix implementation at f7a0cdf on these exact fixtures; the new code reproduces the full trades list and every metric exactly (Decimal-string compare — one ulp fails it). The golden is a capture, not a regenerable baseline; the docstring says so.
- timing smoke — the 1y 8,784-bar window completes < 60s (lenient for CI variance; it runs in ~0.2s). No perf harness beyond this one test, per the issue.
Gates
uv run pytest -q— 2973 passed, 1 skipped (pre-existing). The existing pullback suite passes unchanged; no assertion was touched.uv run ruff check keel tests packages— clean.uv run mypy— clean (238 files).
Fixes #352
chore(release): 0.9.3 (#364)
Version bump across the six pyprojects including the ==-pinned sibling dependencies.
Ships since 0.9.2:
- #352 / #363 — PullbackContinuation backtest performance: the quadratic per-bar full-series recompute (measured 28.3x ratio on 5x bars; 71% in the phase-pivot scans, 26% in the EMA fan) replaced by
_RunningStaterunning values extended bar-by-bar with the pure functions' exact arithmetic — bit-identical results (independent golden reproduction + ~31k-case fuzz; review majors on cache validation and lazy acquisition fixed and re-measured pre-merge). 1y hourly 8.9s → 0.19s; 5y 168.6s+ → 2.59s; identical trades. - #362 — README: the evidence-cadence story and three-profile runbook line.
Packaging pins verified locally: uv run pytest -q tests/test_packaging.py → 15 passed.
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.
keel v0.9.1
Built from 1be07f2. Version binds to this hash:
keel --version reports keel 0.9.1+1be07f208d40 [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.1-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
docs(experiments): the honest-cost restatement and the DCA dip-bonus ablation (#339) (#347)
What & why
Closes #339's measurement checkboxes. Two firsts, one date:
The honest-cost simulate re-run — the first keel simulate under per-product slippage (#334, v0.9.0): verdict TRAIN MORE, unchanged, and the correction runs exactly the conservative direction — BTC identical (floor-anchored), XLM 4.7567→4.4435, ADA 1.7638→1.7087, PAXG capped at 50bp. No flattered outlier survived; the TON-class 3.751-on-n=9 result can no longer print at thin-book pricing. This is the measurement that decides #335: the gate opt-in stays precautionary (nothing promotes on thin assets today), with the evidence now on record.
The DCA dip-bonus ablation — first-ever coverage of the only unswept rule family, three cells (dip_bonus_pct 0/1/2) through sim/portfolio_sim at keel's real costs over ~5 years of BTC hourly. The pre-registered ordinal expectation was NOT confirmed: cells within 0.65% relative, no monotone direction (1.40742 / 1.40424 / 1.41325). The honest reading is stated: mechanism mismatch (the citation holds cash until threshold dips; the param always buys and scales size) plus single-asset/single-window power. No cell preferred; the live rule stays at 0.
Both trials appended to the hash-chained ledger (chain verified clean). Also records the deployment actions: DCA twins re-enabled via keel rules enable + promote --force (the documented bypass, warnings logged), so the paper pipeline now has an always-firing family. v0.9.0 was cut and deployed first (PR #346; four distributions verified, keel versions clean, both DBs at schema 10).
Tests-first evidence
Measurement PR: the instruments' outputs are quoted verbatim; the ledger rows were appended through keel.research.ledger.append_trial with verify_chain returning []. Gates: 2947 passed / 1 skipped; ruff clean; mypy clean.
Gates
-
uv run ruff check keel tests packages— All checks passed! -
uv run mypy— Success: no issues found in 238 source files -
uv run pytest -q— 2947 passed, 1 skipped
Scope check
- Documentation, experiment records, and ledger rows only — no code, rails, rules, or classifications touched.
fix(data): keel fetch honors config.market_data.granularities (#354)
Bug (#349)
keel fetch hardcoded the module constant _SIM_GRANULARITIES = [ONE_HOUR, ONE_DAY] everywhere in its flow, so it ignored config.market_data.granularities. Every shipped config lists ONE_DAY/ONE_HOUR/FIFTEEN_MINUTE, which meant the runbook's documented warm step keel fetch never warmed the FIFTEEN_MINUTE confirmation series: a fresh deployment's cache had zero 15-minute candles, and the agent's first cycles inherited a full multi-hundred-request catch-up. keel agent and keel monitor already did the right thing (list(config.market_data.granularities)); fetch was the odd one out.
Fix
The fetch flow in keel/cli.py now computes granularities = list(config.market_data.granularities) once and threads it through all four places that previously hardcoded the simulate pair:
_assess_productstakes the granularities as an explicit parameter (both call sites updated);- the
--check --fail-on-gapsunexplained count; ensure_history(...)— the actual warm fetch, core of the bug;- the
--repair-gapsloop.
Also fixed: the TUI's _do_fetch (keel/commands/tui.py) — the dashboard twin of keel fetch, documented as such ("matches keel fetch --years's own default") — had the identical bug and now uses the same config-driven list.
simulate deliberately KEEPS _SIM_GRANULARITIES ([ONE_HOUR, ONE_DAY]): those are the backtest engine's supported timeframes — an engine limit, not a data choice. A short comment where the fetch list is computed explains the asymmetry, and a new pin test (test_simulate_still_fetches_exactly_the_engine_timeframes) locks it.
Tests (red first)
New tests in tests/data/test_fetch_cli.py, all confirmed RED against the pre-fix code for the right reasons, then green:
test_fetch_warms_every_configured_granularities— before the fix:Extra items in the right set: FIFTEEN_MINUTE(fetch requested only ONE_DAY/ONE_HOUR under a three-granularity config);test_check_reports_freshness_for_every_configured_granularity— before:'FIFTEEN_MINUTE' not in output;test_fetch_touches_only_the_configured_granularities— before:Extra items in the left set: ONE_HOUR(config listed only ONE_DAY; fetch ignored it).
Existing tests updated to match the intended behavior change (they seed a three-granularity config, so their fixtures now seed the 15-minute series too; the repair pass count goes 6 -> 9 = 3 products x 3 granularities). Full suite green.
Behavior change for existing deployments (intentional, benign)
Scheduled and manual keel fetch runs now also ensure FIFTEEN_MINUTE history. The agent already polls 15-minute candles every cycle, so the cache receives them anyway — fetch just makes the documented warm step honest. --years (default 5) applies to all granularities uniformly; a venue lacking deep 15-minute history lands in the already-existing "some series are still short ... usually benign" branch — no new handling needed.
Docs
docs/operations/scheduled-fetch.md: notes that fetch warms exactlymarket_data.granularities(same series agent/monitor poll) and that simulate is the deliberate exception.docs/operator-runbook.md: the paper-hourly bootstrap warm-step comment now states the config is the source of the granularity list.
Gates
uv run pytest -q— 2951 passed, 1 skippeduv run ruff check keel tests packages— all checks passeduv run mypy— no issues in 238 source files
Fixes #349
test(data): collapse repeated fetch fixture seeding into helpers (#355)
Dedupes the three-granularity seeding block repeated across six fetch tests in tests/data/test_fetch_cli.py (Sonar flagged 19.2% duplicated lines on new code in #354, gate <=3%). The block now lives in _seed_current (day_lag for the tolerance fixture, skip_day for the gap fixtures, replacing _seed_with_hole), and the two day-only stale fixtures share _seed_stale_days. No behavior change: bar counts and timestamp semantics are identical, and the same tests pass before and after (19/19 in the module; full suite 2951 passed, 1 skipped).
chore(release): 0.9.1 (#356)
Version bump across the six pyprojects for the 0.9.1 release.
Ships since 0.9.0:
- #349 / #354 —
keel fetch(and the TUI's_do_fetchtwin) now honorsconfig.market_data.granularities, so the documented warm step also ensures theFIFTEEN_MINUTEconfirmation series every shipped config lists.simulatedeliberately keeps[ONE_HOUR, ONE_DAY](backtest engine limit), pinned by a new test. Intentional, benign behavior change: scheduled/manual fetch on existing deployments now also ensures 15-minute history (the agent already polls it every cycle). - #355 — test-fixture dedupe (SonarCloud duplication gate back to green).
Reviewed as #354 (adversarial round + independent final review: goal met, no schema/rails changes) and #355.
chore(release): pin workspace siblings to 0.9.1 (#357)
Follow-up to #356: the bump updated the six version fields but not the ==-pinned sibling dependencies, so tests/test_packaging.py::test_workspace_siblings_are_pinned_to_the_exact_version fails on main (5 failures). This updates every keel-core==/keel-broker-api==/keel-broker-coinbase== pin to 0.9.1.
Verified locally: uv run pytest -q tests/test_packaging.py → all pass. Required before re-running the 0.9.1 release workflow (the previous dispatch was canceled).
keel v0.9.0
Built from eebf12b. Version binds to this hash:
keel --version reports keel 0.9.0+eebf12b88292 [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.0-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
fix(executor): warn loudly when a conditional entry price is overridden by market routing (#260) (#332)
What & why
Closes #260's minimum viable mitigation (the issue's own scope; full resting-order routing stays deferred).
The live executor records every rule's Setup.entry as expected_fill and then ignores it for execution — all entries route market_market_ioc (_order_row/_order_configuration). For enter-at-close rules that is nearly free; for pullback_continuation, whose entry = signal_candle.high + buffer_ticks deliberately demands follow-through, production silently takes trades the rule meant to decline. The faithful measurement (#258) quantified it across 24 assets: median trade count 58 -> 124 (more than doubled), median gross PF 0.9219 -> 0.7736. The doubling is the count of trades the live box would take that the rule intended to decline, and the PF collapse is their quality.
Fixing it means changing money-moving order routing to rescue a strategy that is independently measured dead — "Upgrading live execution to rescue a dead strategy is a bad trade" (#260). The landmine is not pullback_continuation but the next price-conditional rule, which would be silently mis-executed the same way. So this PR makes the override visible rather than silent — the same principle as #247 printing the fee rate:
keel/execution/executor.pygainsENTRY_OVERRIDE_WARN_BP(50bp, documented below) and_warn_if_market_routing_overrides_entry, called from_run_orderright after the preview, before the confirm gate.- Market reference: the venue's own
best_askout of the preview_run_orderalready fetches — the price a market BUY actually pays, from the one book quote already in the hot path (no new broker call; a mid would understate the deviation by half the spread).CoinbaseClient.preview_ordermapsbest_bid/best_asktoDecimaltoday, and the Coinbase port adapter carries the same book inPreview.detail, so both preview shapes are read. - Threshold:
ENTRY_OVERRIDE_WARN_BP = Decimal("50")— a VISIBILITY threshold, not a correctness one. Anchored in the repo's own cost model (1.2% taker per leg, 5bp slippage): a few bp is the microstructure drift any enter-at-close rule accumulates by routing one cycle late; tens of bp is a rule whose entry encodes a condition. 50bp is 10x the slippage assumption (noise never trips it) yet small enough that any deliberate entry condition does. Comparison is strictly greater — exactly at the line logs nothing. - The warning is a structured WARNING event (
executor.entry_override_market_routed) carrying rule kind, product, intended entry (expected_fill), market reference and its source, signed deviation in bp, the threshold, and an explicitdetailsentence: rendered —
{"level": "WARNING", "logger": "keel.execution.executor", "event": "executor.entry_override_market_routed", "rule": "pullback_continuation", "product": "BTC-USD", "expected_fill": "50300", "market_ref": "50000", "market_ref_source": "preview_best_ask", "deviation_bps": "60.00", "threshold_bps": "50.00", "detail": "the rule's conditional entry price was OVERRIDDEN -- entries are always routed as market orders (#258), so the condition this rule encoded in its entry price was bypassed and the order is going out at the venue's price instead (#260)"}
Scoped to BUYs on the market configuration only: SELL intents (exits, brackets, stop rolls) carry their prices to the venue verbatim, and a future caller passing a resting order_configuration is not on the override path. A preview with no usable book quote is silent, not fatal. _order_row and the module docstring also document the always-market decision (#258) and why resting orders are deferred (#260).
Tests-first evidence
Tests written first in tests/execution/test_executor.py::TestEntryOverrideWarningAtRouting (extending the existing TestIntentDivergenceLog house pattern), seen red:
FAILED tests/execution/test_executor.py::TestEntryOverrideWarningAtRouting::test_routing_an_offset_entry_warns_loudly_at_warning_level
...
E AssertionError: no executor.entry_override_market_routed record was emitted
E assert []
...
7 failed, 1 passed, 63 deselected
The end-to-end routing test (through execute(), no private imports) failed on the assertion meant to assert — the full guard->preview->place path ran clean and no warning fired. The other 7 red on importing the then-nonexistent helper, then went green with the implementation. Cover: beyond threshold via the full routing path (rule kind + both prices + signed bp + WARNING level + the OVERRIDDEN sentence), within threshold silent (a warning that fires every order is a warning nobody reads), exactly at the threshold silent (boundary pinned from the constant), entry below market warns with negative sign, bookless/garbage preview silent and non-fatal, the port Preview shape, SELL intents never warn, non-market configurations never warn.
- Tests written first, seen failing for the right reason
Gates (all must pass)
-
uv run ruff checkclean —All checks passed! -
uv run mypyclean —Success: no issues found in 237 source files -
uv run pytest -qgreen —2870 passed, 1 skipped in 31.34s
Scope check
- This PR touches a rail or a default classification — it does NOT: executor logging + tests only (
keel/execution/executor.py,tests/execution/test_executor.py). - No order-routing change (every entry still routes market per #258), no rule change, no simulator/backtest change, no new dependency.
feat(backtest): per-product slippage scaled from liquidity — assumed, capped, and reported (#259) (#334)
What & why
Closes #259. backtest() charged one global 5bp of slippage on both legs of every trade on every product. Since #257 (entries fill at next bar's open as market orders), that constant is the ONLY term modelling spread crossing and market impact — precisely what a market order pays. The 24-product corpus spans orders of magnitude of liquidity (median daily quote volume, measured over the cached ONE_DAY bars on 2026-08-16: BTC $571M, ETH $337M, SOL $109M … WLD $1.2M, TON $370K), so 5bp was plausible for BTC and optimistic for the tail by an unmeasured factor — and the error ran in the flattering direction on exactly the thin assets that kept surfacing as apparent outliers (TON gross PF 3.751 on n=9; WLD 2.810 on n=12).
This PR replaces the flat constant — for callers that opt in — with per-product slippage scaled from the liquidity statistic compliance/screen.py::median_daily_quote_volume already computes (no new data source, no network), and reports the assumed rate beside the results, exactly the way #247 made the fee rate visible. A profit factor printed without its assumed slippage has the same problem a profit factor printed without its fee rate had.
The mapping, and every parameter as a conservative assumption (keel/strategy/backtest.py::slippage_for_quote_volume):
slippage = clamp(SLIPPAGE_FLOOR_PCT * sqrt(SLIPPAGE_REFERENCE_QUOTE_VOLUME / median_volume),
SLIPPAGE_FLOOR_PCT, SLIPPAGE_CAP_PCT)
SLIPPAGE_FLOOR_PCT = 5bp— the liquid-end bound AND the fallback for products with no statistic. Numerically the old global constant: the liquid end was never the problem.SLIPPAGE_REFERENCE_QUOTE_VOLUME = $500M/day— the anchor that maps to the floor. Measured corpus top (BTC $571M) sits just above it, so BTC itself clamps to the floor. A round number, not BTC's exact median, so the anchor stays honest as the corpus re-measures itself.SLIPPAGE_CAP_PCT = 50bp— the thin-end bound, chosen so it binds at exactly 100x below the anchor (sqrt(100) = 10x the floor — a relationship a reader can recover). TON (~1544x below the anchor) would demand ~184bp unclamped, more than any plausible thin-book spread for the 1-unit notional this engine fills; the cap keeps the model conservative without declaring thin products untreatable by construction.- Square-root of inverse volume ratio: the standard practitioner prior for market impact. The mapping is an ASSUMPTION, not a measurement — keel stores no book snapshots or realised spreads — and it is documented and reported as one, monotone (more liquid → never more slippage) and bounded at both ends.
Computed rates for the anchors named in the issue: BTC ($571M) → 5.0bp (floor); a 100x-thinner product ($5M) → 50.0bp (= the cap, by construction); **TON ($370K, ~1544x) → unclamped ~184bp,...
keel v0.8.1
Built from 498e02c. Version binds to this hash:
keel --version reports keel 0.8.1+498e02c8f571 [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.8.1-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
fix(fetch): window-scope the gap-proven display — the two counts must share a window (#330)
What & why
keel fetch printed, for real series today (2026-08-17):
GAPS SOL-USD ONE_HOUR n=43640 1 bars behind, 158 internal gaps (-2 proven absent at venue)
GAPS SOL-USD ONE_DAY n=1819 0 bars behind, 5 internal gaps (-1 proven absent at venue)
A negative "proven absent" count is an impossible claim — you cannot prove fewer than zero
gaps absent — and it MASKED the real state: for those series the whole-series unexplained-gap
count was 160 hourly / 6 daily (some gaps are NOT proven absent), plus 2+1 missing bars sit
OUTSIDE the assessed window, yet the display read as if reconciliation had happened.
Root cause: _assess_products computed coverage()'s gap count over a WINDOW-BOUNDED read
(get_candles(product, gran, start_ts, None)) but unexplained_gap_count() over the WHOLE
series, then _print_freshness subtracted one from the other. Any bar missing older than
start_ts drove the suffix negative.
The fix makes the two counts share a window by construction:
unexplained_gap_countgains an optionalstart_ts(defaultNone= whole series,
behavior identical to today). Givenstart_ts, it reads the same bounded slice
coverage()reads, so detection covers the same bars. The docstring records the honest
boundary caveat: a hole that straddlesstart_tsis not interior to the bounded slice, so
neither count can see it — the window can only under-report a hole crossing its own start
boundary, never claim one absent, and the whole-series default still sees it._assess_productspassesstart_ts;_print_freshnesskeepsproven = gaps - unexplained
with NOmax(0, ...)clamp — the point is the shared window, and a clamp would only re-hide
a future mismatch.
--fail-on-gaps scope deliberately UNCHANGED (whole series). Reading the fetch flow:
the flag judged _assess_products' whole-series return, and the advice it prints (run keel fetch --repair-gaps) points at repair_series, which itself reads the whole series — so a
hole older than the fetch window is still fixable, still unproven, and still the flag's
business. The flag now computes its whole-series count where it is judged (the --check
branch) instead of riding the display's window-bounded number, and a test pins that a gap
lying entirely OUTSIDE the fetch window still fails --check --fail-on-gaps (and that plain
--check still reports it in the closing "N have UNEXPLAINED gaps" message). The default of
unexplained_gap_count stays whole-series and is pinned by test.
Tests-first evidence
Red first (uv run pytest tests/data/test_fetch_cli.py tests/data/test_gap_repair.py -q):
4 failed, 40 passed in 1.36s
Failing for the right reasons:
test_the_gap_suffix_shares_the_fetch_window_so_it_cannot_go_negative— assertion failure
whose diff SHOWS the bug (the exact old rendered line):
GAPS SOL-USD ONE_DAY n=363 0 bars behind, 2 internal gaps (-1 proven absent at venue)test_every_assessed_row_keeps_proven_absent_never_negative—AssertionError: ('SOL-USD', ONE_DAY, 2, 3): the field shape (window gaps 2 < whole-series unexplained 3).test_unexplained_gap_count_bounded_ignores_holes_before_start_tsand
test_a_hole_straddling_start_ts_is_invisible_to_the_bounded_count—
TypeError: unexplained_gap_count() got an unexpected keyword argument 'start_ts'(the
interface under test not existing yet).test_fail_on_gaps_still_judges_holes_older_than_the_fetch_windowwas BORN GREEN, on
purpose and stated in its docstring: it pins preserved--fail-on-gapssemantics, not the
regression — it must pass both before and after or the flag's scope changed.
Fixture = the field repro: (a) an in-window hole recorded absent in candle_gap_probes,
(b) an in-window hole NOT recorded, (c) a 2-bar hole entirely older than start_ts.
Before → after, exact rendered line:
- GAPS SOL-USD ONE_DAY n=363 0 bars behind, 2 internal gaps (-1 proven absent at venue)
+ GAPS SOL-USD ONE_DAY n=363 0 bars behind, 2 internal gaps (1 proven absent at venue)
The suffix now claims exactly the one proven bar; the unproven in-window hole rides in the
2 internal gaps count unclaimed; the outside-window hole perturbs neither number, and the
closing message still carries the whole-series truth (1 have UNEXPLAINED gaps).
- Tests written first, seen failing for the right reason
Gates (all must pass)
-
uv run ruff checkclean —All checks passed!(overkeel tests packages) -
uv run mypyclean —Success: no issues found in 237 source files -
uv run pytest -qgreen —2862 passed, 1 skipped in 36.56s
Scope check
- This PR touches a rail or a default classification — unchecked: display + one pure
function's optional parameter only. No rail, no rule, no classification, no broker
surface, no default changed (--fail-on-gapswhole-series semantics preserved and
pinned by test). - New dependency added — none.
chore(release): 0.8.1 (#331)
What & why
Version bump across all six distributions. Patch release: exactly one change since v0.8.0 — #330, the window-scoped gap-proven display. keel fetch was computing proven = row.gaps - unexplained from two counts over different windows (coverage's window-bounded read vs. the whole-series unexplained count), printing impossible negative suffixes like "158 internal gaps (-2 proven absent at venue)" for real series, and masking that those gaps were largely unproven. The fix makes both counts read the identical slice; --fail-on-gaps keeps its whole-series scope deliberately (the --repair-gaps remediation reads unbounded too).
Tests-first evidence
tests/test_packaging.py and tests/test_python_floor.py pin the invariants this bump touches; after the bump and uv lock: 2862 passed, 1 skipped; ruff clean; mypy clean.
Gates
-
uv run ruff check keel tests packages— All checks passed! -
uv run mypy— Success: no issues found in 237 source files -
uv run pytest -q— 2862 passed, 1 skipped
Scope check
- Version numbers and uv.lock only; no code, rails, rules, or classifications touched.
keel v0.8.0
Built from a37997f. Version binds to this hash:
keel --version reports keel 0.8.0+a37997f48cf7 [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.8.0-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
ci: gate on mypy, so #266's ungating cannot quietly come undone (#268)
mypy ran in no workflow before this — it appeared only in a passing comment in code-quality.yml. #266 brought keel.* under the checker, but nothing enforced it: a type error in keel/ was a clean CI run.
Why a step, not a job
Added to the existing test job rather than as a typecheck: job of its own. The main ruleset requires the status context test, which comes from that job's id — a separate job would report a context nothing requires, so a red mypy would not block a merge. That is the same failure shape ci.yml's own header already warns about for renamed jobs. As a step, it inherits the gate.
release.yml gets it too, and deliberately re-runs it rather than trusting CI's result: that workflow is dispatched against whatever main is at the time, which need not be a commit any CI run went green on.
Neither workflow repeats the paths — they come from [tool.mypy]'s files in pyproject.toml, so a package moving in or out of the checked set stays a one-line edit there instead of one mirrored across two workflows.
CI only closes half the hole
Running mypy catches a type error in keel/. It cannot catch keel.* being re-added to an ignore_errors override — that silences the package wholesale, mypy exits 0 while checking nothing there, and #266's ungating is reverted with a green build to show for it.
test_keel_is_not_exempt_from_type_checking closes that. Both halves verified:
| scenario | mypy exit |
suite |
|---|---|---|
type error injected into keel/sim/report.py |
1 (fails CI) | — |
keel.* restored to ignore_errors |
0 (CI green) | new test fails |
| neither | 0 | green |
The middle row is the point: the guard catches exactly what CI cannot see.
tests.* and keel_core.* remain legitimately exempt, per the comments beside each in pyproject.toml. The guard pins only the module that was deliberately brought under the checker.
Verification
mypy— clean, 224 source filesruff check keel tests packages— cleanpytest— 2727 passed, 1 skipped (2726 + the new guard)
fix(data): chunk poll_once's catch-up request under Coinbase's candle cap (#269)
The bug
poll_once requested its entire catch-up range in a single get_candles call:
fetch_start = last_ts + gran_sec if last_ts is not None else latest_closed
fetched = client.get_candles(product_id, granularity, fetch_start, latest_closed)Coinbase rejects any range over ~350 candles:
400 INVALID_ARGUMENT ... "number of candles requested should be less than 350"
Why it can't recover on its own
The request size is a function of how stale the product is, and staleness only grows while the poll is failing. Every subsequent poll therefore asks for a strictly larger range and fails the same way — there is no path back to a legal request without intervention.
The live instance: ZEC-USD at ONE_HOUR sat 552 hours stale, i.e. a ~552-candle request, and had been failing hourly for two days, widening by ~24 candles a day. The other 18 products were current, so nothing else masked or explained it.
Note the empty-repo case was never affected: with last_ts is None, fetch_start = latest_closed, so exactly one candle is requested. Wedging requires cached-but-stale candles.
The fix
poll_once now pages [fetch_start, latest_closed] forward in windows of at most MAX_CANDLES_PER_REQUEST candles, importing the existing constant from keel.data.history rather than declaring a second one — a duplicated venue limit is the same bug class being fixed here.
The new _poll_catch_up helper mirrors history._fill_forward's windowing idiom so the two read the same way, with two deliberate differences:
- windows are sized
(MAX - 1) * gran_sec, so the inclusive[start, end]range is at mostMAXcandles (_fill_forward's+ MAX * stepis one over, harmless under the ~350 cap but not a property worth copying); - an empty window does not break the loop, since a mid-history hole must not block catch-up of newer candles.
Each window is upserted as it arrives, so a failure partway through leaves the earlier windows persisted and the next poll resumes further along instead of restarting.
cb_client.get_candles is deliberately unchanged — it correctly passes start/end through, and batching belongs in the caller.
Preserved exactly: the small-range path (still a single request), the empty-repo path (still one candle at latest_closed), the last_ts >= latest_closed no-op, and the return value's meaning.
Tests (written first, confirmed failing for the right reason)
Against the old code the three new chunking tests fail on the oversized single call — assert 552 <= 300 and assert 1 > 1 — not on any import or fixture error:
FAILED test_poll_once_chunks_a_gap_larger_than_the_coinbase_cap
FAILED test_poll_once_never_requests_more_than_the_candle_cap
FAILED test_poll_once_chunk_windows_are_contiguous_and_non_overlapping
3 failed, 12 passed
They reproduce the real 552-hour ZEC-USD gap and assert that catch-up issues multiple calls and persists the complete contiguous series, that no request exceeds MAX_CANDLES_PER_REQUEST candles (asserted on the start/end args the fake client actually received), and that the windows tile the range exactly — no duplicate and no gap at a chunk boundary. A fourth test guards that a small gap still takes exactly one request.
Gates
…truncated — full description in #269.
fix(compliance): stop discovery hiding assets the gate would admit (#270)
The problem
keel assets discover was silently dropping assets that clear the admission gate comfortably, and its own output gave an operator no way to notice. Two defects, one symptom.
(a) A one-day statistic compared against a multi-year threshold
--min-volume-24h defaulted to 1,000,000, pinned EQUAL to the admission floor ScreenPolicy.min_median_daily_volume.
That pinning (2026-08-08, superseding a 5,000,000 floor that had hidden FET at $2.94M/24h while it measured 4.8x the admission floor) had the right intent: a pre-filter must never be stricter than the criterion it screens for. It had the wrong mechanism. The two numbers are not the same statistic:
| statistic | window | |
|---|---|---|
discovery --min-volume-24h |
venue's reported quote_24h_volume |
a single 24-hour snapshot |
admission min_median_daily_volume |
median of volume x close |
all cached history |
Equal numbers cannot make one non-stricter than the other when the two sides measure different things — one quiet trading day pushes the snapshot below a floor the asset's own median clears many times over.
Measured 2026-08-15, five assets were silently dropped whose real gate statistic sits far above the admission floor:
| asset | median daily quote volume | vs admission floor |
|---|---|---|
| ATOM | 3,077,474 | 3.08x |
| AAVE | 6,315,463 | 6.32x |
| BCH | 5,464,940 | 5.46x |
| CRV | 3,329,753 | 3.33x |
| ALGO | 3,780,207 | 3.78x |
Four of the five had 24h volumes clustered between 852,133 and 979,000 on that one quiet day — comfortably under the 1,000,000 floor.
The discovery floor now sits an order of magnitude below the admission floor (100,000), leaving real room beneath that measured cluster. This supersedes the 2026-08-08 pinning without abandoning its goal: the test that asserted the two floors are equal now asserts discovery's is strictly less than the gate's, which is the property the original fix was reaching for. The admission floor itself is untouched — it is the real criterion.
(b) Nothing recorded what was excluded
discover_candidates dropped products with a bare continue and returned only survivors; the CLI printed only N venue products -> M candidates. A filter that can silently remove admissible assets must be auditable from its own output.
It now returns a DiscoveryResult carrying per-reason exclusion counts, surfaced by both keel assets discover and the TUI's discover overlay:
5 venue products -> 2 candidates (quote=USD, 24h volume >= 100,000, excluding the current allowlist)
excluded 3: wrong quote currency 1, not online 0, trading disabled 0, view only 0, already on allowlist 1, unreadable 24h volume 0, below 24h volume floor 1
Every reason is listed even at 0, so the line has a fixed shape an operator can scan rather than one that changes ...
keel v0.7.1
Built from 02bc86d. Version binds to this hash:
keel --version reports keel 0.7.1+02bc86d90b67 [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.7.1-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
docs(experiments): three rules, 24 assets, zero free parameters — the viable intersection is empty (#252)
Closes #251.
Measures every signal rule the codebase ships at its shipped constructor defaults, across the
whole 24-asset hourly corpus, at three fee levels. 270 backtests, 0 errors. Documentation only
— no code, no config, no rule status, no version bump.
Verdict
There is no asset-rule combination that clears the promotion trade floor with an edge surviving
even the cheapest fee we can reach — and the three rules fail for three unrelated reasons.
| rule | n≥100 |
∧ gross>1 | ∧ net@0.6%>1 | ∧ net@1.2%>1 | the lever |
|---|---|---|---|---|---|
turtle_breakout |
21/24 | 7 | 1 (ZEC) | 0 | cost |
pullback_continuation |
4/24 | 1 | 0 | 0 | signal |
rsi_meanrev |
0/24 | — | — | 0 | sample size |
| total | 25/72 | 8 | 1 | 0 |
The single survivor, ZEC-turtle, is then eliminated by a temporal decomposition: three
consecutive losing years (2022–24) with 92.7% of lifetime PnL compressed into 2025–26.
Two earlier conclusions this supersedes
1. rsi_meanrev does not lack gross edge. #248 recorded that it "has no gross edge at all" on
the strength of BTC at 0.775. Across all 24 assets it has the best gross distribution of the
three rules — median gross PF 1.1631, against turtle's 0.9892 and pullback's 0.9292 over
the same universe. BTC sits near the bottom of its distribution. The rule does not lose; it is
not observable — 0 of 24 assets reach n≥100, median n=38. #248's figures came from widened
parameters (oversold 25–35), not defaults, so both documents are right about what they measured;
the error was generalising from one asset.
2. The 864-trial sweep winner is not overfit. #248 framed it as selection. Arm B evaluates that
config on the 18 assets it was never scored on: in-sample mean net PF 0.6335 → out-of-sample
0.6346, gross 1.5317. A clean three-decimal replication across disjoint assets. That is a
harder result than overfitting — overfitting has a methodology fix; a stable measurement of 0.63
does not.
pullback_continuation, measured for the first time
In RULE_REGISTRY since it was written, never backtested. Median gross 0.929, and a fee
collapse qualitatively unlike turtle's — PAXG-USDT 1.097 → 0.001 at maker. The cause is in
the win rates: 52–75% wins at PF≈1.0 is measured_1to1 taking small wins against small losses, so
a 1.2% round trip does not reduce the edge, it erases it. High win rate is the symptom of maximal
fee fragility here, not a strength.
Why the temporal probe is reported and never gated
Three reasons in §6, the third decisive: ZEC under pullback_continuation is the only
combination in the study with no losing complete year (1.06/1.17/1.16/1.13) — at gross PF
0.875. Perfectly stationary at losing slightly, reliably. A stationarity gate would wave it
through while rejecting ZEC-turtle, which at least made money gross. The probe is also
underpowered (passes zero-edge strategies ~50% of the time at four buckets) and fragile to bucket
boundaries (excluding partial 2021/2026 flips the verdict for 4 of 21 assets, including FET).
The principled instrument already exists and is now live: #247 wired g4_pbo_gate into
can_promote, where pbo=None blocks. It is deployed and nothing feeds it. Building a weaker
annual-bucket heuristic beside an unfed rigorous one is the wrong order of work.
Honesty notes recorded in the document rather than omitted
- The pre-registration lived in the dispatch brief, not the script docstring — weaker than
every other harness here. The script now says so at the top instead of presenting the
reconstruction as though it had always been there. - The tail and regime probes were written after seeing Arm A. Post-hoc by construction, which
is exactly why they are diagnostics. - Four failed runs preceded the successful one, caused by piping into
tail(buffers until EOF,
then dies with the shell) and bypkill -fnot reapingmultiprocessingchildren. Both produced
confident status reports that were false. §7. - 27 combinations ran on 0.6.1 and the rest on 0.7.0 after #247 deployed mid-run. Version
independence was checked, not assumed: BTC-USD reproduces to six decimal places across the
boundary.
What this leaves open
One lead, and it is cheap: does rsi_meanrev's gross edge survive being made to fire more
often? #248's 108-cell diagnostic widened the thresholds and found no net-viable cell, but never
asked whether widening preserved the gross edge or simply bought trades by accepting worse
setups. Different findings, different consequences, and the data separating them was never
computed.
A limit-order queue simulator has no target: its measured prize was one asset with ~8bp of headroom
below maker, and §6 removes that asset.
Ledger
Two rows — Arm A (a_priori) and Arm B (fitted, since the params were selected even though this
evaluation selects nothing further). Both diagnostic_only, both series_missing: true so the
CSCV matrix refuses them. verify_chain clean at 82 rows.
docs(experiments): rsi_meanrev's edge is selectivity, not alpha — the last live lead closes (#255)
Closes #253. Answers the single open question left by #252. Documentation only — the simulator
defect found along the way is filed as #254 and deliberately not fixed here.
Result
rsi_meanrev reaches the promotion floor trivially — 21 of 24 assets at oversold=40, so the
pre-declared conditional arm never fired — and has no edge when it gets there.
gross PF at oversold=20 (median n=38) : median 1.1631 gross>1: 14/24
gross PF at every cell with n>=100 : median 0.8938 gross>1: 11/76
net > 1.0 at n>=100, at ANY fee : 0 of 76 (including 0.6% maker)
The edge evaporates exactly when the rule becomes measurable — as a level shift across the
floor, not a gradual decline. The 1.1631 that made this look like the best of the three rules in
#252 is what n=38 looks like. All 11 gross-positive cells at n≥100 are listed in the document
(enumeration, not selection); the best is 1.215 gross and 0.678 at maker.
Hypothesis (b) from the pre-registration, confirmed: the rule is unpromotable by construction.
Pre-registration in the file this time
§7 of #252 recorded against itself that its declaration lived in a dispatch brief rather than the
script. This one's is in the docstring, written before the run — including the conditional arm's
trigger, threshold and reporting rule, so the widening could not become a post-hoc rescue.
Two method notes against ourselves
1. The declared statistic was underpowered for its own question. The slope is reported as
declared — mean −0.0386 per +100 trades, median −0.0197, 15/24 negative — and then flagged:
the relationship is a threshold effect at the floor, not linear, and two barely-trading assets
(PAXG-USD n 12→145, WLD n 1→71) supply most of its 0.35 stdev. The partition on the pre-declared
n≥100 floor is the decisive reading. Reporting the stronger analysis while omitting that it
wasn't the declared one is the exact failure pre-registration exists to prevent, so both are in.
2. The monotonicity assumption is violated on 3 of 24 assets — and the cause is not the rule.
The simulator defect (#254)
UNI-USD's trade count collapses 309 → 9 between oversold 30 and 35 — loosening the entry
threshold reduced trades 34×. Diagnosis: 9 closed trades, 0 open, last exit 2021-11-15,
i.e. the rule stopped detecting for ~40,000 bars.
if position is None and pending is not None:
entry_touched = _touches(candle, pending.entry)
if not entry_touched:
continue # pending persists — foreverA pending setup never expires. If price drifts away so neither entry nor stop is touched again,
rule.detect() is never called for the rest of the series. The strategy freezes silently, and a
frozen backtest is indistinguishable from a selective one — which is how it survived unnoticed.
It biases toward low trade counts, i.e. toward exactly the "unmeasurable" verdicts these documents
have been issuing, and it diverges from production semantics (the live path re-detects each cycle).
#252's headline results were checked and are clean — the three sub-floor turtle assets each
trade to within 1–2 days of their series end, so their lo...
keel v0.7.0
Built from 5c434d3. Version binds to this hash:
keel --version reports keel 0.7.0+5c434d393f33 [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.7.0-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.
Fixes
fix(strategy): price fills at the taker rate, and wire the dormant PBO gate into promotion (#247)
Two defects in the same family, found by docs/experiments/2026-08-11-hourly-backtest-turtle-breakout.md: a gate evaluating rules on numbers that were wrong, and a gate that was never evaluating at all. Both are cases of output that looked like a check had happened.
Baseline on 2218f46: 2696 passed, 1 skipped. This branch: 2712 passed, 1 skipped (+16 tests). No config VALUE changed; nothing under docs/experiments/ was edited.
Fix 1 — the fee defect
The simulator fills market-style at next-bar open — a marketable order crossing the spread, i.e. taker — but priced fills at the maker rate (0.006) in four places: backtest()'s default, cli._SIM_FEE_PCT, portfolio_sim.run's default, and paper._DEFAULT_FEE_PCT.
The config was the half of the project that was right the whole time. config.yaml's own fees: comment reads "taker_pct is the sim's default — it fills market-style at next-bar open", and keel_core.config.FeesConfig has carried taker_pct = 0.012 since it was written. Only the code disagreed.
Round-trip friction ran at 1.30% of notional instead of 2.50% — a 1.92× understatement of the dominant cost term. can_promote reads expectancy, win rate and realized R:R straight off these stats, so the promotion gate has been evaluating every rule at half the price of trading it.
Mechanism chosen — all three of §9.2's options, not one
§9.2 offered (a) read from config, (b) fix the library default, (c) print the rate. They solve different problems, so all three ship.
(a) Config is the source of truth. rules backtest/rules promote and simulate thread config.fees.taker_pct into backtest()/portfolio_sim.run/edge_table; agent passes it to PaperTrader. A deployment on another volume tier or venue moves the rate by editing config, not code. Every caller checked — the two production backtest() call sites are commands/rules.py (took the default; now threaded) and sim/report.py::edge_table (already parameterised; its cli.py caller now passes config). _SIM_FEE_PCT's five uses in simulate collapse to one sim_fee_pct local so the edge table, account pass, tier matrix and both benchmarks stay like-for-like.
(b) The library default is backtest.TAKER_FEE_PCT (0.012) for callers with no config. Deliberately the conservative choice of the two published rates: a default that overstates cost cannot manufacture an edge that isn't there, and the one that understated it already did. A test pins it equal to FeesConfig.taker_pct, so drift is a CI failure rather than a discovery — kept as two constants so backtest.py stays free of config coupling (the same reason it imports no concrete Rule).
(c) The rate is printed — the part that matters most.
rule 1 (pullback_continuation): n_trades=0 win_rate=0.00% expectancy=0 profit_factor=0 \
max_drawdown=0 fee_pct=1.2000% (taker, from config `fees.taker_pct`)
The source is reported too, because "from config" and "library default" answer different questions when a deployment's config isn't the one the operator thought they were running. The simulate report's edge table carries the rate above it, and renders "fee rate not recorded" when a caller omits it — a gap should look like a gap. Prior numbers were unfalsifiable by their readers; that is how a 2× cost error survived in a shipped gate.
Not retroactive, in two places worth naming
- No stored result is rewritten. Past
docs/experiments/numbers were real outputs of the code as it stood. Annotated, not restated. paper.pyis forward-only. Fees are journalled intoorders(mode='paper')as realized cash at fill time, so this changes what the paper account records from the next fill onward and rewrites nothing stored. The paper-forward's history is therefore spliced: pre-PR fills are maker-priced and optimistic, andtrack_record()pools both until they age out. Restating a journalled account's realized cash would be falsifying its own audit trail — a worse defect than the one being fixed.
Fix 2 — wiring the dormant PBO/CSCV gate
cscv.py, deflate.py, matrix.py, g4_pbo_gate, PBOGate, and research: pbo_max/slope_floor in every config — all shipped, none connected. The gate function and its thresholds both existed; the wire between them did not.
Interface, and why this shape
can_promote(stats, cfg)→can_promote(stats, cfg, pbo=None, gate=None), returning aPromotionDecision. The four floors moved out tocheck_floorsunder their own name. The rename is the point: a function calledcan_promotethat ignores overfitting reads as authoritative to every caller, which is how the gap survived review.pbo=NoneisNOT_RUN, andNOT_RUNdoes not promote. A distinct state, notbool | None— "we did not check" and "we checked and it was fine" are different claims, and collapsing them is the defect.PromotionDecisionkeepspromotableandfloors_passseparate, so an operator sees which axis stopped them.transitionthreads it and will not promote without evidence. Demotion deliberately does not require it — missing evidence must block a rule moving toward real money and must never block pulling one back.pbo_gate_from_configreads the shipped thresholds. None invented, no config value moved.
The gate is satisfiable — which matters
An unsatisfiable gate just pushes everyone to --force. rules promote --pbo-session <label> runs the same ledger → build_matrix → cscv.pbo pipeline as keel trials pbo, so the number the gate applies is one an operator can reproduce by hand. Naming a session with no usable trials is a hard error, not a quiet downgrade to "not run": asking for the check and not getting one must stay distinguishable from not asking. --force remains the WARNING-logged bypass it already was.
rule 1 (pullback_continuation): overfitting check = not_run
- n_trades 0 < min_trades 100
- overfitting check (G4 / PBO-CSCV) NOT RUN: no trial matrix was supplied, so the
probability that this rule's parameters were selected by overfitting is UNKNOWN --
which is not the same as low, and is not a pass. Supply a CSCV result (see
`keel trials pbo`), or bypass deliberately and on the record with
`keel rules promote --force`.
rule 1 (pullback_continuation): status -> candidate
Same defect fixed one layer out: report._render_pbo_section defaulted gate_ok to True, so a report with PBO diagnostics and no thresholds applied printed "G4: PASS". Now renders NOT EVALUATED.
Verified end-to-end through the real pipeline: 12 pure-noise trial columns → PBO 0.83, slope −0.66 → correctly fails G4, consistent with §78.8's random-walk calibration (−0.61). The gate discriminates; it isn't just wired.
Existing tests changed, and why
| Test | Change | Why |
|---|---|---|
tests/fixtures/baseline_backtest.json |
Regenerated via the existing dev script | The "strategy change is intended" case that script documents. PF 1.6143 → 1.2694, expectancy 1368.48 → 692.08, max_dd 12177.59 → 14220.67 — with n_trades (13) and win_rate (0.4615) unchanged, which is the check that this was a costing change and not an accidental change to fill logic. Pinning it to the superseded rate would have preserved a golden that no longer describes the engine. |
test_paper.py::FEE_PCT |
Decimal("0.006") → mirrors PaperTrader's own default |
These tests recompute expected cash by hand; a hardcoded rate silently stops testing the default — exactly how the mix-up went unnoticed. |
test_promotion.py floors tests (9) |
Call check_floors |
Same assertions, renamed callee. |
test_promotion.py 2 × transition promote tests |
Now pass pbo=_pbo() |
They asserted the exact behaviour this PR removes. They document the new requirement, alongside a new test pinning that the same call without evidence does not promote. |
test_backtest.py::test_fees_and_slippage_applied_on_entry_and_exit was left alone — it passes an explicit rate and pins the arithmetic, not the default, so it stays valid.
Gates
$ uv run ruff check keel tests packages scripts
All checks passed!
$ uv run mypy
Success: no issues found in 224 source files
$ uv run pytest -q
2712 passed, 1 skipped in 32.93s
Reviewer notes
- **
rules promoteno longer promotes without `--pbo-sess...
keel v0.6.1
Built from 9417343. Version binds to this hash:
keel --version reports keel 0.6.1+9417343ecc0a [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.6.1-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.
Fixes
fix(deploy): upgrade every keel distribution, and verify all of them (#243)
Found while deploying v0.6.0. The ~/keel venv, immediately before the install:
keel-broker-api 0.5.5
keel-broker-coinbase 0.5.5
keel-broker-fake 0.5.5
keel-core 0.5.5
keel-trader 0.5.7 <- only this had been upgraded, across TWO releases
The documented step installs keel_trader only. Its siblings are required with no version, so
the keel-core 0.5.5 already on disk satisfied keel-core and was never touched. The engine ran
new code against old libraries for two releases.
The verification step could not see it. The README calls keel --version "the check that
matters"; it reports the keel-trader distribution's version and nothing else, so it printed
0.6.0 while keel-core sat at 0.5.5. A check blind to the failure mode is worse than none,
because it is trusted.
Reproduced end to end from the real wheels (0.5.5 set built from 2a58ffb with the versions
rewritten, 0.6.0 set built from 2a58ffb unchanged), running exactly what the README said:
$ uv pip install --python .venv --find-links Release Release/keel_trader-0.6.0-py3-none-any.whl
- keel-trader==0.5.5
+ keel-trader==0.6.0
$ uv pip list --python .venv | grep keel
keel-broker-api 0.5.5
keel-broker-coinbase 0.5.5
keel-broker-fake 0.5.5
keel-core 0.5.5
keel-trader 0.6.0
$ .venv/bin/keel --version
keel 0.6.0+bbbbbbbbbbbb [release]
1. The install command
V=0.6.0
gh release download "v$V" --repo CodeGateSoftware/keel --pattern '*.whl' --dir Release/
uv pip install --python .venv --find-links Release \
Release/keel_core-$V-py3-none-any.whl \
Release/keel_broker_api-$V-py3-none-any.whl \
Release/keel_broker_coinbase-$V-py3-none-any.whl \
Release/keel_trader-$V-py3-none-any.whl
.venv/bin/keel versions
.venv/bin/keel statusA wheel path is a direct requirement — that exact file is installed regardless of what is
already there — so naming all four is what actually moves them. --find-links Release stays, now
resolving the pinned siblings locally rather than from PyPI where they do not exist. Installing
by path rather than by bare name is unchanged and still explained: keel on PyPI is an
unrelated project.
Deliberately not Release/*.whl, the obvious route. uv build --all-packages builds every
workspace member and gh release create dist/* publishes all of them, so the glob installs two
distributions production must not have. Measured:
$ uv pip install --python .venv --find-links Release Release/*.whl
keel-broker-fake 0.6.0
keel-broker-robinhood 0.6.0
pynacl 1.6.2
cffi 2.1.1
The four named wheels are keel-trader's whole dependency closure — nothing more, nothing less.
2. Pins — decided yes, with wheel-metadata evidence
Every intra-workspace dependency is now == the workspace version, in the root and in all four
package pyproject.tomls.
[tool.uv.sources] workspace = true governs resolution during development and says nothing about
published metadata, so I built and read the wheel rather than assuming. Before:
$ unzip -p keel_trader-0.6.0-py3-none-any.whl '*/METADATA' | grep Requires-Dist
Requires-Dist: click>=8.4.2
Requires-Dist: keel-core
Requires-Dist: keel-broker-api
Requires-Dist: keel-broker-coinbase
After:
Requires-Dist: click>=8.4.2
Requires-Dist: keel-core==0.6.0
Requires-Dist: keel-broker-api==0.6.0
Requires-Dist: keel-broker-coinbase==0.6.0
The constraint lands, and the pin alone fixes the bug for anyone who ignores the README — the
old command against the new metadata, from the same 0.5.5 starting state:
$ uv pip install --python .venv --find-links Release Release/keel_trader-0.6.0-py3-none-any.whl
- keel-broker-api==0.5.5 + keel-broker-api==0.6.0
- keel-broker-coinbase==0.5.5 + keel-broker-coinbase==0.6.0
- keel-core==0.5.5 + keel-core==0.6.0
- keel-trader==0.5.5 + keel-trader==0.6.0
Why pin, given the README fix already works. The README binds only the person reading it. The
pin binds pip. These are not third-party dependencies with independent release cycles — they are
cut from this repo, at one version, in one uv build --all-packages, so == states a fact rather
than guessing at compatibility, and there is no version skew it would wrongly forbid. The cost is
that a bump must move the pins; the bump commit already edits version in all five files, so this
is incremental work on a commit that exists rather than a new obligation, and
tests/test_packaging.py fails the build if a pin is left behind — the failure mode of a forgotten
pin is otherwise silent (the wheel still builds and still installs, it just stops forcing the
upgrade). >= was rejected: it forbids an older sibling but not a mixed install, and mixed is
the thing that happened. uv.lock is unchanged — workspace sources already resolved to the members.
3. A verification step that can fail
New keel versions, registered in keel/cli.py. It prints the same build-identity line, then
every keel-* distribution in the running interpreter's environment, and exits non-zero when they
disagree. No config, no database, no network, so a non-zero exit is unambiguous.
Healthy — the deployment after the new install command:
$ .venv/bin/keel versions
keel 0.6.0+deb8fa7e978d [release]
keel-broker-api 0.6.0
keel-broker-coinbase 0.6.0
keel-core 0.6.0
keel-trader 0.6.0
ok: 4 keel distributions, all at 0.6.0.
exit=0
Mismatched — same venv, keel-core forced back to 0.5.5 with --no-deps (the pin has to be
bypassed on purpose now):
$ .venv/bin/keel versions
keel 0.6.0+deb8fa7e978d [release]
keel-broker-api 0.6.0
keel-broker-coinbase 0.6.0
keel-core 0.5.5
keel-trader 0.6.0
error: PARTIAL INSTALL: 4 keel distributions at 2 different versions (0.5.5, 0.6.0).
`keel --version` reports keel-trader's version alone and cannot see this. Reinstall
every wheel by path (README, 'Deploying a new version').
exit=1
keel --version on that same venv now says so too, on stderr, leaving its stdout string exactly
what it was (the release workflow greps it):
keel 0.6.0+deb8fa7e978d [release]
warning: PARTIAL INSTALL -- this line reports the keel-trader distribution only, and the
other keel distributions do not agree with it. Run `keel versions`.
It ships in the wheel, not in scripts/. scripts/ is operator tooling and is not packaged;
a deployment is a .venv beside a Release/ directory with no checkout of this repo, so a script
there could not be run without first fetching it. The check has to travel inside the artifact it
is checking. The rules live in keel.version.InstallReport as a pure value, tested without
installing anything; the command is rendering and an exit code. The release workflow now runs
keel versions against the wheels it just built, so a release cannot publish a set that disagrees
with itself.
keel-broker-fake
It should not be there and the check now says so: a release build with keel-broker-fake
installed exits 1 with uv pip uninstall --python .venv keel-broker-fake. A checkout does not
fail — that is exactly where the fake belongs, and a check that cried wolf on every developer's
machine would be ignored by the time it mattered. tests/test_packaging.py also asserts nothing
in the workspace can pull it in as a runtime dependency. The dev group is untouched.
It is inert today (nothing calls load_broker()), but it registers a fake venue under
keel.brokers, and "inert" is a property of this release rather than of the package. The README
and the RELEASING asset table now say not to install it, which is also the reason the install
command names wheels instead of globbing.
Gates
$ uv run ruff check keel tests packages scripts
All checks passed!
$ uv run pytest -q
2696 passed, 1 skipped in 30.51s
SKIPPED [1] packages/keel-broker-api/keel_broker_api/conformance/suite.py:254
$ uv run mypy
Success: no issues found in 224 source files
Baseline on origin/main, re-measured in this worktree, was 2669 passed / 1 skipped; +27 tests,
same single expected skip.
No version bump here — that stays a separate reviewed change. docs/experiments/ untouched.
Docs, CI & tooling
docs: separate paper from live, so a figure from one is never read as the other (#242)
Why
Conflating the paper and live accounts...