Skip to content

keel v0.13.3

Choose a tag to compare

@github-actions github-actions released this 02 Sep 10:49
· 201 commits to main since this release
418c1f5

Built from 418c1f5. Version binds to this hash:
keel --version reports keel 0.13.3+418c1f57ec35 [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.13.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.

Desktop app (macOS / Windows)

⚠️ These builds are not code-signed, so your computer will warn you.
Apple's certificate is $99/yr and Azure Trusted Signing is ~$120/yr, and
keel cannot currently afford either. There is no cheaper tier and no free
open-source option on either platform. (Windows is worse value than it looks:
since 2024 even an EV certificate no longer grants an instant SmartScreen
pass — reputation is earned from download volume over time.)

Prefer no warning at all? Install from the wheels above instead — nothing
is downloaded as an application, so nothing objects. Needs a terminal and
Python 3.14+.

Full explanation, including how to check what you downloaded:
https://github.com/CodeGateSoftware/keel/blob/main/docs/desktop-install.md

macOS — the first open is refused. Open System Settings → Privacy &
Security
, scroll to the message about keel, and choose Open Anyway.
You will only do this once.

Windows — SmartScreen shows "Windows protected your PC". Choose More
info → Run anyway
.

Verify what you downloaded before you run it. Every artifact carries a
GitHub build attestation binding it to this repository, this workflow and this
commit — which is the question a code-signing certificate answers too:

gh attestation verify <file> --repo CodeGateSoftware/keel

A SHA256SUMS-<platform>-<arch>.txt file per build is attached as well
(SHA256SUMS-macOS-arm64.txt, SHA256SUMS-macOS-x86_64.txt,
SHA256SUMS-Windows-x86_64.txt) -- pick the one matching what you
downloaded. If either check fails, do not run it.

The app opens keel in your browser; there is no terminal involved. Your
config, database and credentials live in your user Application Support /
LocalAppData folder and are never touched by an update.

Other changes

chore(actions): name every run, and stop re-scanning the merge commit (#674)

1. run-name on all seven workflows

The Actions list titles every run with the commit message, so one push to main produced four entries all reading Merge pull request #673 from CodeGateSoftware/chore-v0.13.2-bump — four different checks that looked like one job running four times.

12:22:08Z  security          ev=push  sha=6ca893a  title=Merge pull request #673 from …
12:22:08Z  Migrate database  ev=push  sha=6ca893a  title=Merge pull request #673 from …
12:22:08Z  CI                ev=push  sha=6ca893a  title=Merge pull request #673 from …
12:22:08Z  Code Quality      ev=push  sha=6ca893a  title=Merge pull request #673 from …

Applied to seven, not the four that collide today — install-smoke and installer-smoke already run on PRs, and a release run under a commit-message title is the same confusion, just rarer.

Deliberately not github.event.head_commit.message. That was the obvious interpolation and it's the wrong one here: this repo writes long multi-paragraph commit messages, GitHub already shows the commit beside every run, and re-titling with it restores exactly the clutter being removed. What the default title drops is the workflow's identity, so that's what run-name carries — name, event, ref:

CI · push · main
Security audit · pull_request · chore-actions-runname

2. security.yml no longer runs on push

Both its jobs ran a second time on the merge commit to re-answer what the PR run answered four minutes earlier. dependencies audits uv.lock, which the merge doesn't change, and neither job reads anything a merge could combine differently.

Measured over the last 40 runs: 11.4 minutes, ~2.5 per merge (30s dependencies, ~2min codeql).

ci.yml keeps its push trigger. When two PRs merge in quick succession the post-merge run is the only thing that ever tests the tree that actually landed — and that argument doesn't transfer to a dependency audit.

⚠️ The tradeoff, which your rationale didn't cover

Code scanning reports alerts against the default branch, and that baseline is refreshed by analyses of the default branch — which now means the weekly cron alone. A finding introduced by a merge, or an alert a merge fixes, can take up to seven days to appear or clear in the Security tab.

PR analysis is unaffected and still runs on every change, so nothing reaches main unscanned; what's slower is the summary of what's on main, not the scanning. It's recorded in the file with the note that if the lag ever matters, restore push rather than shortening the cron — a daily scan pays most of the cost for a fraction of the freshness.

Happy to revert this half alone if you'd rather keep same-day Security-tab accuracy; the run-name half stands on its own.

Verification

New pin discovers every *.yml from disk rather than listing them, so a workflow added tomorrow is covered on the day it lands. 4 mutants, 4 killed — including one asserting the commit-message interpolation is rejected. Full suite 5110 passed / 3 skipped; ruff clean.

fix(update): retry a transport stall, and stop paying 466 MB before the step that fails (#675, #676) (#677)

Closes #675 and #676. Both came out of the failed keel update to 0.13.2 this morning.

downloading keel_core-0.13.2-py3-none-any.whl
update did not complete: could not download …: The read operation timed out

The asset was healthy the whole time — HTTP 200, 51,701 bytes, 0.82s a minute later, download counter still 0. A transient stall cost the entire update.

#675 — retries

_download_file made one attempt, and _HTTP_TIMEOUT_SEC = 15 is a per-socket-read timeout, not a deadline for the transfer — so a CDN going quiet for fifteen seconds fails a 51 KB download. Five wheels, five independent chances to trip. _http_get had the same shape and aborts the update before it even starts.

The trap this avoids. urllib.error.HTTPError is a subclass of OSError, so the obvious except OSError: retry retries a 404 as eagerly as a dropped connection — three times wrong instead of once — and retries a 403, which on the unauthenticated releases endpoint is the rate limit, spending the budget that just ran out. _is_retryable judges an HTTPError by its status and treats everything else as transport.

429 is deliberately not retryable, even though it's transient. It means the 60-per-hour budget is spent, and asking again is the one response that can't help; _http_get already turns it into a message naming the manual procedure, which needs no API call at all.

Timeout 15 → 30: with retries in hand, 30-and-retry recovers from more than 60-without while giving up on a genuinely dead host sooner. Backoff is a tuple rather than a formula so the added latency of a doomed update is legible at a glance — 4 seconds, once.

#676 — order

Backups ran before any download, on the reasoning that "if anything after this point half-happens, the databases' pre-update state exists on disk."

That guarantee is real and unchanged here: a download cannot make anything half-happen to a database, because _download_file writes only into Release/, and the two steps that do touch one — install and migrate — still run after every backup exists. The existing pin (_FakeOps.install asserting backups are already on disk and open cleanly as SQLite snapshots) passes untouched.

What the old order did was spend the expensive work before the failure-prone one: ~466 MB of sqlite3.backup() across three databases, thrown away by a stall on the first wheel. Backups are timestamped and deliberately never deleted, so the retry left a second full set behind rather than reusing the first — ~/keel holds 7.0 GB of them going back to 0.9.1.

Plan and gate wording updated to match: back up first → then back up.

Verification

12 mutants, 12 killed, and the last three were the lesson:

  • _with_retries being correct proves nothing about _download_file using it. Every retry test called the helper directly, so removing the call from the production downloader left them all green. Same for _http_get. Both now have tests that drive the real function through a monkeypatched urlopen.
  • backups=() on a failed download passed against a glob("*.bak-before-*") implementation until a test seeded an earlier run's backup — which is the normal state of a real launch folder, not an edge case. Reporting someone else's backup as this run's would send an operator to restore from the wrong file.

One more worth recording: a mutation whose anchor appeared three times never applied and read as a survivor until the count was checked.

Full suite 5124 passed / 3 skipped; ruff and mypy clean.

Note

This does not fix the update you ran — re-run keel update whenever; the release is healthy. This makes the next stall a non-event.

feat(web): expect a tunnel's own hostname, one name at a time (#648, partial) (#678)

⚠️ This does not complete #648, and nothing remote should ship on it. Two of the five requirements are met; three are not. The issue stays open, and docs/remote-access.md says so at the top rather than leaving it to be discovered by someone who read a changelog line and exposed a console.

The problem

A Cloudflare Tunnel forwards to loopback and presents the app's public domain in Host:. The DNS-rebinding defence refuses it — correctly, and for exactly the same reason it refuses evil.example: from inside the process the two are indistinguishable, both being names that resolve to a machine this server did not bind.

Nothing in the request can tell them apart. Only the operator can, so only the operator can name one:

keel serve --external-host keel.example.com

Extends, never replaces

Checked before the bind-derived rules and changing neither — every loopback spelling still answers, every unlisted name is still refused, and the port check still applies to a proxied request. Matching is case-insensitive because DNS is; an operator whose correct-looking config silently refused everything would have no way to see why.

No wildcards, refused at startup. *, *.example.com, .example.com, 0.0.0.0, ::, any, all and the empty string all raise. A wildcard is not a wider expectation, it is the absence of one — * answers evil.example exactly as readily as the operator's own domain, which is the attack. A leading-dot suffix admits every subdomain an attacker can provision, and a tunnel presents one name.

The bug its own test found

Worth keeping, because it's the whole argument for testing a control rather than reading it.

ServeConfig.host_policy is a lazy property, so HostPolicy.__post_init__'s wildcard guard fired on the first request, not at startup. --external-host '*.example.com' started cleanly, said nothing, and would have raised somewhere inside a handler. My docstring claimed the opposite. The command now builds a policy eagerly and converts the refusal into click.BadParameter.

The threat model, written down

docs/remote-access.md states what each transport actually guarantees rather than "it's encrypted":

who can read it what the operator is trusting
Loopback only anything running as the operator the machine
WireGuard mesh only the operator's enrolled devices the coordination server for keys, not content
Cloudflare Tunnel Cloudflare, in the clear at its edge Cloudflare with the plaintext of every page and session token

That third row needs saying out loud. Cloudflare terminates TLS. In that mode the operator isn't trusting it to route traffic — they're handing it the readable contents of a console showing positions, balances and attestations. That may be an acceptable trade; it is not a neutral one.

Still open (#648 stays open)

  • Session tokens over a remote origin — entropy stated against an internet-reachable attacker, issuance rate-limiting, brute-force posture. On loopback there was no attacker to rate-limit.
  • Secure-context re-verification — the service worker and manifest work because http://127.0.0.1 is a secure context by specification. Over an external origin that comes from HTTPS instead and must be re-verified, not assumed.
  • --host 0.0.0.0 — today it produces a server that refuses every request, because HostPolicy then expects Host: 0.0.0.0, which no browser sends. Fails closed, which is the safe direction, but fails confusingly and needs its own decision.

Verification

10 mutants, 10 killed. The last was CLI-side normalisation — invisible until a test typed the name the way a shell or config file actually hands it over (" KEEL.Example.COM ").

Full suite 5145 passed / 3 skipped; ruff and mypy clean.

docs(readme): lead with the fee-reality benchmark, rendered from the ledger (#646, partial) (#679)

Partial. #646 also asks for a ~10-second terminal capture of a real run with committed generation tooling. That is unbuilt and the issue stays open for it. This is the numbers half — and the half a GIF would have to agree with.

The README already stated the honest result in prose. #646 asks for it as numbers: the same rule priced twice, before the feature list.

asset trades PF at 0% fee PF at 1.2% taker break-even fee, measured
BTC 123 1.090 0.333 0.068%
ETH 121 1.458 0.556 0.433%
SOL 92 1.533 0.801 0.751%
ZEC 50 2.713 1.303 1.741%

Four of four profitable with the fee removed. Zero of four survive the fee actually charged. Break-even spans 0.068% to 1.741% — a factor of ~26 across four assets running the same rule on the same clock over the same window.

Rendered, never typed

That is the issue's binding constraint, not a nicety. Every figure is read out of docs/experiments/trials-ledger.jsonl — the hash-chained record of what was actually run — by scripts/render_fee_reality.py and reformatted. A hand-typed benchmark would be a marketing claim wearing a measurement's clothes, in the one block whose entire argument is the difference between those two.

tests/test_fee_reality_block.py fails if the README and the renderer disagree, and separately asserts that every number in the block appears in the ledger row — stronger than "the renderer produced it", because a renderer with a literal baked in would satisfy the drift pin with both sides carrying the same invention.

The caveats travel with the numbers

Pinned by a test, because dropping them would make the block a stronger claim than the measurement supports:

  • Every configuration is the argmax of its asset's 144-cell slice, selected on the same data it is re-priced on. Maxima of 144 draws, not edge estimates — the ledger's selection_bias field says so at full strength.
  • The bias runs against the finding, which is why the comparison survives it: it inflates the arm that wins with the fee removed, and that arm still dies when the fee is charged.
  • Slippage is held at 0.0005 in every cell, so the zero column is zero fee, not zero cost.

Parsing prose is deliberate

The ledger stores the curve as a recorded sentence, because that row was written by the experiment that produced it, and rewriting history to suit a renderer would be the tail wagging the dog. So the parser is strict and every recognition failure is fatal — a parse that silently produced three of four assets would be the quiet half-truth this block exists to refuse.

Two bugs the tests found in the renderer's own output:

  • [\d.]+ is greedy enough to swallow the sentence's full stop into the last asset's profit factor. 1.303. is still readable, still wrong, and would have shipped inside a table.
  • Fee columns rendered as raw fractions (PF at 0.012), making the reader perform the conversion that produced #247's costing error in the first place.

Verification

9 mutants, 9 killed — and the last took three attempts to write. The column-count guard sat behind the missing-break-even guard, so every input meant to reach it tripped the earlier one instead, and the mutation survived while the test looked like it pinned something.

Full suite 5135 passed / 3 skipped; ruff and mypy clean.

feat(web): a remote session expires, and a wildcard bind must say what it expects (#648) (#680)

Three of #648's five requirements. The first finding is that one of them should not be built.

No rate limiter, and the arithmetic is why

The issue asks for "issuance rate-limiting and brute-force posture". The token carries 256 bits (secrets.token_urlsafe(32)) — a space of ~1.2 × 10⁷⁷. At a billion guesses per second, which no http.server on a laptop will serve, covering a meaningful fraction takes on the order of 10⁶⁰ years.

Guessing is not a threat this server has. A limiter installed to stop it would be theatre: state, a failure mode, and a false sense that something was closed. TOKEN_ENTROPY_BITS records the number so it travels with the claim, and the comment says that if a limiter is ever added it must be justified by bounding log volume or making probing visible — never by brute force.

What a remote origin actually changes

Not the strength of the token — who can use one that leaked.

On loopback that population is software already running as this operator, against which no session lifetime helps at all. Through a tunnel it becomes anyone who can reach the origin, and the token has been in a URL, in terminal scrollback, and in whatever got pasted while asking for help. The 30-day cookie Max-Age is a browser hint such an attacker ignores entirely.

So a server configured with --external-host enforces a 12-hour session lifetime on its own side of the wire, checked before the token so an expired session cannot be told apart from a wrong one by which refusal comes back. A loopback-only server has none.

That asymmetry is the argument, not an exemption: SESSION_COOKIE_MAX_AGE_SECONDS's reasoning for the 30-day cookie is sound and is left completely intact. Restarting keel serve remains the instant revocation gesture in both postures.

A wildcard bind must name what it expects

--host 0.0.0.0 produced a server that refused every request — HostPolicy would then expect Host: 0.0.0.0, which no browser sends. It failed closed: right direction, wrong explanation, and the operator's conclusion was "keel is broken" rather than "keel does not know which name to expect".

A wildcard is precisely the bind where the name cannot be derived, because every interface has a different one. So it is the one bind that requires stating it. Not a blocklist — permitted the moment --external-host says what to expect, pinned by a test, and a specific --host 10.0.0.5 is untouched.

Still open — #648 stays open

Secure-context re-verification. The service worker and manifest work today because http://127.0.0.1 is a secure context by specification; over an external origin that property comes from HTTPS instead and has to be re-verified there. That needs a real deployed origin and cannot be closed from a checkout — so an installed console reached through a tunnel is untested, not merely unsupported, and docs/remote-access.md says exactly that.

Verification

6 mutants, 6 killed. One is worth keeping: shrinking the lifetime to one second passed everything, because the tests only pinned that it was shorter than the cookie. A lifetime that expires mid-use is not a session bound — it is the "refuses every request" outage the wildcard refusal exists to prevent. It is now pinned at both ends.

Full suite 5166 passed / 3 skipped; ruff and mypy clean.

feat(doctor): count the update backups nobody was ever told about (#681) (#682)

Closes #681.

keel update copies every keel*.db before it installs and never deletes one. That is correct — update.py names them as the data-recovery path, and an updater that pruned its own rollback would be an updater you cannot roll back from. What was missing is that nothing ever told the operator they were there.

The live deployment, measured 2026-09-01

88 files — 7.0 GB in ~/keel, plus 408 MB in ~/keel/backups
database backups for 24 releases, going back to 0.4.0

Three databases at 130–180 MB, one set per release, roughly a release a week — so it grows ~470 MB per update and nothing bounds it. df says 343 GB free, so this is not urgent. It is invisible, which is the actual problem: the first signal would be a disk filling during an update, the one moment a rollback path matters most.

Surface, never delete

doctor gains backups.footprint — WARN past three per database, OK below. A handful of recent backups is the design working, and warning about three would train the finding to be ignored by the time it matters.

Two tests carry the "never delete" half:

  • the fix text must say prune by hand and that keel will not do it — asserted to contain no keel backups prune, no --prune, no rm;
  • a scan of keel/ for any line that both mentions bak-before and calls unlink/rmtree/os.remove. A future change to the rollback guarantee has to delete that test to make it.

The count is the actionable number, not the bytes. "keel.db: 23 going back to 0.4.0" says what to do; "7 GB" says only that something is large. A hand-named backup (keel.db.bak-before-recordflow-…, which exists in the live folder) is never sorted as the oldest release — reporting "recordflow" as the oldest is both wrong and unactionable, since you can't decide whether to keep a release you can't name.

keel update's plan also names what is already kept, immediately before another set is written.

Verification

9 mutants, 9 killed — and two changed the code, not the tests:

  • Replacing the launch-folder read with an empty footprint passed everything. The seeded deployment has no backups, so both paths reported ok and the finding was never shown reading anywhere real. There's now a test that seeds four and monkeypatches update._launch_dir.
  • The try/except OSError around the glob was unreachable. Path.glob returns an empty iterator for a missing directory and for one at mode 000 alike, so deleting the handler changed nothing — which is how it was found. It's gone, with the correction recorded in its place: what genuinely races is stat on a file that vanished between the glob and the read, and that one is guarded where it can actually happen.

Full suite 5177 passed / 3 skipped; ruff and mypy clean.

feat(rules): CUSUM event gating, with the threshold priced in round trips (#341) (#683)

Research brief B-1, prerequisite-gated on #339's honest-cost re-measurement — which closed 2026-08-17. A fifth RULE_REGISTRY kind: a deterministic trading-core change, filed as an issue first, exactly as the registry's own comment requires.

What it does differently

The three shipped signal rules evaluate a condition on every bar. This one asks a prior question: has price moved far enough since the last event to be worth evaluating at all? A symmetric CUSUM filter accumulates returns from a rolling anchor and fires only when the running sum crosses a threshold.

The threshold is a multiple of friction, not a percentage

This is the whole design ask of the issue. The source's 2.0–2.5% threshold is not conservative here — it is almost exactly one round trip on this venue (2 × 1.2% taker + 2 × 0.05% slippage = 2.5%). So "a 2.5% move" names a move that pays for the trade and leaves nothing.

threshold_friction_mult puts that in the knob itself: 1.0 reproduces the paper and says out loud that it is break-even before the order is placed. Default is 2.

ROUND_TRIP_FRICTION_PCT is imported from strategy.backtest rather than restated, so a rule defined as a multiple of friction cannot drift from the number the backtest charges it. The percentage is a derived property, never persisted — a stored 2.5% would keep answering 2.5% after a fee change that made 2.5% mean something else.

The reset is the load-bearing part, and my first draft didn't have it

Without resetting the crossing side, S+ climbs monotonically through a rally and stays above the threshold for every later bar — so the rule fires on every bar of the move. That is the every-bar evaluation it exists to replace, wearing a threshold.

It was caught by the test fixture: a rally with "comfortably more" rise than the threshold does not fire, because the crossing already happened earlier in the window and the anchor moved. The fixtures are built to cross on the final bar, and that constraint is the reset property made concrete.

Both sides are tracked though keel can act only on the upward one — a one-sided filter accumulates an unbounded downward sum that never resets and then mis-times the next upward event. The exit is the same filter on the other side, symmetric on purpose: a different exit threshold would be a second free parameter with no evidence behind it. #342's triple-barrier exits are deliberately not smuggled in — this rule must be measurable alone before it is combined.

⚠️ The honest prior, in the module and in the registry

Zero of ninety asset-rule-parameter combinations are simultaneously measurable (n≥100), gross-positive and net-positive at any fee this venue offers. Nothing here is expected to change that.

Worse: this rule's own mechanism cuts trade count, and the ρ=−0.77 bind between edge and sample size says the rules with enough trades to promote are the ones without edge. A gate that trades less is walking into it.

It ships to be measured, not to be believed. Nothing is promoted by this PR; no rule row is added; the live allowlist and config are untouched.

One thing that moved on its own

SEARCH_SPACES gained a fourth family without anyone editing research/tuning.py — since #528 it reads the rules' own param_space(), so declaring a space is what enrols a rule. I updated that pin with the reason written down, because enrolment is now a side effect of a declaration made in another file, and a rule that gained a space by accident would otherwise appear in a sweep budget silently.

Verification

9 mutants, 9 killed — including the reset, the stale-event carry-forward, the friction scaling, the dropped downward side, and the granularity fallback.

Full suite 5210 passed / 3 skipped; ruff and mypy clean across 121 source files.

docs(experiments): cusum_event measured — feasible, and without a gross edge (#341) (#684)

The measurement #341 asked for: pre-declared, then run. 168 trials, 24 assets, ~5 years of hourly candles, fees 0 / 0.006 / 0.012.

1. Feasibility: yes — and it contradicts the worry filed with the issue

#341 was filed with the concern that "gating cuts n on rules already below the admission floor." It does not.

assets with n ≥ 100 21 of 24
median n 553
range 32 – 884

A 5% cumulative move in an hourly crypto series is a common event, not a rare one. The three below the floor are the two PAXG pairs and TON — gold and the thinnest name in the universe, which is the expected shape. That objection is answered and shouldn't be repeated.

2. Profitability: no, and not marginally

fee PF median PF max above 1.0
0% 0.925 1.238 8 / 24
0.6% (maker, unreachable) 0.553 0.750 0 / 24
1.2% (taker, actual) 0.343 0.479 0 / 24

The intersection of n ≥ 100 and PF > 1.0 is empty across all 168 trials.

3. The diagnostic — this is the rsi_meanrev disease, not the turtle_breakout one

The number that matters is the zero-fee column: only 8 of 24 clear 1.0, median 0.925.

turtle_breakout at zero fee was profitable on 4 of 4 with a max of 2.713 — a real gross edge that cost destroyed, which is why cheaper execution was a coherent thing to want. This rule loses at a median before a single fee is charged. Same symptom as turtle at 1.2%, different disease — and the one execution cannot fix, because zero fee bounds from above everything an execution improvement could buy, and zero fee is already a loss.

4. The knob works exactly as designed, and converges to nothing

mult threshold median n PF median above 1.0
1 2.5% 1252 0.226 0
2 5.0% 553 0.343 0
3 7.5% 330 0.361 0
4 10.0% 224 0.379 0

Monotonic in the right direction — trading less does lose less per toll, so the mechanism is real. It is also converging to a ceiling well below 1.0 while n falls toward the floor. A fourfold threshold buys 0.15 of profit factor and costs 82% of the sample.

5. The source's own setting is the worst cell

mult=1 is the paper's 2.0–2.5%, which on this venue is exactly one round trip: lowest median PF (0.226) with the highest median n (1252) — the most trading at the least edge. That's #341's friction-scaling argument measured rather than asserted.

Honesty

Arm A is one pre-declared configuration, so it carries no selection bias. Arm B's per-asset best is a maximum of four draws and is stated as such — best cell CRV-USD at mult=4, PF 0.593, still 0.4 short, so the bias changes nothing either way. Screening result only: no walk-forward, no PBO (series_missing). Slippage held at 0.0005 throughout, so the zero column is zero fee, not zero cost.

The ledger row was written twice

Worth recording. Every non-int summary value must be a string: _decode_summary turns anything that isn't an int into a Decimal on read, so a JSON float round-trips to a different value and the row stops hashing to its own row_hash.

verify_chain caught it immediately. The corrupt trailing row was removed — restoring the chain to exactly its prior state, verified clean at 90 rows — and re-appended with the encoding every existing row already uses. The chain verifies at 91 rows.

Changed nothing else

A document, a driver script, a ledger row. No rule row added, nothing promoted, no config or allowlist touched. cusum_event stays registered and untraded.

The measured intersection goes from 0 of 90 to 0 of 114. That is the result, and publishing it is the point.

feat(rules): triple-barrier exits with a vertical time stop, and the measurement (#342) (#685)

Research brief B-2, prerequisite-gated on #339 (closed 2026-08-17). A sixth RULE_REGISTRY kind, and the A/B it exists to make possible.

The result, in two numbers

cusum_event and triple_barrier share an entry — same CUSUM filter, same threshold, same universe, same window. Only the exit differs, so the difference is attributable.

zero fee 1.2% taker
control (cusum_event) median PF 0.925 0.343
triple_barrier median PF 1.001 0.338
median delta +0.033 −0.004
assets improved 17 of 24 11 of 24 (a coin flip)

The exit genuinely works. At zero fee it lifts the median profit factor across break-even and improves 17 of 24 assets — the first number in this series of documents to move the right way.

And it is worth nothing. At the rate the account pays, the median delta is −0.004. The gross gain is smaller than the friction it has to be harvested through.

Zero of 24 clear PF 1.0 at the taker rate. The only two cells above 1.0 anywhere are TON-USD at n=17 and n=16 — a sixth of the admission floor, maxima of five draws on the thinnest asset. The intersection is empty across all 192 trials.

What's new in the rule

  • A vertical barrier — sell at the close after N bars, whatever price has done. No shipped rule has a holding-duration exit, and it's the leg of the source's method that transfers: executable under market fills, where a resting order at a horizontal barrier is not.
  • Barriers as multiples of the product's own round trip (#342's design ask). The source's ±2.5–5% sit at or below one round trip here, so transplanted as percentages they're mechanically dead. A fixed percentage is also wrong per asset — since #259 the backtest prices thin books to 183.8bp per leg, so the same 5% is four round trips on BTC and barely one on the tail.

Two things worth your eye

A unit trap that produces no error. median_daily_quote_volume returns a per-bar median despite its name, and slippage_for_quote_volume is anchored on $500M daily. Handing it the hourly figure unscaled reports every asset as maximally thin, clamps the universe to the 183.8bp cap, and makes every barrier four times too wide — silently. per_product_round_trip scales by bars-per-day; a test asserts the consequence rather than the call.

A bug the mutation run found in my own guard. if not stop < entry < target passes a negative stop: a large enough stop_friction_mult drives the barrier below zero, the ordering still holds, and risk = entry − stop then exceeds the entry itself — sizing a position off a loss larger than the whole holding. It's 0 < stop < entry < target now. Unreachable inside the declared param space, reachable from a stored row, which is why it isn't dead code.

The vertical axis

bars median n PF median above 1.0
6 520 0.161 0
12 486 0.243 0
24 431 0.338 0
48 374 0.392 1
72 338 0.464 1

Monotone, and the source's own 24-bar barrier is mid-range rather than optimal. The direction agrees with its "wide barriers beat next-bar labeling" finding; the magnitude doesn't, because 1.2% per leg is twelve times the 0.1% that paper priced.

What this settles

Both halves of Grądzki et al. are now implemented on keel's cost structure and measured on one universe: the entry half has essentially no gross edge, and the exit half is a real improvement friction consumes entirely.

The paper is not wrong about its own venue — at 0.1% per leg a +0.033 gross improvement is worth keeping. At 2.5% it is not. That's the clearest measurement in the series of the difference between a result and a result at a price.

Verification

9 mutants, 9 killed. Full suite 5241 passed / 3 skipped; ruff and mypy clean. Ledger verifies at 92 rows.

Nothing promoted, no rule row added, config and allowlist untouched. The null grows: 0 of 90 → 0 of 114 → 0 of 138.

feat(rules): the promotion gate prices fills per product, and the restatement that fires it (#335) (#686)

Closes #335. It waits for "a thin-asset candidate to reach the gate" — measuring the universe answers a sharper question than the trigger asked.

Every document in this repo prices fills at a floor nobody reaches

slippage_for_quote_volume reaches its 5bp floor only at the model's $500M/day anchor. Over the 24-asset universe's own cached candles:

assets at the floor 0 of 24
cheapest BTC-USD, 5.5bp — 1.1×
dearest TON-USD, 183.8bp — 36.8× (the cap)
median ≈ 10×

#335 names "the STX/CRO-class 1.15–1.30× floor entries" as the live example of a thin-asset candidate. The live example is the entire universe.

What it costs

240 trials — five rules at shipped defaults × 24 assets × both regimes, run in one driver so the A/B is internally consistent rather than compared across documents.

rule flat per-product delta
turtle_breakout 0.336 0.267 −0.063
rsi_meanrev 0.261 0.175 −0.082
pullback_continuation 0.042 0.012 −0.021
cusum_event 0.343 0.243 −0.080
triple_barrier 0.338 0.237 −0.092

Median across 120 cells: 0.309 → 0.219. Every one of the 120 deltas is ≤ 0.

The comparison that matters

That 0.090 median overstatement sits next to the other number measured this week: the triple barrier's better exit bought +0.033 of gross profit factor.

The error in the cost model was 2.7× larger than the best genuine improvement any rule change produced. Every strategy comparison in this repository has been made through a lens that mis-priced execution by more than the differences being compared.

The corpus's only positive cell dies

turtle_breakout on WLD-USD: 1.061 → 0.626, at 24.2× the rate it was priced at, already below the trade floor at n=58. Zero of 120 clear PF 1.0 per-product.

Why the deferral was safe, and why it isn't now

#259 deferred on the reasoning that the correction is conservative-only — real cost is higher, a corrected profit factor can only fall, and per-product pricing can never manufacture an edge. Confirmed: all 120 deltas are ≤ 0.

What it doesn't survive is the magnitude. A correction assumed to be a rounding adjustment is worth 0.090 of median PF and kills the only positive cell in the corpus. A gate pricing promotion decisions at the best rate the model can produce is not being conservative.

The change

rules backtest / rules promote now price per product, from the product's cached ONE_DAY bars by the same one definition simulate.slippage_assumptions uses — deliberately not the rule's own trading granularity, because median_daily_quote_volume is a per-bar median despite its name, and an hourly figure handed to a daily-anchored model reports every asset as maximally thin. (triple_barrier.per_product_round_trip can't read daily bars — a pure rule has only what it's handed — and scales explicitly instead. Both say so where they do it.)

No daily bars falls back to the flat floor and is flagged as a fallback, never presented as a measured verdict. ResolvedBacktest carries both, so a front-end can print the assumption beside the number it produced.

Verification

7 mutants, 7 killed — two were the call sites. Removing slippage_pct= from _backtest_rule or from backtest_resolved left every helper test green, because the helper being right proves nothing about anything using it.

Full suite 5247 passed / 3 skipped; ruff and mypy clean. Ledger verifies at 93 rows.

Deliberately not done

The older documents keep their flat-priced figures. Their verdicts don't move — the correction only pushes them further from 1.0 — and re-running them isn't free. A pointer note is worth more than the compute.

The null is unchanged and firmer: 0 of 138, now at honest cost.

fix(docs): the rail count drifted to five different answers in one commit (#687)

guards.check implements twenty rails — 1–14 and 16–21, rail 15 retired. At v0.13.2 the repository stated that number four different ways at once:

where said
guards.py:267 "all twenty rails" ✅
README.md:227 "the 18 rails" ❌ two behind
executor.py:4 "eighteen … rails" ❌
executor.py:93 "eighteen rails" ❌
executor.py:1558 "eighteen rails" ❌

My own sweep caused it

#667 added rail 21 and updated the count by grepping for "nineteen". That found guards.py, glossary.md, the runbook, launch.md and go-live-runbook.md — and never touched the four places saying "eighteen" or "18", which had already been stale since rail 19. Fixing the spelling I happened to search for left the ones I didn't, and nothing failed.

Test first, and it earned it immediately

Writing tests/execution/test_rail_count.py before the fix found five claims where my manual grep had found three — including executor.py's module docstring, which I'd missed entirely.

The count is derived from the numbered rails in guards.check, and every English and numeric spelling is searched repository-wide. Rail 22 fails the suite instead of leaving documents wrong for three releases.

Dated records are excluded, deliberately

docs/research/2026-08-20-quant-lab-note-cross-verification.md says "eighteen rails" and "four rule families" — both were true when a third party's note was verified in August 2026. Updating it would silently rewrite a verification nobody re-ran. docs/experiments, docs/presentations and docs/superpowers are out for the same reason: they state what was true when written.

The contiguity assertion is about the set

Not "sorted, unique, starts at 1". A mutation renumbering rail 21 → 22 passed all three of those — the count held, the order held, 15 was still absent — while a silent gap opened at the top. A gap is the shape this drifts in, so the assertion compares against 1..max minus the retired 15.

Verification

Tests: tests/execution/test_rail_count.py — 2 tests.
4 mutants, 4 killed: README going stale, a docstring going stale, a renumbering that opens a gap, and reuse of the retired rail 15.

Full suite 5249 passed / 3 skipped; ruff and mypy clean.

docs(readme): the cost-distortion finding, rendered from the ledger (#646) (#688)

Partial. #646 also asks for a ~10-second terminal capture with committed generation tooling. That's unbuilt and the issue stays open for it. This is the research half.

The README already leads with the fee-drag table (shipped earlier). This adds the finding that came out of measuring it properly.

What it says

Every backtest here used to price fills at the floor of keel's own slippage model — the best case the model can produce, reached only at a $500M/day anchor. 0 of 24 assets reach it. Re-pricing per product moved the median profit factor across 120 cells from 0.309 → 0.219.

profit factor
the cost-model correction −0.090
the best strategy improvement we have measured +0.033

2.7× — derived at render time from two separate hash-chained ledger rows, not typed. A literal would survive either number changing underneath it, which is exactly the trap a headline ratio sets, and a test asserts the ratio is computed.

What it deliberately does not say

Nothing about any other framework.

The tempting version of this section framed crypto backtesting generally as living on fantasy friction assumptions. We have not measured anyone else's defaults, and an unsourced claim about a named third party is the exact failure this section exists to avoid making about ourselves. #646 forbids competitor naming in the asset on trademark grounds; the stronger reason is that the finding needs no help.

A test asserts six framework names are absent and that the scope sentence — "keel mis-pricing keel, on one venue" — is present.

The headline is narrower than proposed

"Strategy optimization is irrelevant under flat slippage assumptions" generalises from one comparison, one universe, one venue. What's written instead — "we mis-priced our own execution by more than any strategy change we ever made" — is checkable line by line, and is the more damaging claim precisely because it's about us.

Two rendering bugs the tests caught

Both invisible in the source, both obvious on the page:

  • textwrap broke the source link mid-URL, so it rendered as literal text.
  • A blank line after every paragraph turned one four-row table into four one-row tables.

Verification

Tests: tests/test_fee_reality_block.py (+6, 16 total).
6 mutants, 6 killed: a hardcoded ratio, a dropped scope disclaimer, a named competitor, a hand-edited README number, a wrapped link, a split table.

Full suite 5253 passed / 3 skipped; ruff and mypy clean.

Regenerate with python scripts/render_fee_reality.py --write; the drift pin fails if the README and the ledger disagree.

chore(release): 0.13.3 — honest cost at the gate (#689)

Version bump only — 8 pyproject.toml files plus uv.lock, the same nine-file shape as 0.13.1 and 0.13.2. No code, no migration; schema stays at v16.

Why cut now

#335 has live consequence. The promotion gate priced every fill at the floor of keel's own slippage model — a rate reached only at a $500M/day anchor, which not one of the 24 assets keel holds candles for reaches. keel rules promote would have priced a promotion decision at 5bp on an asset whose measured rate is up to 36.8× that.

Nothing is promoting today, so it was never urgent. It's the kind of thing that matters the first time it isn't.

The restatement that fired it ships too: 240 trials, median profit factor across 120 cells falling 0.309 → 0.219, and the corpus's only cell above 1.0 dying at 0.626. The correction is 2.7× the +0.033 the best strategy change ever bought.

Also shipping

#341 / #342 Two new rule kinds — cusum_event, triple_barrier — both measured, both null. Registered and untraded: no rule row, no allowlist change, nothing promoted.
#648 Enforced 12-hour session lifetime once a remote host is configured; wildcard bind refuses to start without one. Remote access is still not ready — PWA secure-context over a real HTTPS origin is unverified, and docs/remote-access.md says so at the top.
#681 doctor reports the update backups it never mentioned. This is the first build that can see them — which is why the 7.0 GB in the live launch folder had to be found by asking.
#687 / #688 The rail count (which disagreed with itself in five places at v0.13.2) now derived from guards.check and pinned; the cost-distortion finding rendered into the README from the ledger.

Verification

tests/test_packaging.py::test_every_workspace_package_carries_the_same_version was re-run against a deliberately partial bump before committing this one, and it still fails as it should — the guard that caught the root-only bump at 0.13.0 is alive rather than merely present.

Full suite 5255 passed / 3 skipped; ruff and mypy clean.

After merge: Actions → Release → Run workflow, 0.13.3, desktop: publish-unsigned for the DMG/installer artifacts.