keel v0.9.0
Built from eebf12b. Version binds to this hash:
keel --version reports keel 0.9.0+eebf12b88292 [release].
Install
Download all wheels from this release into one directory, then install the
keel_trader wheel by path:
pip install --find-links . ./keel_trader-0.9.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.
Other changes
fix(executor): warn loudly when a conditional entry price is overridden by market routing (#260) (#332)
What & why
Closes #260's minimum viable mitigation (the issue's own scope; full resting-order routing stays deferred).
The live executor records every rule's Setup.entry as expected_fill and then ignores it for execution — all entries route market_market_ioc (_order_row/_order_configuration). For enter-at-close rules that is nearly free; for pullback_continuation, whose entry = signal_candle.high + buffer_ticks deliberately demands follow-through, production silently takes trades the rule meant to decline. The faithful measurement (#258) quantified it across 24 assets: median trade count 58 -> 124 (more than doubled), median gross PF 0.9219 -> 0.7736. The doubling is the count of trades the live box would take that the rule intended to decline, and the PF collapse is their quality.
Fixing it means changing money-moving order routing to rescue a strategy that is independently measured dead — "Upgrading live execution to rescue a dead strategy is a bad trade" (#260). The landmine is not pullback_continuation but the next price-conditional rule, which would be silently mis-executed the same way. So this PR makes the override visible rather than silent — the same principle as #247 printing the fee rate:
keel/execution/executor.pygainsENTRY_OVERRIDE_WARN_BP(50bp, documented below) and_warn_if_market_routing_overrides_entry, called from_run_orderright after the preview, before the confirm gate.- Market reference: the venue's own
best_askout of the preview_run_orderalready fetches — the price a market BUY actually pays, from the one book quote already in the hot path (no new broker call; a mid would understate the deviation by half the spread).CoinbaseClient.preview_ordermapsbest_bid/best_asktoDecimaltoday, and the Coinbase port adapter carries the same book inPreview.detail, so both preview shapes are read. - Threshold:
ENTRY_OVERRIDE_WARN_BP = Decimal("50")— a VISIBILITY threshold, not a correctness one. Anchored in the repo's own cost model (1.2% taker per leg, 5bp slippage): a few bp is the microstructure drift any enter-at-close rule accumulates by routing one cycle late; tens of bp is a rule whose entry encodes a condition. 50bp is 10x the slippage assumption (noise never trips it) yet small enough that any deliberate entry condition does. Comparison is strictly greater — exactly at the line logs nothing. - The warning is a structured WARNING event (
executor.entry_override_market_routed) carrying rule kind, product, intended entry (expected_fill), market reference and its source, signed deviation in bp, the threshold, and an explicitdetailsentence: rendered —
{"level": "WARNING", "logger": "keel.execution.executor", "event": "executor.entry_override_market_routed", "rule": "pullback_continuation", "product": "BTC-USD", "expected_fill": "50300", "market_ref": "50000", "market_ref_source": "preview_best_ask", "deviation_bps": "60.00", "threshold_bps": "50.00", "detail": "the rule's conditional entry price was OVERRIDDEN -- entries are always routed as market orders (#258), so the condition this rule encoded in its entry price was bypassed and the order is going out at the venue's price instead (#260)"}
Scoped to BUYs on the market configuration only: SELL intents (exits, brackets, stop rolls) carry their prices to the venue verbatim, and a future caller passing a resting order_configuration is not on the override path. A preview with no usable book quote is silent, not fatal. _order_row and the module docstring also document the always-market decision (#258) and why resting orders are deferred (#260).
Tests-first evidence
Tests written first in tests/execution/test_executor.py::TestEntryOverrideWarningAtRouting (extending the existing TestIntentDivergenceLog house pattern), seen red:
FAILED tests/execution/test_executor.py::TestEntryOverrideWarningAtRouting::test_routing_an_offset_entry_warns_loudly_at_warning_level
...
E AssertionError: no executor.entry_override_market_routed record was emitted
E assert []
...
7 failed, 1 passed, 63 deselected
The end-to-end routing test (through execute(), no private imports) failed on the assertion meant to assert — the full guard->preview->place path ran clean and no warning fired. The other 7 red on importing the then-nonexistent helper, then went green with the implementation. Cover: beyond threshold via the full routing path (rule kind + both prices + signed bp + WARNING level + the OVERRIDDEN sentence), within threshold silent (a warning that fires every order is a warning nobody reads), exactly at the threshold silent (boundary pinned from the constant), entry below market warns with negative sign, bookless/garbage preview silent and non-fatal, the port Preview shape, SELL intents never warn, non-market configurations never warn.
- Tests written first, seen failing for the right reason
Gates (all must pass)
-
uv run ruff checkclean —All checks passed! -
uv run mypyclean —Success: no issues found in 237 source files -
uv run pytest -qgreen —2870 passed, 1 skipped in 31.34s
Scope check
- This PR touches a rail or a default classification — it does NOT: executor logging + tests only (
keel/execution/executor.py,tests/execution/test_executor.py). - No order-routing change (every entry still routes market per #258), no rule change, no simulator/backtest change, no new dependency.
feat(backtest): per-product slippage scaled from liquidity — assumed, capped, and reported (#259) (#334)
What & why
Closes #259. backtest() charged one global 5bp of slippage on both legs of every trade on every product. Since #257 (entries fill at next bar's open as market orders), that constant is the ONLY term modelling spread crossing and market impact — precisely what a market order pays. The 24-product corpus spans orders of magnitude of liquidity (median daily quote volume, measured over the cached ONE_DAY bars on 2026-08-16: BTC $571M, ETH $337M, SOL $109M … WLD $1.2M, TON $370K), so 5bp was plausible for BTC and optimistic for the tail by an unmeasured factor — and the error ran in the flattering direction on exactly the thin assets that kept surfacing as apparent outliers (TON gross PF 3.751 on n=9; WLD 2.810 on n=12).
This PR replaces the flat constant — for callers that opt in — with per-product slippage scaled from the liquidity statistic compliance/screen.py::median_daily_quote_volume already computes (no new data source, no network), and reports the assumed rate beside the results, exactly the way #247 made the fee rate visible. A profit factor printed without its assumed slippage has the same problem a profit factor printed without its fee rate had.
The mapping, and every parameter as a conservative assumption (keel/strategy/backtest.py::slippage_for_quote_volume):
slippage = clamp(SLIPPAGE_FLOOR_PCT * sqrt(SLIPPAGE_REFERENCE_QUOTE_VOLUME / median_volume),
SLIPPAGE_FLOOR_PCT, SLIPPAGE_CAP_PCT)
SLIPPAGE_FLOOR_PCT = 5bp— the liquid-end bound AND the fallback for products with no statistic. Numerically the old global constant: the liquid end was never the problem.SLIPPAGE_REFERENCE_QUOTE_VOLUME = $500M/day— the anchor that maps to the floor. Measured corpus top (BTC $571M) sits just above it, so BTC itself clamps to the floor. A round number, not BTC's exact median, so the anchor stays honest as the corpus re-measures itself.SLIPPAGE_CAP_PCT = 50bp— the thin-end bound, chosen so it binds at exactly 100x below the anchor (sqrt(100) = 10x the floor — a relationship a reader can recover). TON (~1544x below the anchor) would demand ~184bp unclamped, more than any plausible thin-book spread for the 1-unit notional this engine fills; the cap keeps the model conservative without declaring thin products untreatable by construction.- Square-root of inverse volume ratio: the standard practitioner prior for market impact. The mapping is an ASSUMPTION, not a measurement — keel stores no book snapshots or realised spreads — and it is documented and reported as one, monotone (more liquid → never more slippage) and bounded at both ends.
Computed rates for the anchors named in the issue: BTC ($571M) → 5.0bp (floor); a 100x-thinner product ($5M) → 50.0bp (= the cap, by construction); TON ($370K, ~1544x) → unclamped ~184bp, clamped to 50.0bp. On the recent-1y window: ETH 6.0bp, SOL 10.1bp, LTC 38.5bp, PAXG capped at 50.0bp.
API shape: backtest() and report.edge_table() gain slippage_by_product: Callable[[str], Decimal] | None = None, called once per run with rule.product_id. A callable rather than a dict because the caller composes it from its own liquidity statistics plus its own fallback policy, and the flat slippage_pct remains both the default and the per-product fallback — every existing caller's behaviour is identical until it opts in. The simulate/CLI path opts in: it computes the statistic from the ONE_DAY candles the run already loads and prints/writes the per-product table. Products with no cached daily bars fall back to the flat rate and are flagged "fallback (no liquidity statistic)".
Callers left on the flat constant, deliberately: keel rules backtest/rules promote (its shared _run_backtest feeds the promotion gate — out of scope by this PR's own scope check), sim/portfolio_sim.run and the DCA benchmarks (SimAccount's cost model is unchanged; the report states beside the table that the account pass and benchmarks still price at the flat 5bp), strategy/paper.py, and the annotated experiment records in docs/experiments/, which are untouched.
Tests-first evidence
- Tests written first, seen failing for the right reason
Red run before any implementation existed:
tests/strategy/test_backtest.py—ImportError: cannot import name 'SLIPPAGE_CAP_PCT' from 'keel.strategy.backtest'(the mapping did not exist).tests/sim/test_report.py—ImportError: cannot import name 'SLIPPAGE_FLOOR_PCT'(the assumption type did not exist).tests/test_cli.py::test_simulate_reports_per_product_slippage_beside_the_results—AssertionError: assert 'slippage' in …(simulate printed no slippage; a real behavioural failure, not an import error).
New tests pin: the anchor maps to the floor; a 100x-thinner product pays exactly 10x (50bp, the cap, by construction); the cap genuinely clamps TON's measured median (unclamped ~184bp asserted > cap); more-liquid-than-anchor never discounts below the floor; weak monotonicity across a 20,000x synthetic ladder with strict increase through the unclamped middle; zero volume → the cap (fail-closed); measured corpus medians (BTC/ETH/TON) land where the corpus says; backtest() integration — a resolver overrides the flat constant on the entry fill, a thin product nets strictly less than a liquid one on identical candles (the direction check), a resolver answering the flat rate is indistinguishable from no resolver, and omitting the parameter reproduces the flat run exactly; edge_table threads the resolver through and without it prices at the flat rate; the report renders the per-product table (volume, bp, capped flag, fallback flag, assumption stated with parameters) and the legacy fee line is byte-identical when no rows are passed; the CLI prints and writes the table, fallback products flagged.
Gates (all must pass)
-
uv run ruff checkclean —All checks passed! -
uv run mypyclean —Success: no issues found in 237 source files -
uv run pytest -qgreen —2887 passed, 1 skipped in 36.17s(the full suite passing is the "every existing caller unchanged" evidence)
Scope check
- This PR touches a rail or a default classification — cost model + reporting only. No rule, rail, or promotion-gate change:
rules promotestill prices at the flat 5bp exactly as before. The simulate report's G2 verdict necessarily inherits the engine's more conservative pricing — that is the intended direction of #259, not a gate change. - New dependency added — none.
chore(fidelity): close Phase 9's loose ends — alias, stale docstrings, telemetry overflow guard (#336)
What & why
Closes Phase 9's loose ends, from the independent whole-phase review (all findings non-blocking; this PR is bookkeeping and robustness, not behavior):
cli._SIM_SLIPPAGE_PCTis now an alias ofbacktest.SLIPPAGE_FLOOR_PCT, not a repeated0.0005literal. The simulate report asserts its flat-priced dollar sections cost "the flat SLIPPAGE_FLOOR_PCT per leg" — that claim was true by numeric coincidence; a retuned floor would have made the report's own cost statement silently false. Now structurally true, pinned by a test at the site that renders the claim. (portfolio_simandpaperstill carry their own literals — folding them is bundled into #335, the promotion-gate opt-in, so all remaining flat-rate sites move together.)- Two stale #332-era docstrings refreshed:
_log_intent_divergenceno longer claims the per-asset liquidity model "does not exist yet (#259)" — it exists on the research side; the live path just has no statistic at fill time.ENTRY_OVERRIDE_WARN_BP's rationale now citesSLIPPAGE_FLOOR_PCT(not the CLI literal) and states honestly that "10x the slippage assumption" holds at the liquid end and narrows toward #259's 50bp cap — plus an explicit note that the threshold's 50bp and #259's cap 50bp are different, uncoupled constants. - The entry-override warning's arithmetic moved inside a try:
is_finite()admits extreme exponents (a rule bug like1E+999999999parses, is finite, compares fine), and the division raisesArithmeticError— which telemetry must swallow, matching the siblingintent_divergence's inside-the-try arithmetic. Regression-tested. - The promotion-gate opt-in deferral now has its tracking issue (#335), prerequisite-gated like #333 — the phase's one untracked decision is tracked.
Tests-first evidence
The overflow guard was written failing-first conceptually and verified directly (Decimal('1E+999999999') against a quoted preview raises through the old code, returns silently on the new); the regression case is now in test_a_preview_without_a_book_quote_is_silent_not_fatal. The alias pin lives in test_simulate_reports_per_product_slippage_beside_the_results, where the claim it guards is rendered.
Gates
-
uv run ruff check keel tests packages— All checks passed! -
uv run mypy— Success: no issues found in 237 source files -
uv run pytest -q— 2887 passed, 1 skipped
Scope check
- No rail, rule, or classification touched. No behavior change except one: the entry-override telemetry can no longer raise on pathological exponents (strictly toward "never raises").
feat(promotion): pool min_trades across same-parameter rules, with a diversity floor (#338) (#343)
What & why
The promotion gate judged min_trades = 100 per rule per product. At the measured
daily rates (1.19–3.20 trades/asset-year, the go-live runbook's own table) a single
product needs 31–84 years to reach 100 trades — the floor was unreachable in a human
timespan, which is why the sandbox's five rules were live-seeded and why #337 adds an
hourly paper profile that makes samples collectable at all. This PR makes the gate able
to COUNT that evidence.
The change is to the gate's unit of evaluation, not its floors — 100 stays 100. This
is the agreement CONTRIBUTING requires for a gate change, recorded in the issue: the
operator approved it on 2026-08-17. The recorded discipline in promotion.py (the
win-rate axis was relaxed ALONE; axes move only with their own justification) is
respected: PromotionConfig is untouched.
Concretely, when promoting, sibling evidence is gathered — same kind, same params
(exact match on the stored JSON-plain form, minus product_id), different product_id,
status paper — plus the candidate itself, once. The sample-size axis passes if EITHER:
- (a) the per-rule stat clears
min_tradesexactly as today (a rule with no
siblings is judged byte-for-byte as before, and a rule whose own sample is full is
still judged on its own stats — pooling is not a quality rescue for the sample-rich),
OR - (b) pooled n ≥ 100 and a diversity floor: ≥
MIN_POOLED_PRODUCTS(5) distinct
products each contributing ≥MIN_TRADES_PER_PRODUCT_POOLED(10) trades.
The diversity floor is the honest discount on pooling: crypto assets correlate, so a
pool of correlated samples carries less information than its trade count claims —
pooled-but-correlated evidence overstates its statistical power. Requiring breadth
(products each with an independently meaningful sample) is how the pooled path pays for
the larger n instead of just collecting it.
Quality floors (expectancy / rr / win rate) are judged on the POOLED aggregates when
path (b) carries the decision; on the rule's own stats otherwise.
The PBO/overfitting gate (G4) stays per-rule, deliberately. It consumes the CSCV
result for the trial matrix the rule's parameters were selected from (--pbo-session) —
evidence about the parameter selection, which is per-parameter-set already — and this
PR does not change its scope. A pooled promotion without a run overfitting check still
refuses, exactly as before.
keel rules promote prints BOTH readings whenever a pool exists — per-rule n, pooled
n, and the per-product census — and failure reasons name their path:
rule 1 (pullback_continuation): sample readings -- per-rule n_trades=16, pooled n_trades=128 across 8 products
pooled census (diversity floor 5 products x >= 10 trades): ADA-USD=16, BTC-USD=16, DOGE-USD=16, ETH-USD=16, LTC-USD=16, PAXG-USD=16, SOL-USD=16, XLM-USD=16 -- 8 products contribute, min contribution 16
rule 1 (pullback_continuation): overfitting check = pass
rule 1 (pullback_continuation): status -> paper
Pooling arithmetic (stated in pool_stats' docstring)
Pooled from the per-result aggregates BacktestResult actually carries: pooled n = Σn;
per-result wins recovered as round(n × win_rate) (the float round-trip error of
wins/n is ~1e-16, far below 0.5); pooled win rate = Σwins/Σn; pooled avg_win/avg_loss
= win-/loss-weighted means; pooled expectancy/profit_factor/avg_mfe/avg_mae = trade-
weighted means / gross sums with summarize's conventions. max_drawdown and
max_losing_streak are NOT pooled — they are path-dependent and unrecoverable from
aggregates — and are set to 0; the gate reads neither.
One adjacent fix
transition()'s kind-level row lookup ("newest row of this kind") predates multi-row
kinds; with pools made of same-kind sibling rows it would have advanced a sibling the
operator never typed. keel rules promote now pins the target row (rule_id=); the
kind-level lookup remains for library callers that don't name a row (pinned by test).
Closes #338
Tests-first evidence
- Tests written first, seen failing for the right reason
Red run (tests written before any implementation):
$ uv run pytest tests/strategy/test_promotion.py -q
ImportError: cannot import name 'MIN_POOLED_PRODUCTS' from 'keel.strategy.promotion'
1 error during collection
$ uv run pytest tests/test_cli.py -q -k "pooled or siblings or readings"
test_rules_promote_reports_both_readings_and_promotes_via_the_pooled_path FAILED
AssertionError: assert 'per-rule n_trades=16' in <output> # the command printed no readings
test_rules_promote_names_the_diversity_failure_when_the_pool_is_too_narrow FAILED
AssertionError: assert 'pooled n_trades=120 across 4 products' in <output>
1 failed, 1 passed, 75 deselected # then: 2 failed after the module tests went red on import
New coverage: single-product pass/fail unchanged (including "no pooled lines at all");
pooled pass (8×16=128, candidate's own quality fails, pooled quality carries it); pooled
fail on n (8×10=80, reason names path+census); pooled fail on diversity (4×30=120 but
only 4 products); pooled quality judged on pooled aggregates; a full own-sample rule not
rescued by the pool; pooled reading reported even when per-rule passes; params-mismatch
and non-paper rows are not siblings; candidate's own product never double-counted;
duplicate sibling rows pool once; transition promotes via the pooled path and still
refuses without a run overfitting check; CLI prints both readings + census.
Gates (all must pass)
-
uv run ruff checkclean -
uv run mypyclean -
uv run pytest -qgreen
2908 passed, 1 skipped in 38.87s
All checks passed! # ruff
Success: no issues found in 237 source files # mypy
Scope check
- This PR touches a rail or a default classification — checked means it DOES;
leave checked only if true, and if so: cite the source and open the discussion
BEFORE review (CONTRIBUTING.md, "Governance: rulings vs. machinery"). - New dependency added (needs discussion first)
feat(paper): an hourly-cadence paper profile — evidence at a collectable rate (#337) (#344)
What & why
The paper deployment evaluates once per UTC day and daily-turtle rules fire 1.19–3.20 times per asset-year — the promotion gate's n=100 is 31–84 years away. The same rules on ONE_HOUR bars fire ~50/asset-year (median n≈268 over the cached 5-year window), making the sample collectable in months. The honest caveat, stated up front because it changes nothing about the decision: the hourly configuration is measured NET-NEGATIVE — 0 of 90 / 0 of 82 cells at every reachable fee (docs/experiments/2026-08-13-restated-under-a-production-faithful-engine.md). This profile exists to produce ADMISSIBLE EVIDENCE — rail vetoes, outcomes, pending lifespans, intent divergence: the things a backtest cannot observe — not profitability. That caveat is pinned in the config header, the plist comment, and the runbook by tests, so it cannot silently disappear.
Investigation
1. How does the agent map rules to candle granularity?
A cycle feeds each rule candles of the rule's DECLARED granularity, not the profile's configured granularity and not "whatever was fetched". The plumbing:
agent.run_oncepolls and reads every granularity the profile configures (keel/agent.py:855—granularities = list(config.market_data.granularities);:1064—candles_by_tf = {g: repo.get_candles(product_id, g) for g in granularities}). The profile's config is the menu, not the choice.- The choice is the rule's own attribute:
engine._trading_granularity(keel/strategy/engine.py:225-237) returnsrule.granularity/rule.timeframeif declared, else the finest available.agent._entry_gate_granularity(keel/agent.py:221-249) reads the same attributes to gate entries (falling back to the coarsest configured series for rules that declare neither — the DCA hazard).backtest._rule_trading_tf(keel/strategy/backtest.py:208-218) does the same for backtests. - Turtle's granularity was a hard-coded attribute, not a param:
self.granularity = Granularity.ONE_DAYfixed in__init__(keel/strategy/rules/turtle_breakout.py, pre-PR line 140), anddetect()/exit_signal()read theONE_DAYkey unconditionally via_completed_days. No configuration could point the rule at another series — which is why the 2026-08-11 hourly corpus had to hand hourly bars to a rule that "believed they were days" (docs/experiments/2026-08-11-hourly-backtest-turtle-breakout.md§2/§7:backtestkeys the fetchedONE_HOURseries under the rule's declaredONE_DAY, "the rule cannot tell"). - Trading granularity is configured per rule row (
rules.params), constrained by the profile'smarket_data.granularities:PullbackContinuationdeclaresgranularity(constructor param, defaultONE_HOUR) andRsiMeanReversiondeclarestimeframe(dataclass field, persisted asself.timeframe.value, coerced back byagent._GRANULARITY_PARAMS). Pullback is the cautionary tale: it acceptsgranularitybut does NOT persist it indescribe()["params"], sorules addrefuses it rather than silently rebuild the rule at the default on a different candle series (keel/commands/rules.py:822-824, 967-984).
Consequence: an hourly paper profile needs turtle's declaration itself to be a param. This PR adds it (see below).
2. Can an hourly cycle keep ONE_HOUR bars current within venue limits?
Yes, comfortably:
poll_once(keel/data/market_feed.py:184-221) fetches, per(product, granularity)pair, only candles strictly newer than the latest stored and no later than the most recently closed one. Steady state at hourly cadence: ~one small request per pair per cycle. This profile: 5 traded products × 3 configured series = ~15 read-only candle requests per hour (ONE_DAY accrues 1 bar/day, ONE_HOUR 1/hour, FIFTEEN_MINUTE 4/hour) — far under Coinbase's public-endpoint budget, and identical in shape to what the live profile's 24 hourly triggers already do today.- The 350-candle cap self-heals rather than wedging. Coinbase rejects ranges over ~350 candles;
MAX_CANDLES_PER_REQUEST = 300(keel/data/history.py:30) andmarket_feed._request_windows/_poll_catch_up(keel/data/market_feed.py:61-77, 150-181) tile any catch-up range into ≤300-candle windows, upserting per window.repair.pyapplies the same chunking to interior gaps and records "absent at source" windows so permanently empty ones stop retrying. So a machine that was off for days catches up in ONE poll — 300 hourly bars ≈ 12.5 days per window, and windows repeat. - Freshness gates align with the cadence: rail 12's staleness window is
interval_sec × FEED_STALENESS_CYCLES(keel/execution/guards.py:125) = 3600×3 = 3h here, on the finest configured series (FIFTEEN_MINUTE). The entry gatefreshness.entry_bar_ready(keel/data/freshness.py:126-237) for anONE_HOUR-declared rule requires the newest stored hourly bar to be the expected one and every finer series to have crossed the boundary — the :20 trigger's 20-minute publication margin (same margincom.keel.liveuses for the same reason) keeps that from racing; when it does race, the cycle exitsDATA_NOT_READY_EXIT=4, the hour goes unstamped, and the next trigger retries.
3. What defines a "profile"?
Four tracked artifacts per profile (tracked in-repo since 2026-08-03 per docs/RELEASING.md:101-104; the scripts' older "gitignored" header lines are stale on that point and this PR's new files say "tracked"): config (config.paperforward.yaml / config.live-sandbox.yaml), database (keel.db / keel-live.db — the --db CLI flag, default keel.db), launchd plist (com.keel.paperforward.plist — local-anchored, 09:00–20:00 hourly triggers + RunAtLoad, LOCAL day-stamp; com.keel.live.plist — 24 hourly triggers at :20, UTC day-stamp, where the stamp is a correctness mechanism because nothing on the live path dedupes an entry, pinned by tests/test_schedule.py), and run script + wrapper (paperforward-run.sh + keel-paper; keel-live-run.sh + keel-live, the wrapper pinning --config X --db Y together so --db's keel.db default can never silently cross the ledgers). The operator-facing contract for the pair lives in docs/operator-runbook.md "Paper vs. live". This PR adds the third profile in exactly that shape: config.paper-hourly.yaml + keel-paperhourly.db + com.keel.paper-hourly.plist + paper-hourly-run.sh + keel-paperhourly.
The code change (and the default-compatibility story)
TurtleBreakout gains granularity: Granularity = Granularity.ONE_DAY:
- Declared exactly the way RSI declares
timeframe(the convention that round-trips): persisted inparamsasgranularity.value(a JSON-plain string), registered inagent._GRANULARITY_PARAMSsobuild_rule_from_paramscoerces"ONE_HOUR"back to the enum. Not Pullback's non-persisted convention, whichrules addrefuses. detect()/exit_signal()readself._trading_series(candles_by_tf): theONE_DAYdefault keeps_completed_days' forming-bar guard verbatim (the account-sim lookahead guard, pinned by existing tests); any other declared granularity reads that series verbatim — the same contract pullback/rsi already trade under (agent persists only closed candles; the sim decides at the current bar's close). An absent key declines as insufficient history rather than silently falling back to another granularity's bars.- Default compatibility: turtle rows already exist in
keel.dbandkeel-live.dbwith nogranularitykey; they rebuild at theONE_DAYdefault and keep meaning exactly what they meant. This is the asymmetry_params_delta(keel/commands/rules.py:734-740) already documents for any kind that grows a param, anddeploy/live-rules.jsonis deliberately NOT regenerated (it mirrors the live rows, which are unchanged). Pinned bytest_a_row_written_before_the_param_existed_defaults_to_daily. - Everything downstream follows with no further code: the entry gate (
_entry_gate_granularity), the engine's trading-TF and higher-TF bias gate (an hourly turtle gets the ONE_DAY bias check, like every hourly rule), andbacktest._rule_trading_tfall read the declared attribute.
The deployment profile
config.paper-hourly.yaml— paperforward's exact universe (same allowlist/weights/caps/fees/paper seed, pinned by test) atinterval_sec: 3600, header carrying the net-negative caveat and the separate-database contract.com.keel.paper-hourly.plist— 24 hourly triggers at :20 + RunAtLoad, comment blocks matching the existing plists (including the XML no-double-hyphen rule).paper-hourly-run.sh— its own stamp semantics: stamps the UTC hour (date -u '+%Y-%m-%dT%H'). The paperforward day-stamp is daily-grained and would collapse 23 of 24 cycles into no-ops (the exact regression a copy-paste would ship — mutation-checked red, see below). The stamp is cadence bookkeeping here, not the live path's duplicate-entry barrier: paper already refuses a second entry while a product is open (strategy/paper.py), but duplicate cycles would still inflate the rail-veto/no-signal evidence counts.keel-paperhourly— wrapper pinning--config config.paper-hourly.yaml --db keel-paperhourly.db.docs/operator-runbook.md— third column in the paper-vs-live table + "The hourly evidence profile" section: purpose (evidence cadence), the net-negative caveat, the separate database with exact bootstrap (keel migrate --db, per-productrules add --params '{"granularity": "ONE_HOUR"}', deliberaterules promote --forcewith the reason,keel fetch), the one-param difference from every other turtle row, and the honest limits (an hour lost to power-off is lost; the runner cannot replay bars).
Tests-first evidence
- Tests written first, seen failing for the right reason
Red (before the param existed), failing on the assertion meant to assert — not an import error:
FAILED tests/strategy/test_turtle_breakout.py::TestDeclaredGranularity::test_the_default_is_daily_and_is_persisted
assert rule.params["granularity"] == "ONE_DAY"
E KeyError: 'granularity'
plus test_an_hourly_rule_detects_on_the_one_hour_series (TypeError: unexpected kwarg) and tests/test_agent.py::test_coerced_param_keys_... (frozenset mismatch). Green after the change: same tests pass, full suite below.
For the runner's regression tests (written after the runner, so discrimination was proven by mutation instead — both mutations confirmed red, then reverted and re-verified green):
- mutating the stamp to daily-grained (
date -u '+%Y-%m-%d') →test_the_next_utc_hour_runs_its_own_cycleFAILED (1 failed); - dropping the
--db keel-paperhourly.dbpin →test_the_cycle_runs_the_hourly_config_against_its_own_databaseFAILED (1 failed).
Gates (all must pass)
-
uv run ruff checkclean —All checks passed! -
uv run mypyclean —Success: no issues found in 238 source files -
uv run pytest -qgreen —2909 passed, 1 skipped in 36.85s
Scope check
- This PR touches a rail or a default classification — unchecked: no rail, no gate (#338's PR owns that), no change to the live or paperforward profiles' semantics (their rows, configs, plists and runners are untouched;
config.paperforward.yaml/com.keel.paperforward.plist/paperforward-run.shdiff-free). - New dependency added — none.
Closes #337
feat(status): surface rail-17 attestation expiry before it vetoes, and a rules enable verb (#340) (#345)
Closes #340
What & why
On 2026-08-14 rail 17 vetoed the only live DCA signal because the withdrawal-capability
attestation had expired; as of 2026-08-17 both deployments' attestations were weeks stale, so
rail 17 was halting entries everywhere — and nothing surfaced that until the veto fired. This
PR makes the staleness VISIBLE before it vetoes, records the weekly refresh habit beside the
other account-level obligations, and closes the re-enable gap #339 depends on. The typed
re-attestation stays a human terminal action BY DESIGN — this is visibility and cadence, never
automation.
keel statusrail-17 line (also rendered bykeel tui, styledalerton every halted
state): a newWithdrawalAttestationStatuson the existingStatusReport, resolved through
the executor's own_withdrawals_enabledand aged with the executor's own
WITHDRAWAL_ATTESTATION_TTL_SEC— never a restated 7 days — so the display cannot call an
attestation fresh on the very cycle the rail vetoes it:rail 17 (withdrawal capability): attested, expires in 3drail 17 (withdrawal capability): EXPIRED 12d ago -- entries halted; re-attest with keel withdrawals attestrail 17 (withdrawal capability): never attested -- entries halted; re-attest with keel withdrawals attest- (plus the fresh-deliberate case)
... SUSPENDED -- entries halted; re-attest with keel withdrawals attest --enabled
- Runbook cadence note (
docs/operator-runbook.md, item 3 beside the USDC-rewards note):
the weekly re-attestation habit, naming the consequence of missing it (rail 17 fails closed
on unknown → live DCA buys vetoed, the 2026-08-14 event) and why the typed confirmation must
stay human — a scheduled job must never release a §65.4 halt. keel rules enable <rule_id>: the inverse ofrules disable's WRITE. Verified that
disablerecords nothing about prior status (it stamps onlydemoted_at), soenable
restores tocandidate— the bottom of the ladder — and prints the path onward
(rules promote, gated;--forceas the documented bypass for paper-forwards that can
never reach the min_trades floor, e.g. DCA). The docstring documents that a rule disabled
fromlivelands atcandidate, notlive. This is the supported CLI path #339's
"re-enable the disabled paper DCA twins" needs.
Tests-first evidence
- Tests written first, seen failing for the right reason
Red run (all 11 new/extended assertions failing on the pre-change code, none for an import
error — the rail-17 gather tests fail on the missing withdrawal_attestation field, the
render/CLI tests on the missing line, the enable tests on No such command 'enable'):
FAILED tests/commands/test_status.py::test_rail17_attested_fresh_shows_time_remaining
FAILED tests/commands/test_status.py::test_rail17_expired_names_the_halt_and_the_fix
FAILED tests/commands/test_status.py::test_rail17_never_attested_is_said_as_such
FAILED tests/commands/test_status.py::test_rail17_suspended_attestation_still_names_the_halt
FAILED tests/commands/test_status.py::test_rail17_freshness_uses_the_executors_own_ttl
FAILED tests/commands/test_status.py::test_rail17_line_sits_beside_rail11
FAILED tests/commands/test_status.py::test_status_command_runs_read_only_and_prints_key_facts
FAILED tests/commands/test_status.py::test_status_command_json_flag_emits_parseable_json
FAILED tests/test_cli.py::test_rules_enable_restores_a_disabled_rule_to_candidate
FAILED tests/test_cli.py::test_rules_enable_on_a_non_disabled_rule_is_an_error
FAILED tests/test_cli.py::test_rules_disable_then_enable_round_trip_lands_at_candidate
9 failed, 2 passed, 95 deselected
The TUI rail-17 styling tests were likewise seen red (5 failed) before the TUI line was
added.
Gates (all must pass)
-
uv run ruff checkclean —All checks passed! -
uv run mypyclean —Success: no issues found in 238 source files -
uv run pytest -qgreen —2945 passed, 1 skipped in 35.38s
Scope check
- This PR touches a rail or a default classification — checked means it DOES;
leave checked only if true, and if so: cite the source and open the discussion
BEFORE review (CONTRIBUTING.md, "Governance: rulings vs. machinery"). - New dependency added (needs discussion first)
No rail, guard, or classification changes: rail 17's veto logic is untouched; this PR only
reads the same state through the executor's own resolver. Stale comments claiming disabled
is terminal were updated (rules.py disable docstring, promotion.py _PROMOTE_NEXT note)
to name the new operator verb.
chore(release): 0.9.0 (#346)
What & why
Version bump across all six distributions; closes #339's first checkbox. Minor (0.9.0), not patch: the changes since v0.8.1 are a feature set —
- #334 per-product backtest slippage scaled from liquidity (5–50bp, assumed/capped/reported) — the honest cost model;
- #332 the executor's routing-time WARNING when a conditional entry is overridden (#260's mitigation);
- #336 the Phase 9 cleanup (floor alias, stale docstrings, telemetry overflow guard);
- #338 pooled
min_tradeswith a cross-sectional diversity floor — the promotion gate can now count evidence collected across the universe; - #337 the hourly-cadence paper profile (turtle's granularity became a persisted param, daily default unchanged) — evidence at a collectable rate;
- #340 rail-17 attestation expiry visible in
keel status/TUI before it vetoes, and thekeel rules enableverb.
No rail, rule, or default classification changed semantics; the promotion gate changed its unit of evaluation per the operator-approved #338, floors untouched.
Tests-first evidence
tests/test_packaging.py pins the sibling-== invariants; after the bump and uv lock: 2947 passed, 1 skipped; ruff clean; mypy clean.
Gates
-
uv run ruff check keel tests packages— All checks passed! -
uv run mypy— Success: no issues found in 238 source files -
uv run pytest -q— 2947 passed, 1 skipped
Scope check
- Version numbers and uv.lock only.