Skip to content

keel v0.9.3

Choose a tag to compare

@github-actions github-actions released this 18 Aug 11:40
· 47 commits to main since this release
4923820

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.

⚠️ Never install by bare name. The distribution is 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_HOUR bars (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_aligned against detect_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 _RunningState running 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.