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.