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 low counts are genuine short history. What
is not established is the effect on lower-n cells of earlier sweeps; that needs a re-run, which
is why #254 exists rather than a guess here.
What this closes
Every signal rule the codebase ships has now been measured at defaults across 24 assets and
along its own frequency axis. There is no asset-rule-parameter combination that is simultaneously
measurable and profitable at any reachable fee.
Priority order for what remains is in §6: fix #254 first (it's a correctness issue in the
instrument every one of these conclusions was produced with), then feed the PBO/CSCV gate that
#247 deployed and nothing supplies. The maker-execution and queue-simulation work has no target —
#252 removed its only candidate and this removes the rule that might have supplied another.
Ledger
One row, decision: rejected rather than diagnostic_only — this trial made a decision and should
count toward the multiple-testing N. verify_chain clean at 83 rows.
fix(strategy): re-detect while flat, so an unfilled setup cannot switch the detector off (#256)
Closes #254. A correctness defect in the instrument every experiment in docs/experiments/ was
produced with.
The defect
backtest() set pending and, if the entry was never touched, carried it forward unchanged. The
only path that cleared it without a fill was the stop being touched first. So a setup whose entry
and stop were both never revisited pinned pending for the rest of the series: the
position is None and pending is None branch never ran again, rule.detect() was never called
again, and the engine silently switched its own detector off.
Nothing errored, nothing warned. The output was indistinguishable from a rule that simply found no
further setups — a frozen backtest looks exactly like a selective one, which is how it survived
every experiment to date.
Measured
rsi_meanrev on UNI-USD, hourly, everything at defaults except oversold:
oversold=30 -> 309 closed trades, last exit 2026-08-04 (trades throughout)
oversold=35 -> 9 closed trades, last exit 2021-11-15 (dead for ~40,000 bars)
Loosening the entry threshold cut the trade count 34×. Also visible as non-monotonic n on
BTC-USD (181 → 80 between oversold 30 and 35) and AAVE-USD.
Why re-detect rather than "expire after N bars"
Because N never existed in production. strategy/engine.py::evaluate calls rule.detect()
once per cycle unconditionally and carries no pending-setup state between cycles — an unexecuted
setup is simply re-derived from fresh data next cycle. Expiry would have been a new tunable
invented for the simulator; re-detection makes the simulator match the live path it is supposed to
be modelling. This was checked against the live code rather than assumed.
No lookahead is introduced. The fill attempt still happens first, and the re-detect uses the
same candles[: i + 1] window the flat branch would have used, so a setup derived on bar i can
still only fill on bar i+1 or later.
One-position-at-a-time is unaffected. That is enforced by the open position check, not the
pending one, and it is unchanged — TestNoOverlap passes untouched. Two now-stale docstrings that
claimed detect() runs only when pending is None have been corrected.
Verification
- 2714 passed, 1 skipped (2712 before + 2 new).
- Two regression tests from the UNI-USD case, both verified to fail on the unfixed code
(detect_calls1 vs 3) — reverted the fix, ran them, confirmed red, restored. - The committed BTC daily baseline fixture is unchanged. No freeze occurs on that corpus, which
usefully bounds the blast radius: this only moves series where a setup went unrevisited. ruff check keel tests packagesclean.
Blast radius on the research corpus
Non-trivial, and being re-measured now. Early rows from the #252 re-run against fixed code, Arm B
(entry_lookback=336):
turtle NEAR-USD n 73 -> 74 gross 1.079 -> 0.899
turtle TON-USD n 9 -> 9 gross 3.751 -> 2.873
turtle PAXG-USD n 26 -> 26 gross 2.936 -> 2.659
turtle ADA-USD n 85 -> 89 gross 1.598 -> 1.631
10 of the first 13 combinations changed
Trade counts move by only ±1–4, but gross profit factors move materially on the low-n assets. The
full 90-combination re-run of #252 and the 96-cell re-run of #255 follow in a separate PR, since
restating those documents' numbers is a documentation change and this is an engine change.
fix(strategy): fill entries at the next bar's open, as production's market orders do (#258)
Closes #257. Option A of the fork raised on that issue, per your call: match the live path rather
than change money-moving code to accommodate a rule already measured as alpha-deficient.
The divergence
The simulator held a Setup until a later bar's range touched its entry, then filled at
that level. Production does not do that:
order_type="market", limit_price=None, expected_fill=intent.entryLive never rests an order at Setup.entry and never waits for price to come to it. When
engine.evaluate emits a signal and the rails pass, the executor buys at market that cycle.
The old model therefore gave the backtest two things the live box does not have:
- Free optionality on the entry price — a setup only became a trade if the market offered the
chosen level, so unfavourable entries were silently declined. - Unbounded patience — any later bar touching the level filled there.
Both flatter results, and the bias runs opposite to #254's, which suppressed trades.
The change
Entries fill at candles[i+1].open plus slippage — the first price obtainable once the signal
exists, and the earliest fill involving no lookahead. Setup.entry becomes informational,
exactly as expected_fill is live; risk is measured from the achieved fill against the setup's
stop, never from the quoted entry.
This subsumes #254. Every pending now resolves on the very next bar, so the state that froze
the detector is unreachable rather than merely handled.
It also removes the wrinkle in #247's fee justification. "Touching entry is marketable,
therefore taker" held for a breakout entry above the market but not for a mean-reversion entry
below it, which touching would make a maker fill. With every entry a market order, taker is
unconditionally correct.
The consequence, named rather than hidden
A rule encoding a confirmation condition in its entry price no longer gets one.
pullback_continuation sets entry = signal_candle.high + buffer_ticks precisely to demand
follow-through, and a market fill takes trades it meant to decline.
That is not introduced here — it is what the live box already does, and modelling it faithfully
is the point. Option B (make the executor honour a stop/limit entry) is the alternative and stays
open on #257; it was declined because it changes live order routing to rescue a rule we have
already measured as alpha-deficient.
Tests
TestIntrabarResolutionEntryVsStopdeleted — that ambiguity only arises for an entry seeking
a level and is now unreachable. A note in its place says so._resolve_orderis still exercised
for stop-vs-target, which remains real, including on the fill bar.- #254's regression class restated as
TestEntryFillsAtNextBarOpen: a setup whose entry is never
touched now fills anyway, anddetect_calls == 1rather than #256's 3 — the number that
distinguishes the two fill models. - 2711 passed, 1 skipped;
ruff check keel tests packagesclean.
Golden regenerated (second time ever; #247 was the first)
profit_factor 1.269371 -> 1.269287
expectancy 692.0773 -> 691.9480
max_drawdown 14220.67 -> 14222.05
n_trades 13 win_rate 0.4615 (both UNCHANGED)
#247's reasoning for why n_trades held does not carry over — this is a fill-logic change,
so the count was free to move and simply didn't. It held because turtle_breakout enters at the
close and 24/7 crypto barely gaps, so both models pick nearly the same price on this corpus. Read
those deltas as "this corpus is insensitive to the fill model", not as "the fill model barely
matters" — the daily BTC baseline never exercises an offset entry. The docstring now says exactly
that, so the small numbers can't be misread later as evidence the change was cosmetic.
Pre-registered prediction for the re-run (in flight)
Recorded before results, so it can be wrong: n rises (every signal becomes a trade, including
the gap-away entries previously declined) and gross PF falls (the declined entries were
disproportionately the unfavourable ones). Largest effect expected on pullback_continuation,
whose entry is offset above the signal bar; smallest on turtle/rsi, which enter at the close.
docs(experiments): restate #252 and #255 on a production-faithful engine, and record why the defects were invisible (#261)
Both experiments re-run under #256 (pending setups no longer freeze the detector) and #258
(entries fill at the next bar's open, as production's market orders do). Same designs, same
combinations, same fee grid and slippage pin — only the engine differs.
Every conclusion survives except one
ZEC-turtle no longer clears the maker line — 1.034 → 0.968. That collapses #252 §6's
architecture: its three-probe narrative (fee curve, tail-sensitivity, temporal — each catching what
the others miss, with ZEC as the through-line) describes a survivor the faithful engine never
produces.
The replacement is shorter and worse for the library: the viable quadrant is empty at every
reachable fee — 0 of 90 in #252, 0 of 82 in #255 — with nothing needing three gates to die. The
probes aren't worthless; they're repositioned from "what killed the last survivor" to
"forensics explaining an artifact a defective engine manufactured."
#255 strengthens: the level shift widens from 1.1631 → 0.8938 to 1.1251 → 0.8396,
gross-positive cells at the floor nearly halve (11/76 → 6/82), and the 34× UNI-USD monotonicity
anomaly that exposed both defects is structurally gone (3 assets → 0) — an independent check on
the fixes, not a restatement.
The two defects pushed opposite ways
#256 suppressed opportunity; #258 flattered execution. Correcting both didn't add noise
— it moved everything one way:
trade counts rose in 87 of 90
gross profit factors fell in 69 of 90
Every prior conclusion in docs/experiments/ was measured on an engine simultaneously too
pessimistic about how often a rule fires and too optimistic about what it pays to get in.
Arm B, restated on a single engine
in-sample 0.5770 out-of-sample 0.5427 gap 0.034
#252 reported 0.6335 vs 0.6346 — a three-decimal agreement across one engine each, so partly
luck. Conclusion unchanged and now engine-consistent: the sweep winner is not overfit, it is
stably unprofitable.
§5 is the part worth reading
Neither defect was found by looking for defects, and neither was findable by the means we had.
2,712 tests passed throughout. #256 surfaced only from a 34× non-monotonicity visible because a
grid happened to sweep wide enough; #258 only from asking why #256's fix reduced trade counts.
The reason tests couldn't catch either generalises:
A frozen backtest and a highly selective strategy produce identical-looking output. So do a
patient limit fill and a lucky one. Nothing about n, win rate or profit factor distinguishes "the
rule declined to fire" from "the engine stopped asking."
Adding tests doesn't fix that — a test asserts behaviour someone already imagined. The document
recommends invariants the engine reports about itself: a dead-tail warning (#256 had a
4.7-year dead tail in plain sight of every run), intent-vs-fill divergence logging (#260 — the
order row already stores both numbers and nothing compares them), and cost anchored to output
(#247, shipped, and the model for the rest).
Also corrected: a distinction that would have misled future work
pullback_continuation's median n doubled (58 → 124) when the market fill removed its offset
entry. That is not evidence offset entries generate alpha — both sides lose money gross
(0.9219 filtered, 0.7736 unfiltered). The filter separated bad from worse. §3.1 says so explicitly
so this dataset can't later be cited the other way.
Method
Annotates rather than rewrites, per the convention #247 set — "past numbers were real outputs
of the code as it stood; they are annotated by this change, not restated." Both documents keep
their figures and carry a banner pointing here, so each remains a true record of what its engine
produced.
Two ledger rows (diagnostic_only for the intersection restatement, rejected for the rsi grid,
which decides against the rule); verify_chain clean at 85. Both re-run harnesses committed.
2711 passed, 1 skipped.
feat(engine): two observable invariants, because neither past defect was findable by testing (#262)
The operational takeaway from #261 §5, implemented. Neither #254 nor #257 was found by looking for
defects — #254 surfaced from a 34× non-monotonic trade count, #257 from asking why #254's fix
reduced trade counts — and 2,712 tests passed throughout both.
The reason tests couldn't catch either generalises: a frozen backtest and a highly selective
strategy produce identical output. So do a patient limit fill and a lucky one. Adding tests
doesn't help, because a test asserts a behaviour someone already imagined. These two make each
defect class announce itself in the ordinary output of an ordinary run. Neither touches order
routing.
1. Pending lifespan invariant — keel/strategy/backtest.py
Asserts a pending setup is never carried across more than one bar. Since #257 the fill is
unconditional, so the correct value is exactly 1 for every rule on every series — checked
rather than thresholded, no per-asset tuning, no false-positive mode. On UNI-USD under #254
this counter would have read ~40,000.
The dead-tail heuristic was considered and rejected. #257 made the freeze structurally
impossible, so a dead tail now only ever means the rule genuinely stopped firing — a regime change,
a threshold too strict for recent volatility. A warning on "trades stopped >15% before the corpus
ends" would fire exclusively on legitimate runs, and the first three false alarms teach an
operator to ignore the fourth. That costs more than no alert.
If resting entry orders return (#260 Option B), the bound stops being 1 — and the value it
becomes IS the cancel/replace policy, stated in one place.
2. Intent divergence log — keel/execution/executor.py
The executor already persisted the rule's intended entry (expected_fill) and the achieved fill
(actual_fill) on the same order row, and nothing compared them. That missing subtraction is
exactly how #257 stayed invisible. _log_intent_divergence now reports signed basis points on
every fill.
- Unconditional, not thresholded. A cutoff right for BTC is wrong for a thin book, and the
per-asset liquidity model that would set one doesn't exist yet (#259). Gating this on that work
would block the cheap half behind the expensive half. Same principle as #247 printing the fee
rate — make the number visible first, act on it second. - Signed, not absolute. "We paid up" and "we got filled cheaper" are opposite failures for a
rule whose entry encodes a condition. - Never raises. Telemetry on an already-settled order must not be able to fail a cycle.
Verification, including what could not be tested
The lifespan invariant cannot be violated through the public API — since #257 there is no input
that makes the engine carry a setup. So there is no red-then-green test, and writing one that
passes trivially would be theatre. The test class says this in its docstring rather than implying
coverage it doesn't have.
What was done instead:
- Proven live. I deliberately broke the fill path and the assertion fired immediately:
Then restored.
AssertionError: engine invariant violated: rule 'scripted' on BTC-USD carried a pending setup across 2 bars (bar index 3). Since #257 every pending fills at the next bar's open, so this cannot exceed 1 -- the fill path has regressed - It sits in the hot loop, so all 2,717 tests, the baseline golden and the whole sim suite
execute it on every run. A reintroduced carry fails the suite loudly instead of silently.
The divergence log has four real tests, asserting on the structured payload (log_event
attaches fields via extra) rather than caplog.text — which shows only the event name and would
have passed vacuously for any values at all. I hit that exact false-pass while writing them.
2717 passed, 1 skipped; ruff check keel tests packages clean.
chore(skills): track keel-asset-scout in the repo, and record today's measurements in it (#263)
Moves the scout skill to project scope and puts it under version control. It previously lived
only in ~/.claude/skills/, outside git, with a single .bak as its entire history.
Project scope is the correct home: every path, gate and experiment the skill references is keel's,
so it means nothing in a session rooted elsewhere. There is now exactly one copy — the
user-level one has been retired (parked in ~/.claude/skill-backups/, not deleted). A second copy
is a second source of truth, and the two drift silently.
Tracked via git add -f, because .gitignore:34 ignores .claude/. That is the same exception
the two review-fix-merge-pr skills already use — following existing precedent rather than
inventing a location.
Why version control matters more here than for a typical skill: most of this document is not
procedure — it is recorded reasoning about measurements that cost real compute to produce. Why
stationarity is never a filter, why a small-sample profit factor is a lottery ticket, why the
engine's own invariants are unobservable from a proposer. An untracked file loses the argument
along with the rule, and the next person re-derives both the hard way.
Content added today — all additive
All 13 original sections are intact, including every guardrail: Do NOT, Rationalizations, the
citation-fabrication warning, and Output 2's rejection history (the only record of why an asset
was passed over, since keel deliberately has no rejection table).
| Added | Anchored to |
|---|---|
| "Taker math controls the field" | ~2.5% round-trip, levied on the search not the edge; viable intersection vacant across 24 assets |
| "What has already been measured" | the three experiment documents, read the restatement first |
| Expansion refuted as a performance fix | turtle_breakout negative on all 24 — a 25th cannot fix it. Still valid for power, compliance, future rules |
cost_regime |
a result at 5bp does not transfer to a 1.2% taker venue |
sample_size |
rsi_meanrev 1.1631 on n=38 → 0.8396 across 82 cells at n≥100 |
evaluation_window |
ZEC compressed 92.7% of lifetime PnL into one regime atop three losing years |
not_already_covered |
the skill's own example, entry_lookback: 55, sits inside an already-swept grid |
| Fill-model framing | entries fill at next-bar open as market orders (#258); a technique assuming a resting limit will not execute as described |
| 4 Rationalizations rows | incl. "this backtest looks clean, the numbers are all plausible" |
Two things deliberately NOT added
Both were proposed and declined, with the reasoning written into the file so the next person to
propose them can see why rather than relitigating:
A check on the pending-lifespan invariant. It's an AssertionError inside backtest() — it
raises rather than logs, so a violation ends the run and produces nothing to inspect.
Unobservable from a proposer that never runs a backtest.
A stationarity rejection threshold. ZEC under pullback_continuation is the only asset-rule
pair in the study with no losing complete year — at 0.875 gross. Any stationarity gate
passes that and rejects ZEC-turtle, which at least made money gross. Stationarity is a
diagnostic, never a filter.
Verification
- The committed copy is byte-identical to the live project-scoped file in the working checkout.
- The file notes in its own text that being gitignored does not mean untracked here — the trap
that would otherwise make a future edit look uncommitted. - No symlink is involved and no user-level copy remains, so there is nothing to keep in sync.
fix(packaging): ship py.typed with the four broker distributions (#264)
What
Adds an empty py.typed to keel_broker_api, keel_broker_coinbase, keel_broker_fake and keel_broker_robinhood, plus a packaging test that keeps them there.
Why
All four are checked with strict = true, but none shipped a PEP 561 marker — so every wheel through 0.7.0 carried its annotations invisibly.
Inside this repo it never showed, because mypy reads the source tree. Off it, a consumer sees:
error: Skipping analyzing "keel_broker_api": module is installed, but missing library stubs or py.typed marker
and falls back to Any for every symbol crossing the boundary. The wheel still builds, still installs, still imports — the type contract is just gone. Same silent shape as an unpinned sibling, which is why this lands as a test rather than a note.
keel_broker_api is the one that costs most: the port's types are the contract every adapter and every consumer codes against.
The test
test_strictly_typed_packages_ship_a_py_typed_marker derives its subject list from the [tool.mypy] strict overrides instead of naming packages, so tightening a package into strict mode brings it under the rule with no edit here.
Scoped to strict modules deliberately — keel.* and keel_core.* are still ignore_errors, and a marker on unchecked code promises a guarantee nothing verifies. keel_core carries one anyway for historical reasons; the rule is a floor, not an equality, so that stays legal.
Verification
- Confirmed the test fails when a marker is removed, not just that it passes now.
- Checked the built wheel, not only the source tree:
unzip -l keel_broker_api-0.7.0-py3-none-any.whlnow listskeel_broker_api/py.typed. ruff check keel tests packagesclean;mypyclean (224 files);pytest2721 passed, 1 skipped.
fix(experiments): resolve the sweep harnesses' paths, so a recorded run can be re-run (#265)
What
Four sweep harnesses in docs/experiments/ hardcoded two absolute paths — the deployment DB under one laptop's home, and an output dir inside a per-session temp directory that is collected when that session ends. Both now resolve, and both are overridable:
DB = $KEEL_EXPERIMENT_DB or ~/keel/keel.db
OUT_DIR = $KEEL_EXPERIMENT_OUT or <script dir>/_out
The default DB resolves to the same physical file it named before, so nothing changes on the machine these were written on — it just stops being the only machine where they work.
Why it matters more than it looks
A recorded experiment that cannot be re-run is the one thing a recorded experiment must not be, and the output path had already gone stale.
_out/ is gitignored but deliberately not temporary: these sweeps are resumable, reading their own append-only JSONL back to skip completed cells, so that file has to outlive the run. A per-session scratch dir is exactly what broke that.
It is also the handoff point between scripts — each *-rsi-scale run reads the JSONL its matching *-intersection run writes, as the ANCHOR supplying its oversold=20 rows. Both chains verified by name, and the two studies verified not to collide (intersection.jsonl vs intersection_257.jsonl).
Only half of this was lintable
Ruff saw two of the four files. The other two spelled the dead path as a multi-line SCRATCH constant — under the line limit, so flagged by nothing. They were found by grepping for the literal rather than trusting the linter's list. The hardcoded DB was invisible to ruff in all four.
Lint cleanup
Also clears the 13 remaining ruff findings under docs/: 6 import blocks (autofix) and 5 long lines rewrapped without changing any output; the other 2 were the path literals above.
ruff check . now passes repo-wide — previously only the CI-scoped ruff check keel tests packages did.
Verification
- Candles load through the resolved DB path: 44,407 bars for BTC-USD, matching the ~44k those docstrings cite. Run against a copy of the live DB, not the deployment file.
- Defaults and both env overrides assert-checked, including that the output dir gets created.
- ANCHOR chain confirmed intact for both study pairs.
_out/confirmed gitignored and absent fromgit status.mypyclean;pytest2717 passed, 1 skipped.
fix(typing): scope mypy strict to the brokers, and check keel.* for real (#266)
What this is
An assessment of the codebase turned up no active bugs — the tree is healthy (2721 tests green, ruff clean). The one real gap was the mypy config itself, plus the 45 default-mode errors it was hiding on keel.*.
The config bug
strict = true is not a per-module setting. Written inside a [[tool.mypy.overrides]] block it enables strict globally, whatever module pattern the section carries. Verified directly — a section naming a module that does not exist produces the identical error count:
| config | errors |
|---|---|
broker block with strict = true |
131 |
same block targeting nonexistent_module_xyz.* |
131 |
| block removed entirely | 45 |
So pyproject.toml read as "brokers strict, everything else default" but did not do that. It was invisible because keel.*, keel_core.* and tests.* all carry ignore_errors, so strict had nothing left to shout at. Ungating keel.* is exactly the action that exposes it.
The bundle is now spelled out flag by flag, scoping it to the four broker packages for real. Broker strictness is preserved — confirmed by injecting an untyped def and a bare dict into a broker module and watching no-untyped-def / type-arg still fire.
keel.* is now checked, not yet strict — that stays the next tightening step, per the existing "one package at a time, never all at once" policy. keel_core.* and tests.* remain gated.
The 45 errors
Almost all annotation debt guarded by real invariants rather than live defects. Fixed at the root instead of silenced:
sum()overDecimalwith no start value picks theintoverload and widens toDecimal | float(stats.py,levels.py).Trade.pnl/exit_tsare optional because an OPEN trade has none; the aggregates run only over closed trades. Stated via_closed_pnland an explicit filter, so a violation names the offending trade instead of raising from inside a generator.TurtleBreakouttracked entry and stop as two independently-optional locals though they are set and cleared together — now one optional pair, making "stopped without an entry" unrepresentable rather than merely unreachable._effective_modereturns exactly"confirm"/"autonomous"but was typedstr, so bothexecutor.executecall sites passed an unchecked value into aLiteralparameter.product_idwas already a de-facto part of theRuleinterface (every concrete rule takes and stores it;simreads it off the base type) — declared.rules seedbuilt rules viaRULE_REGISTRY[kind](...), reaching aroundbuild_rule_from_params, the documented(kind, params)->Ruleboundary._decline's 8 errors are a documented idiom (one-line declines so the reason cannot drift from its branch). Idiom kept; annotation corrected.
Real findings
One genuine defect — _process_rule_signals declared held: dict[str, _Held] while every other declaration, its only caller, and its own body use (asset, rule_name) tuple keys.
Two silent TUI bugs — _human_dt(None) does not raise: time.localtime(None) means now, so a missing timestamp rendered as a lapse that happened this instant. Both guarded.
A guardrail this broke, and its repair
The config change silently gutted an existing test. test_strictly_typed_packages_ship_a_py_typed_marker discovers its packages by reading strict = true, so the expanded flag list collapsed four passing cases into one skipped [NOTSET] — the PEP 561 rule stopped being enforced without anything going red. It surfaced only from diffing collected tests against baseline, not from the summary line.
Discovery now keys off the flag block, plus two new guards:
- the strict-module list can never be empty;
- the expanded list must still equal what the installed mypy's
--strictactually turns on, derived from mypy's own option parser rather than hard-coded — a copy of someone else's bundle drifts. Confirmed to fail correctly by deleting a flag.
Verification
mypy— clean, 224 source filesruff check keel tests packages— cleankeel --help— imports fine (theci.ymlsmoke check)pytest— 2723 passed, 1 skipped; collection diffed against baseline is exactly +2, the two new tests
No runtime behaviour changes.
chore(release): 0.7.1, with the review follow-ups to #266 (#267)
Bumps all six distributions to 0.7.1 and closes the findings from reviewing #266 after it merged. Per docs/RELEASING.md, the release workflow refuses to set the version itself — that decision belongs in a reviewed PR.
Why the fixes ride along
#266 merged before its review findings were applied. Releasing 0.7.1 without them would bake a known-weak guard into a live-trading build, so they are here rather than in a follow-up.
Follow-ups to #266
test_broker_strict_flags_match_mypy_strictfailed badly in its own headline case. Re-collapsing the broker block tostrict = true— the exact regression the test exists to catch — made it die with a bareStopIterationfrom an exhausted generator, the least legible failure pytest renders. It now asserts, and the message points at the pyproject comment explaining why the block is expanded.- That test's
warn_redundant_castsexclusion was implicit. The flag is missing from the default-vs-strict diff only because it is currently mypy's default. If that default ever flips, the flag entersexpected, the test demands it in a per-module section, and mypy rejects it there as global-only — leaving the config unsatisfiable. Now excluded by name. TradeOutcomeused the PEP 695typestatement.get_type_hints()leaves such an alias as aTypeAliasTypewhoseget_origin()isNone, whereas the assignment form resolves through toLiteral.commands.rules._declared_choicesvalidates operator-suppliedrules add --paramsby testing preciselyget_origin(hint) is Literal. No impact today —TradeOutcomeappears in no rule constructor — but it introduced the spelling that silently disables that validation, right beside the modules whose--paramssafety depends on the other one. Reverted to the formStopMethod/TargetMethodalready use.
Tests for behaviours #266 shipped uncovered
Three new user-visible behaviours had zero coverage — all three mutations survived the entire suite. Each new test was verified to fail when its guard is removed:
summarize()rejecting a closed trade with no realised P&L, asserting the outcome appears in the message. The named diagnostic is the whole reason for raising: unguarded this surfaced asTypeError: unsupported operand type(s) for +: 'Decimal' and 'NoneType'from inside a generator, with no way to tell which trade caused it.- Both
_human_dt(None)guards in the TUI. These never raised —time.localtime(None)means now — so a missing timestamp rendered the current instant as fact. Each test asserts the text saysunknownand does not contain the rendered current time.
Deliberately not added: a test pinning cli.avg_hold_hours's exit_ts filter. Both SimTrade producers set outcome and exit_ts together, so outcome != "open" implies exit_ts is not None and the filtered denominator provably cannot differ from the old one. Covering it would mean fabricating a state the code cannot reach, pinning an arbitrary choice rather than a behaviour.
Version bump
All 12 version strings across 6 pyproject.toml files, plus uv.lock. The == sibling pins move too — tests/test_packaging.py fails the build if one is left behind.
Verification
mypy— clean, 224 source filesruff check keel tests packages— cleanpytest— 2726 passed, 1 skipped (2723 + the 3 new tests)
After merge
Actions → Release → Run workflow, input 0.7.1.