Skip to content

keel v0.9.0

Choose a tag to compare

@github-actions github-actions released this 17 Aug 21:43
· 53 commits to main since this release
eebf12b

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.

⚠️ Never install by bare name. The distribution is keel-trader; the name
keel on PyPI belongs to an unrelated project, so pip install keel fetches
someone else's package. A build reporting DIRTY or [checkout] is not this
release and must not be run against live funds.

Configure

config.yaml is attached to this release: the production config, in
auto_trade.mode: confirm — keel previews every order and waits for your
approval. Drop it beside the install (or run keel init-config --live), put
your CDP key in a git-ignored .env, then:

keel migrate     # existing database: apply schema migrations
keel init        # fresh deployment: write config + seed candidate rules

Seeded rules start as candidate and trade nothing until you promote them.

Other changes

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.py gains ENTRY_OVERRIDE_WARN_BP (50bp, documented below) and _warn_if_market_routing_overrides_entry, called from _run_order right after the preview, before the confirm gate.
  • Market reference: the venue's own best_ask out of the preview _run_order already 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_order maps best_bid/best_ask to Decimal today, and the Coinbase port adapter carries the same book in Preview.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 explicit detail sentence: 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 check clean — All checks passed!
  • uv run mypy clean — Success: no issues found in 237 source files
  • uv run pytest -q green — 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.pyImportError: cannot import name 'SLIPPAGE_CAP_PCT' from 'keel.strategy.backtest' (the mapping did not exist).
  • tests/sim/test_report.pyImportError: cannot import name 'SLIPPAGE_FLOOR_PCT' (the assumption type did not exist).
  • tests/test_cli.py::test_simulate_reports_per_product_slippage_beside_the_resultsAssertionError: 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 check clean — All checks passed!
  • uv run mypy clean — Success: no issues found in 237 source files
  • uv run pytest -q green — 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 promote still 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_PCT is now an alias of backtest.SLIPPAGE_FLOOR_PCT, not a repeated 0.0005 literal. 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_sim and paper still 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_divergence no 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 cites SLIPPAGE_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 like 1E+999999999 parses, is finite, compares fine), and the division raises ArithmeticError — which telemetry must swallow, matching the sibling intent_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_trades exactly 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 check clean
  • uv run mypy clean
  • uv run pytest -q green
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_once polls and reads every granularity the profile configures (keel/agent.py:855granularities = list(config.market_data.granularities); :1064candles_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) returns rule.granularity / rule.timeframe if 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_DAY fixed in __init__ (keel/strategy/rules/turtle_breakout.py, pre-PR line 140), and detect()/exit_signal() read the ONE_DAY key 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: backtest keys the fetched ONE_HOUR series under the rule's declared ONE_DAY, "the rule cannot tell").
  • Trading granularity is configured per rule row (rules.params), constrained by the profile's market_data.granularities: PullbackContinuation declares granularity (constructor param, default ONE_HOUR) and RsiMeanReversion declares timeframe (dataclass field, persisted as self.timeframe.value, coerced back by agent._GRANULARITY_PARAMS). Pullback is the cautionary tale: it accepts granularity but does NOT persist it in describe()["params"], so rules add refuses 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) and market_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.py applies 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 gate freshness.entry_bar_ready (keel/data/freshness.py:126-237) for an ONE_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 margin com.keel.live uses for the same reason) keeps that from racing; when it does race, the cycle exits DATA_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 in params as granularity.value (a JSON-plain string), registered in agent._GRANULARITY_PARAMS so build_rule_from_params coerces "ONE_HOUR" back to the enum. Not Pullback's non-persisted convention, which rules add refuses.
  • detect()/exit_signal() read self._trading_series(candles_by_tf): the ONE_DAY default 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.db and keel-live.db with no granularity key; they rebuild at the ONE_DAY default 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, and deploy/live-rules.json is deliberately NOT regenerated (it mirrors the live rows, which are unchanged). Pinned by test_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), and backtest._rule_trading_tf all 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) at interval_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.shits 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-product rules add --params '{"granularity": "ONE_HOUR"}', deliberate rules promote --force with 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_cycle FAILED (1 failed);
  • dropping the --db keel-paperhourly.db pin → test_the_cycle_runs_the_hourly_config_against_its_own_database FAILED (1 failed).

Gates (all must pass)

  • uv run ruff check clean — All checks passed!
  • uv run mypy clean — Success: no issues found in 238 source files
  • uv run pytest -q green — 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.sh diff-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 status rail-17 line (also rendered by keel tui, styled alert on every halted
    state): a new WithdrawalAttestationStatus on the existing StatusReport, resolved through
    the executor's own _withdrawals_enabled and 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 3d
    • rail 17 (withdrawal capability): EXPIRED 12d ago -- entries halted; re-attest with keel withdrawals attest
    • rail 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 of rules disable's WRITE. Verified that
    disable records nothing about prior status (it stamps only demoted_at), so enable
    restores to candidate — the bottom of the ladder — and prints the path onward
    (rules promote, gated; --force as the documented bypass for paper-forwards that can
    never reach the min_trades floor, e.g. DCA). The docstring documents that a rule disabled
    from live lands at candidate, not live. 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 check clean — All checks passed!
  • uv run mypy clean — Success: no issues found in 238 source files
  • uv run pytest -q green — 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_trades with 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 the keel rules enable verb.

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.