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-session. Intended and central. The escape hatch is the pre-existing, audited--force.rules backtestdegrades to the library default (not an error) if no config is loadable — it's read-only and useful on a bare checkout — but says which source it used. Both values are the taker rate, so the fallback can't flatter.- Fee scope covers
_SIM_FEE_PCT,portfolio_sim.runandpaper.pybecause §9.2 explicitly asks for them; they were0.006by deliberate agreement with the wrong default.
Research & validation
docs(experiments): the promotion floor is reachable, and turtle_breakout fails it on edge (#245)
What this is
An experiment write-up recording a negative finding, plus one diagnostic_only trials-ledger row. Documentation only — no code, no config, no parameter, no rule status, no version bump.
docs/experiments/2026-08-11-hourly-backtest-turtle-breakout.md
The finding
turtle_breakout is substantially keel's strategy: 5 of the 6 rules in keel-live.db run it. config.live-sandbox.yaml:62-94 keeps those five live despite never clearing the promotion gate, on the explicit premise that the gate is unreachable — "100 trades in ~39 years" for BTC, "~84 years" for ADA, and "waiting for the gate is not a slower path to the same place; it is no path."
The same rule, same constructor defaults, same cached candles, re-run on ONE_HOUR instead of ONE_DAY across 19 assets:
- The floor is reachable. 195–274 trades hourly against 4–13 daily, over the same calendar window (BTC: 1,850 daily bars and 44,393 hourly bars are both 5.07 years). 18 of 19 assets clear
min_trades=100outright; the gate is ~1.9–2.1 years away, not 31–84, and 5 years of hourly history are already cached. The daily trade rate spans 1.19–3.18/yr across the five live assets; the hourly rate spans 47.3–54.1/yr. The rate is set by the bar clock, not the asset. - And the rule fails on edge, not on sample size. At the fee the CLI actually charges: PF 0.270–1.042, 18 of 19 below 1.0, win rates 15–27%.
- The one apparent winner is a fee artifact.
keel/commands/rules.py:100callsbacktest(rule, candles)with nofee_pctand takes the maker default0.006, whileconfig.paperforward.yaml:111-117declarestaker_pct: 0.012and states in its own comment that taker is the sim's default because it fills market-style at next-bar open. At0.012, zero of 19 clear PF 1.0 (ZEC 1.042 → 0.736). The same0.006is pinned incli.py::_SIM_FEE_PCT,portfolio_sim.runandpaper.py::_DEFAULT_FEE_PCT, sopromotion.can_promotehas been gating on half-cost stats too. Not fixed here — raised as a follow-up issue. - Independent convergence. DOGE's shortlisting figure is daily PF 1.489 on n=12, but one trade is 63% of gross profit:
1.489 × 0.37 = 0.551, against an hourly PF of 0.558 on n=261. Two methods sharing no data beyond the price series, 0.007 apart.
What it argues
- The standing exception's premise is false; its conclusion may still hold on the caps and guards, and this PR deliberately does not edit that live config — §8 says why and proposes the honest replacement text for the reviewer who owns it.
- Asset selection is not the lever. Negative on 19 of 19. Adding a twentieth cannot fix it; §73.3's power argument for expansion is untouched, expansion as a performance fix is refuted.
- The strongest objection is argued in §7, not buried.
entry_lookback=40is 40 hours here,atr_period=20is no longer Turtle's N, andTurtleBreakouthard-codesgranularity = ONE_DAYso the rule is handed hourly bars believing they are days and cannot tell. A fair reading is "daily-tuned turtle does not transfer to an hourly clock", not "trend following fails on crypto". §7 states what survives that objection (the trade-count result, the fee defect, the DOGE decomposition) and what does not, and §9.4 proposes the resolving experiment.
Ledger
One row, hourly-turtle-granularity-2026-08-11, kind: ablation, decision: diagnostic_only (it changed nothing, so under spec §4.4 it must not count toward N), provenance: a_priori, series_missing: true. Appended through the shipped ledger.append_trial rather than hand-written, so the hash chain is produced by the same code that verifies it.
$ uv run keel trials verify
chain intact
$ uv run keel trials list | tail -3
77 hourly-turtle-granularity-2026-08-11 turtle_breakout a_priori ablation diagnostic_only [series_missing]
M=77 N_decisions=31
Gates
Baseline re-measured on this branch point before the change; identical after.
$ 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
2696 passed, 1 skipped in 39.80s
docs(experiments): the hourly objection was right, and 0 of 144 tuned param sets clear break-even (#246)
What this is
A companion to docs/experiments/2026-08-11-hourly-backtest-turtle-breakout.md (#245), which
showed daily-tuned turtle_breakout losing on all 19 assets at the realistic 1.2% taker fee — and
which named its own strongest objection in §7 without answering it:
entry_lookback=40means 40 hours on hourly bars, not 40 days. The parameters were never
tuned for this granularity, so the finding may only show that daily params do not transfer.
That objection is the one thing standing between "the rule is negative" and "the measurement was
mis-scaled". This PR runs the test that settles it.
864 trials — 144 pre-declared parameter sets × 6 assets spanning the observed PF range (ZEC,
FET, SOL, DOGE, ETH, BTC) — all completed, 0 errors, every cell priced at taker 1.2%.
Verdict
The objection was correct, and correct does not mean exculpatory. Hourly-appropriate parameters
really are better — mean PF rises 0.419 → 0.634 (+51%), and the winners are the longest
lookbacks exactly as wall-clock theory predicts. It is a 51% improvement on a number that has to
double. Zero of 144 parameter sets average PF above 1.0 across the six assets; 8 of 864 cells
clear it and all eight are the same asset in the middle of a 30× liquidity surge, at sample sizes
half the promotion floor.
| finding | number |
|---|---|
| daily-tuned baseline's rank in the grid | 112 of 144 — the objection was right |
| configs averaging PF > 1.0 | 0 of 144 |
| cells above PF 1.0 | 8 of 864 (0.93%) vs ~43 that noise alone would give at p<0.05 |
| where those 8 are | all ZEC, n=49–67, all adx_threshold=25, all at entry 240/336 |
cells clearing PF 1.0 and min_trades=100 |
0 of 864 |
| Spearman(mean PF, mean n) over 144 configs | −0.77 — edge and sample move apart |
Three things I'd flag for the reader:
- Under-shooting the noise floor is itself the finding. 8 observed against ~43 expected means
the return distribution is shifted so negative that luck rarely reaches break-even. - ZEC's monopoly is probably regime, not edge. Its 180-day median daily quote volume is
$37.9M against a full-history median of $1,229,309 (~30×), and its close ran 36.92 → 510.43
in 2025H2. Every other asset in the sweep trades below its own historical median. The doc is
explicit that this is argued, not proven — no regime split was run, and it names that split as the
highest-value follow-up. - The bind is structural, not a tuning gap. The only productive axis buys PF by trading less.
The best config clearing n≥100 is 0.584; the best overall is 0.634 at n=91.
What is now eliminated, and what to test next
Asset selection (19 assets, #245), granularity (daily→hourly, #245) and parameters (144 sets, here).
What remains is the rule itself or execution cost — and cost is the larger measured lever: the
same six assets at maker pricing average 0.640, which beats the best of all 144 tuned configs at
taker (0.634). Changing the fee is worth more than the entire grid. §8 recommends that, with the
caveat that maker pricing is a different fill model (adverse selection on breakout limits), so
0.640 is an upper bound and is still below 1.0.
Contents
docs/experiments/2026-08-11-hourly-param-sweep-turtle-breakout.mddocs/experiments/2026-08-11-hourly-param-sweep-turtle-breakout.py— the sweep script. Grid lives
in its docstring, which is where it was declared before the run. Paths were adjusted on
copy-in (scratchpad →--db/--out, cache openedmode=ro); noted in §2 and in the docstring.- One ledger row.
No code, no config, no parameter, no rule status, no version bump. The 0.6% fee defect this run
prices around is deliberately still unfixed — §9 records that these numbers are stricter than
the project's own gate (backtest.py:171, portfolio_sim.py:225, paper.py:46, cli.py:1691 all
still 0.006), which is a comparability hazard the doc flags rather than patches.
Ledger
One row, hourly-turtle-param-sweep-2026-08-11, kind: ablation, provenance: a_priori (the grid
was pre-declared), decision: diagnostic_only, n_trials: 864 recorded honestly so nobody
re-runs this grid without knowing it has been paid for.
$ keel trials verify
chain intact
$ keel trials list | tail -1
78 hourly-turtle-param-sweep-2026-08-11 turtle_breakout a_priori ablation diagnostic_only [series_missing]
M=78 N_decisions=31
Gates
$ uv run ruff check keel tests packages scripts
All checks passed!
$ uv run ruff check docs/experiments/2026-08-11-hourly-param-sweep-turtle-breakout.py
All checks passed!
$ uv run mypy
Success: no issues found in 224 source files
$ uv run pytest -q
2696 passed, 1 skipped in 32.78s
Identical to the 2bf5c6f baseline measured before any change.
docs(experiments): at zero fee turtle_breakout makes money and rsi_meanrev does not — two failure modes, not one (#248)
Third in the hourly-backtest series, and the first one whose finding is about the previous two
rather than about a rule. It assumes both companions have been read and does not restate them.
Verdict: the inference the operator drew from the first two documents — that execution cost is
the binding constraint system-wide, since two structurally opposite strategies both fail at 1.2% —
is half right, and the wrong half is the half that would have set the roadmap. Priced at zero,
turtle_breakout is profitable on 4 of 4 assets (BTC 1.090, ETH 1.458, SOL 1.533, ZEC 2.713) and
rsi_meanrev is not (BTC 0.775 at n=255, FET 0.907 at n=284; ZEC 1.094 dies at a 0.093% fee).
Turtle has a gross edge that cost destroys; rsi_meanrev has no gross edge for cost to destroy.
Pooling them predicts that maker fills rescue both — which cutting cost to zero, an intervention
strictly better than any maker rate on any venue, does not.
What is in the PR
docs/experiments/2026-08-12-fee-curve-and-rsi-meanrev.mddocs/experiments/2026-08-12-fee-curve-and-rsi-meanrev-sweep.py— the abandoned first
rsi_meanrevgrid, committed because it was paid for (448 trials) and because the reason it was
abandoned is a finding. Its pre-registration docstring is unedited, stale numbers and all.docs/experiments/2026-08-12-fee-curve-and-rsi-meanrev-diag.py— the diagnostic grid.- Two rows appended to
docs/experiments/trials-ledger.jsonlvia the shipped
keel/research/ledger.py::append_trial.
Paths were adjusted when the scripts were copied in (deployment sys.path / ~/keel/keel.db /
scratchpad output → --db / --out with repo-relative defaults, cache opened mode=ro); the
grids, fee, asset lists, metrics, resume logic and per-trial bodies are the runs'. Recorded in
both docstrings and in the document.
No code, no config, no parameter, no rule status, no version bump, no demotion. The fill model
is deliberately not touched — §8 argues that changing the fee rate without changing it would be
a worse costing error than the one #247 just fixed, because it would be deliberate.
The three arguments worth reviewing
1. Two failure modes, not one (§3, §7). The symptom at 1.2% converges; the disease does not.
The document argues explicitly what pooling would have cost: a maker-execution programme sold as a
platform-level fix, validated on turtle where it works, rolled out to rsi_meanrev where zero
cost leaves BTC at 0.775. It also names the error in the other direction — pooling reads as
evidence against the venue and would discard a real positive result.
2. The maker pivot is justified, narrowly (§4, §8). Break-even fee_pct measured by
bracketing rather than interpolated — BTC 0.068%, ETH 0.433%, SOL 0.751%, ZEC
1.741%, a 26× spread across four assets running the same rule on the same clock. At the
0.6% maker rate SOL and ZEC clear, ETH is a near miss, BTC is unreachable. The first SOL bracket
was placed from a linear interpolation and missed in the direction that flatters the maker
argument; §4 records that too. And the two assets maker execution rescues are exactly the two
below min_trades=100 (n=92, n=50), while the two with enough trades to promote stay
unprofitable — the companion's Spearman −0.77 bind reappearing on a different axis.
3. The document records its own wrong turns (§5, §6). The prediction that
support_proximity_pct was the binding gate was wrong (10× widening buys ×1.19), and the
correction — that level_min_touches was co-binding — was also wrong (×1.185, indistinguishable
from the gate it replaced). oversold binds alone (×3.93). And the abandoned grid's own n≥100
subset (48 cells) already gave the answer the purpose-built grid later produced, to within 0.016 in
max PF.
Re-derivation
Every figure was recomputed from the raw JSONL rather than taken from a running summary, and
several summary figures did not survive: the first grid's median n (17, not 6), its max n (224, not
80), its trial accounting (448 rows / 432 distinct cells / 16 resume duplicates), the sample sizes
behind its apparent winners (n ∈ {2, 3, 16}), and the claim that every fee-curve config sits
below the 100-trade floor (BTC n=123 and ETH n=121 clear it). The first grid's raw file was still
being appended to during analysis — its producing process was still resident hours after
abandonment — so all figures come from a frozen snapshot whose md5 is recorded in the document.
Ledger — two rows, not one
rsi-meanrev-grid-abandoned-2026-08-12 and rsi-meanrev-diagnostic-and-fee-curve-2026-08-12, both
kind: ablation, provenance: a_priori, decision: diagnostic_only, both series_missing.
Neither counts toward N. §10 argues the split: provenance is a per-row claim about a
pre-registration and no single declaration covers both grids; the second grid was declared after
seeing the first's data; and the ledger's job is to stop trials being re-spent, which a merged row
would defeat by burying the dead grid inside a row headlined by the diagnostic result.
Trial budget
Reported honestly: 617 today — 448 (abandoned grid) + 108 (diagnostic grid) + 61 (fee curve,
zero-fee and bracketing cells) — on top of the prior document's 864, for 1,481 on this line of
work. Both grids were pre-declared in their script docstrings, hence a_priori on both rows. Of
the 448, only 432 are distinct cells and 16 are resume re-runs; all 448 are counted, because a
duplicate buys no information but consumed a draw.
Limits
No walk-forward, no out-of-sample split, no CSCV/PBO on these configurations (both rows are
series_missing, so they cannot enter the matrix #247 just wired into promotion), one rule family
with gross edge and one without, 4–6 of 19 assets, the same cached candles and the same 2021–2026
cycle as everything else. Every fee-curve configuration is the argmax of a 144-cell search and none
of them is an edge estimate; §9 states that at full strength and specifies what a maker re-test
would have to look like to avoid laundering the bias.
Other changes
chore: bump to 0.7.0 for the taker-fee correction and the live PBO gate (#250)
Bumps all six workspace packages and every inter-package == pin from 0.6.1 to 0.7.0, and
relocks. No other code changes.
Closes #249.
Why this release matters operationally
The ~/keel deployment is running 0.6.1, whose backtest() still defaults to
fee_pct=Decimal("0.006"). The simulator fills market-style at the next bar open — taker
behaviour — so every backtest run on the box today is costed at the maker rate. #247 fixed that on
main six commits ago and it has never been released, which means the promotion gate is currently
reading numbers that are roughly twice as favourable as the fee schedule the config declares.
Why minor, not patch
#247 is not a bugfix in the version-number sense. It changed can_promote's return type from a
bare (bool, reasons) tuple to a PromotionDecision, and it added a blocking condition that did
not previously exist: a missing trial matrix (pbo=None) is now NOT_RUN, appears in reasons,
and fails closed instead of passing silently. A rule that cleared promotion under 0.6.1 can be
refused under 0.7.0 with identical stats.
Every backtest figure this release produces also differs from 0.6.1's, for the fee reason above.
Callers who diff results across the upgrade should expect that and not treat it as a regression.
What is in the release
| PR | |
|---|---|
| #247 | fix(strategy): price fills at the taker rate, and wire the dormant PBO gate into promotion |
| #248 | docs(experiments): at zero fee turtle_breakout makes money and rsi_meanrev does not — two failure modes, not one |
| #246 | docs(experiments): the hourly objection was right, and 0 of 144 tuned param sets clear break-even |
| #245 | docs(experiments): the promotion floor is reachable, and the rule fails it on edge |
Verification
uv lockrefreshed, so the release build is not dirty (same reason as dc99601).grep -rn "0\.6\.1" --include=pyproject.toml --include=uv.lock . | grep -i keel→ empty.uv run pytest -q→ 2712 passed, 1 skipped.tests/test_packaging.pyfails the build if a
pin is left behind, so a green suite is itself the pin check.- Diff is version lines only — no third-party constraint,
requires-python, or
keel/_build_info.pywas touched (the release workflow stamps the latter).
After merge: Actions → Release → Run workflow with input 0.7.0, then install the four deployment
wheels into ~/keel/.venv by path and run keel versions + keel migrate against both databases.