diff --git a/com.keel.paper-hourly.plist b/com.keel.paper-hourly.plist
new file mode 100644
index 0000000..a711639
--- /dev/null
+++ b/com.keel.paper-hourly.plist
@@ -0,0 +1,101 @@
+
+
+
+
+ Label
+ com.keel.paper-hourly
+
+
+ ProgramArguments
+
+ /bin/bash
+ /Users/elmehdiaitbrahim/keel/paper-hourly-run.sh
+
+
+ StartCalendarInterval
+
+ Hour0Minute20
+ Hour1Minute20
+ Hour2Minute20
+ Hour3Minute20
+ Hour4Minute20
+ Hour5Minute20
+ Hour6Minute20
+ Hour7Minute20
+ Hour8Minute20
+ Hour9Minute20
+ Hour10Minute20
+ Hour11Minute20
+ Hour12Minute20
+ Hour13Minute20
+ Hour14Minute20
+ Hour15Minute20
+ Hour16Minute20
+ Hour17Minute20
+ Hour18Minute20
+ Hour19Minute20
+ Hour20Minute20
+ Hour21Minute20
+ Hour22Minute20
+ Hour23Minute20
+
+
+
+ RunAtLoad
+
+
+ WorkingDirectory
+ /Users/elmehdiaitbrahim/keel
+
+ StandardOutPath
+ /Users/elmehdiaitbrahim/keel/logs/paper-hourly.out.log
+ StandardErrorPath
+ /Users/elmehdiaitbrahim/keel/logs/paper-hourly.err.log
+
+
diff --git a/config.paper-hourly.yaml b/config.paper-hourly.yaml
new file mode 100644
index 0000000..6d6b31c
--- /dev/null
+++ b/config.paper-hourly.yaml
@@ -0,0 +1,150 @@
+# keel runtime configuration — the HOURLY-CADENCE paper profile (issue #337).
+#
+# Same structure and same universe as config.paperforward.yaml; the ONE thing this profile
+# changes is CADENCE. The daily Turtle rules fire 1.19-3.20 times per asset-year, so the
+# promotion gate's n=100 per rule per product is 31-84 years away on the daily clock. The
+# same rules evaluated on ONE_HOUR bars fire ~50 times per asset-year (median n=268 over the
+# 5-year cached window) — the sample becomes collectable in months. See
+# docs/experiments/2026-08-11-hourly-backtest-turtle-breakout.md and the 2026-08-13 restatement.
+#
+# HONEST CAVEAT, STATED UP FRONT: the hourly configuration is measured NET-NEGATIVE — 0 of 90
+# / 0 of 82 cells at every fee this venue offers (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. Do not read a positive stretch here
+# as anything but noise; n≈250 sequential, same-regime trades are not 250 independent draws.
+#
+# SEPARATE DATABASE. The rows that trade under this config live in keel-paperhourly.db
+# (seeded hourly: turtle_breakout rules with params.granularity="ONE_HOUR", status paper),
+# pinned together by the keel-paperhourly wrapper — never in keel.db (the daily paper
+# account) or keel-live.db. Bootstrap commands are in docs/operator-runbook.md, "The hourly
+# evidence profile".
+#
+# `allowlist` and `caps` are required and validated by keel.config.load_config; missing or
+# invalid values raise ConfigError naming the offending key rather than silently defaulting.
+
+allowlist:
+ - BTC
+ - ETH
+ - PAXG
+ - SOL
+ - XLM
+ - LTC
+ - ADA
+ - LINK
+
+target_weights:
+ BTC: 0.30
+ ETH: 0.20
+ PAXG: 0.20
+ SOL: 0.06
+ XLM: 0.06
+ LTC: 0.06
+ ADA: 0.06
+ LINK: 0.06
+
+risk_pct: 0.01
+
+caps:
+ # Same non-binding internal limits as the daily paper profile — deliberately NOT tighter:
+ # this profile's evidence is partly ABOUT rail behaviour (rail vetoes are admissible
+ # evidence), so silently clamping sizing here would bias exactly the thing being measured.
+ max_exposure_usd: 5000
+ max_per_asset_pct: 0.50
+
+market_data:
+ # Same three series as paperforward. The hourly rules TRADE on ONE_HOUR; ONE_DAY stays the
+ # higher-TF bias input (engine._higher_tf_bias_ok) and FIFTEEN_MINUTE stays the entry-gate
+ # confirmation series. An hourly cycle keeps them current with ~16 small read-only candle
+ # requests per hour (8 products x 2 series polled per cycle; 24 in the UTC-boundary hour
+ # when ONE_DAY also rolls), and market_feed's 300-candle chunked catch-up
+ # self-heals any downtime in one poll.
+ granularities:
+ - ONE_DAY
+ - ONE_HOUR
+ - FIFTEEN_MINUTE
+ history_days: 365
+
+auto_trade:
+ mode: paper
+ # NOTE: currently UNUSED by any code path -- it is NOT a kill-switch and setting it
+ # true or false changes nothing. Use `keel kill` to halt trading.
+ enabled: false
+ # THE CADENCE CHANGE. 3600 (one hour), not paperforward's 900: each cycle evaluates the
+ # ONE_HOUR bar that closed at the top of the hour. This value also scales the feed-staleness
+ # window (interval_sec x FEED_STALENESS_CYCLES = 3h): freshness is judged on the FINEST
+ # series, so a feed twelve FIFTEEN_MINUTE bars (3h) behind is "stale", not "one bar late".
+ interval_sec: 3600
+
+promotion:
+ min_trades: 100
+ min_expectancy: 0.0
+ min_rr: 1.5
+ min_win_rate: 0.55
+
+money_mgmt:
+ profit_trigger_pct: 0.10
+ acceleration_pct: 0.05
+ max_total_dd_pct: 0.20
+ max_weekly_dd_pct: 0.08
+ # Rail 16 (consecutive-loss breaker) — DISABLED by default (0 = off), same as paperforward.
+ max_consecutive_losses: 0
+ streak_cooloff_days: 0
+
+dca:
+ budget_usd: 50
+ cadence_days: 7
+
+paper:
+ # Same seed/contribution as the daily paper profile, deliberately: the hourly corpus's
+ # net-negative measurement was taken under these settings, and changing the sizing basis
+ # here would confound the one comparison this profile exists to enable. NOTE the documented
+ # interplay (operator-runbook, "How much money moves"): at $10k+ synthetic equity, rail 14's
+ # $500/month allowance can veto large sized setups — those vetoes are themselves evidence,
+ # and are recorded as such.
+ starting_equity_usd: 10000
+ monthly_contribution_usd: 500
+
+# The settlement currency this deployment TRADES IN — USD, same as paperforward (see that
+# file's comment for the full reasoning; nothing about cadence changes it).
+quote_currency: USD
+
+subscription:
+ # Same simulator assumptions as the daily paper profile.
+ assumed_free_volume_usd: 500
+ unsubscribed_allowance_usd: 0
+ pacing: opportunistic
+
+# Coinbase One subscription tiers (Issue #86) — same catalogue as paperforward.
+tiers:
+ - name: Basic
+ free_volume_usd: 500
+ subscription_usd_month: 4.99
+ - name: Preferred
+ free_volume_usd: 10000
+ subscription_usd_month: 29.99
+ - name: Premium
+ free_volume_usd: null
+ subscription_usd_month: 299.99
+
+# Same fee model as paperforward: paper fills are market-style, so taker is the rate this
+# account would actually pay (and the rate the net-negative measurement was taken at).
+fees:
+ taker_pct: 0.012
+ maker_pct: 0.006
+
+# Same logging posture as paperforward — and NOTE it matters MORE here: 24 cycles a day means
+# rail vetoes and no-signal reasons arrive hourly, and verbose=false would make "why no order
+# this hour?" a manual database replay. Rotation bounds the volume.
+logging:
+ verbose: true
+ file: logs/keel-paperhourly.log
+ max_file_mb: 25
+ file_count: 5
+
+# G4 overfitting gate (KB §78). NEVER tune these to obtain a desired verdict. Same floors as
+# every other profile; the hourly profile's rows are expected to FAIL them on edge (see the
+# caveat above) — that failure is the finding, not a configuration problem.
+research:
+ pbo_max: 0.05
+ slope_floor: -0.5
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index e96a208..ca66186 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -98,10 +98,12 @@ an existing rule's params (it reports drift and exits non-zero) and refuses to c
without `--allow-live` — a manifest must never be able to resize or arm a rule by file edit, which
is what the promotion ladder exists to prevent. Promotion stays `keel rules promote`'s job.
-The deployment's **configs** (`config.live-sandbox.yaml`, `config.paperforward.yaml`, the run
-scripts, the launchd plists and the `keel-live`/`keel-paper` wrappers) are tracked as of
-2026-08-03 for the same reason. They are still excluded from the wheel and the release assets —
-that exclusion comes from the packaging config, which ships only `keel/`, not from `.gitignore`.
+The deployment's **configs** (`config.live-sandbox.yaml`, `config.paperforward.yaml`,
+`config.paper-hourly.yaml`, the run scripts, the launchd plists and the `keel-live`/
+`keel-paper`/`keel-paperhourly` wrappers) are tracked as of 2026-08-03 for the same reason
+(the hourly profile joined them in #337). They are still excluded from the wheel and the
+release assets — that exclusion comes from the packaging config, which ships only `keel/`,
+not from `.gitignore`.
The **Migrate database** workflow (Actions → Migrate database → Run workflow) is manual-only. Give
it a `db_path` to migrate that database; leave it empty and it verifies the migration chain
diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md
index ce20d00..d99efc1 100644
--- a/docs/operator-runbook.md
+++ b/docs/operator-runbook.md
@@ -152,18 +152,20 @@ histories. **A figure from one says nothing about the other.** Checking a paper
against live account equity — or a live cap against paper cash — yields a confident wrong answer,
and has already produced one. Establish which account a number came from before reasoning about it.
-| | paper | live |
-| --- | --- | --- |
-| config | `config.paperforward.yaml` | `config.live-sandbox.yaml` |
-| database | `keel.db` (the `--db` default) | `keel-live.db` (must be passed) |
-| `auto_trade.mode` | `paper` | `confirm` |
-| allowlist | BTC, ETH, PAXG, SOL, XLM, LTC, ADA, LINK (8) | BTC, ETH, PAXG, ADA, XLM (5) |
-| `caps.max_exposure_usd` | 5000 | 200 |
-| money spent | synthetic `paper_cash_usdc` | the real broker balance |
-| sizing basis | the paper account's own equity | `caps.max_exposure_usd`, as a proxy |
-| rail 14 allowance | $500/month (Basic tier) | $200/month |
-| `equity_state_mode` | `paper` | `live` |
-| launchd job | `com.keel.paperforward` | `com.keel.live` |
+| | paper | live | paper-hourly |
+| --- | --- | --- | --- |
+| config | `config.paperforward.yaml` | `config.live-sandbox.yaml` | `config.paper-hourly.yaml` |
+| database | `keel.db` (the `--db` default) | `keel-live.db` (must be passed) | `keel-paperhourly.db` (must be passed) |
+| `auto_trade.mode` | `paper` | `confirm` | `paper` |
+| allowlist | BTC, ETH, PAXG, SOL, XLM, LTC, ADA, LINK (8) | BTC, ETH, PAXG, ADA, XLM (5) | same 8 as paper |
+| `caps.max_exposure_usd` | 5000 | 200 | 5000 |
+| money spent | synthetic `paper_cash_usdc` | the real broker balance | synthetic `paper_cash_usdc` |
+| sizing basis | the paper account's own equity | `caps.max_exposure_usd`, as a proxy | the hourly account's own equity |
+| rail 14 allowance | $500/month (Basic tier) | $200/month | $500/month |
+| `equity_state_mode` | `paper` | `live` | `paper` |
+| launchd job | `com.keel.paperforward` | `com.keel.live` | `com.keel.paper-hourly` |
+| cadence | daily (day-stamp) | daily, UTC (UTC day-stamp) | **hourly**, UTC (UTC hour-stamp) |
+| rules traded | daily turtle, `paper` | daily turtle + DCA, `live` | **hourly** turtle, `paper` |
**Which one am I looking at.** On any dashboard (`keel status`, `keel insights`, `keel tui`) the
`equity_state_mode` line names the account the equity, high-water mark and drawdown figures
@@ -183,7 +185,9 @@ places nothing — *unless autonomy is armed*, which is exactly what makes an un
place. Autonomy changes who is asked, never what is allowed; check the flag before assuming a
live cycle is supervised, rather than inferring it from `confirm`.
-**Both fire hourly; both run once a day.** Each launchd job has a list of hourly triggers plus
+**Both fire hourly; both run once a day** (the third job, `com.keel.paper-hourly`, is the
+exception that runs once per UTC *hour* — see "The hourly evidence profile" below). Each
+launchd job has a list of hourly triggers plus
`RunAtLoad`, and each runner is day-stamped: the first eligible trigger that finds no stamp for
today runs the cycle and writes the stamp, and every later trigger that day is a no-op. The
trigger count is **catch-up breadth, not cadence** — launchd re-runs a calendar interval missed
@@ -202,6 +206,68 @@ daily bar (`tests/test_schedule.py` pins it).
the same day therefore produce different quantities on the two accounts, and neither is an estimate
of the other. The settings behind those numbers are covered next.
+## The hourly evidence profile (paper-hourly)
+
+A third deployment, `config.paper-hourly.yaml` + `keel-paperhourly.db`, running the **same**
+turtle rules on a different bar clock: one paper cycle per **UTC hour** (`com.keel.paper-hourly.plist`
+fires hourly at :20; `paper-hourly-run.sh` stamps the UTC hour). Use `./keel-paperhourly `
+so the config and database always travel as a pair.
+
+**Why it exists: evidence cadence, not profitability.** The daily-turtle rules fire 1.19–3.20
+times per asset-year, so a promotion gate demanding n=100 per rule per product is 31–84 years
+away — waiting is not a slower path, it is no path. The same rules evaluated on `ONE_HOUR` bars
+fire ~50 times per asset-year (median n=268 over the 5-year cached window;
+`docs/experiments/2026-08-11-hourly-backtest-turtle-breakout.md`), which makes the sample
+collectable in months.
+
+**The honest caveat, which changes nothing about the decision: the hourly configuration is
+measured NET-NEGATIVE** — 0 of 90 / 0 of 82 cells at every fee this venue offers, restated
+2026-08-13 under the production-faithful engine
+(`docs/experiments/2026-08-13-restated-under-a-production-faithful-engine.md`). This profile
+produces **admissible evidence** — rail vetoes, outcomes, pending lifespans, intent divergence:
+the things a backtest cannot observe — not profit. Do not promote from it on a positive stretch:
+n≈250 sequential trades inside one regime are not 250 independent draws. Daily-tuned parameters
+on an hourly clock is also, legitimately, a different strategy (the experiment's own §7) — which
+is exactly why the forward evidence this profile accrues is the only kind that can settle it.
+
+**Bootstrap.** The database is created at deploy time by the operator and is empty until then
+(24 hourly cycles against an unseeded database log `signals=0` and do nothing else — there are
+no rules to evaluate):
+
+```bash
+keel migrate --db keel-paperhourly.db # schema only; never seeds
+# Seed the hourly rules. `rules seed` cannot do this (it writes each kind's constructor
+# defaults, i.e. daily), so add each row with the one param that makes it hourly:
+for p in BTC ETH PAXG SOL XLM LTC ADA LINK; do
+ keel --config config.paper-hourly.yaml --db keel-paperhourly.db rules add \
+ --kind turtle_breakout --product "${p}-USD" --params '{"granularity": "ONE_HOUR"}'
+done
+# Advance each printed id candidate -> paper. --force is the documented bypass for a rule
+# whose backtest can never clear the gate; for hourly turtle the backtest clears min_trades
+# easily and fails on EDGE (the net-negative finding above), so force is deliberate here and
+# the warning it prints is the caveat restated:
+keel --config config.paper-hourly.yaml --db keel-paperhourly.db rules promote --force
+# Warm the candle cache (ONE_HOUR/ONE_DAY/FIFTEEN_MINUTE x 365d) before the first cycle:
+keel --config config.paper-hourly.yaml --db keel-paperhourly.db fetch
+```
+
+**The rows differ from every other turtle row by one param.** `params.granularity: "ONE_HOUR"`
+— `TurtleBreakout`'s declared trading timeframe, persisted the way `RsiMeanReversion.timeframe`
+is and coerced back by `keel/agent.py`'s registry. A row with no `granularity` key (every row
+written before the param existed) keeps meaning daily. `keel rules list` shows the param; it is
+the one thing to check when a cycle logs `signals=0` and you need to know which clock a row trades.
+
+**Cadence mechanics.** Hourly candles close at the top of each UTC hour; the :20 trigger gives
+Coinbase twenty minutes to publish and `data.market_feed` to persist the bar (the same margin
+`com.keel.live` uses for the same reason). The runner stamps the UTC **hour**
+(`date -u '+%Y-%m-%dT%H'`) — the paperforward day-stamp is daily-grained and would collapse 23
+of the 24 cycles into no-ops. The stamp is cadence bookkeeping, not the duplicate-entry barrier
+it is on live: the paper path already refuses a second entry while a product is open
+(`strategy/paper.py`). A failed cycle leaves the hour unstamped and the next trigger retries
+against the then-newest bar. An hour lost to the machine being powered off is lost — the runner
+cannot replay bars that closed while it was down; that is an hour of evidence, not an hour of
+money, and it is why the profile's duty cycle matters more than its exact schedule.
+
## How much money moves
Four settings decide position size and how much can be spent. Three live in `config.yaml`; the
diff --git a/keel-paperhourly b/keel-paperhourly
new file mode 100755
index 0000000..5f67e49
--- /dev/null
+++ b/keel-paperhourly
@@ -0,0 +1,25 @@
+#!/bin/bash
+# keel-paperhourly -- run ANY keel command against the HOURLY paper profile (issue #337),
+# with the config and database pinned together, symmetric with `keel-live`/`keel-paper`.
+#
+# Same footgun `keel-live` exists to remove: `--db` defaults to keel.db, so
+# keel --config config.paper-hourly.yaml agent
+# without `--db keel-paperhourly.db` would drive the hourly rules against the DAILY paper
+# ledger. Config and db must always travel as a pair; here they do.
+#
+# Paper mode places NOTHING real. The rows this profile loads are `paper`-status turtle rules
+# with params.granularity="ONE_HOUR" (see docs/operator-runbook.md, "The hourly evidence
+# profile", for the bootstrap). The live READ-ONLY Coinbase API is still used for candles.
+#
+# ./keel-paperhourly status
+# ./keel-paperhourly agent # one hourly cycle (same as paper-hourly-run.sh)
+# ./keel-paperhourly rules list
+#
+# Authored in the dev repo (tracked there since 2026-08-03). DEPLOY (copy) to ~/keel.
+set -euo pipefail
+
+# Resolve the deployment dir from this script's own location, so it works from any cwd.
+DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$DIR"
+
+exec ./.venv/bin/keel --config config.paper-hourly.yaml --db keel-paperhourly.db "$@"
diff --git a/keel/agent.py b/keel/agent.py
index 6541a63..3bdf0df 100644
--- a/keel/agent.py
+++ b/keel/agent.py
@@ -118,9 +118,15 @@
}
# The constructor kwarg holding a `Granularity`, stored as its `.value` string, per kind.
+# `turtle_breakout`'s entry is what lets the hourly evidence profile (#337) store hourly rows:
+# without it, a `granularity` in a turtle row's params would reach the constructor as the
+# STRING "ONE_HOUR" and `isinstance(value, Granularity)` lookups (`_entry_gate_granularity`,
+# `engine._trading_granularity`) would miss it, silently re-gating the rule on the coarsest
+# configured granularity while it kept deciding on daily candles.
_GRANULARITY_PARAMS: dict[str, str] = {
"pullback_continuation": "granularity",
"rsi_meanrev": "timeframe",
+ "turtle_breakout": "granularity",
}
diff --git a/keel/strategy/rules/turtle_breakout.py b/keel/strategy/rules/turtle_breakout.py
index b439693..11bd480 100644
--- a/keel/strategy/rules/turtle_breakout.py
+++ b/keel/strategy/rules/turtle_breakout.py
@@ -13,14 +13,25 @@
high channel); the opposite side (a close below the low channel) is exit-only, wired through
`exit_signal()`, never a short entry -- consistent with every other rule in this codebase.
-**This is a DAILY rule** (`self.granularity = Granularity.ONE_DAY`, a fixed attribute read by
-the evaluation engine via `getattr(rule, "granularity")` -- see
-`strategy.engine._trading_granularity` -- not a persisted param). The classic Turtle system is
+**This is a DAILY rule BY DEFAULT** (`self.granularity = Granularity.ONE_DAY`, a constructor
+param persisted in `params` and read by the evaluation engine via `getattr(rule,
+"granularity")` -- see `strategy.engine._trading_granularity`). The classic Turtle system is
a daily trend-follower, and that is not cosmetic: ADX is a *daily-scale* trend measure. On
noisy hourly bars +DI/-DI cancel out and ADX stays structurally suppressed (measured ~7 median
/ 18 max on real hourly BTC/ETH regardless of period), so an ADX>25 gate on hourly data never
fires and the rule produces zero trades. On daily candles ADX(14) has a ~26 median and
Donchian-high breakouts routinely coincide with ADX>25. So the lookback defaults are DAY counts.
+
+**The `granularity` param exists to make the rule MEASURABLE on another clock, not profitable
+on one (issue #337).** The hourly evidence profile (`config.paper-hourly.yaml`) runs the SAME
+rule at `granularity=ONE_HOUR`, where the 40/20/14/20 defaults become BAR counts on hourly
+candles: measured at n≈250 trades per 5 years (vs 4-13 daily) and NET-NEGATIVE -- 0 of 90 /
+0 of 82 cells at every reachable fee (docs/experiments/2026-08-13). That profile collects
+admissible forward evidence (rail vetoes, outcomes, pending lifespans) at a rate the daily
+clock cannot supply; the param defaults to `ONE_DAY` so every row written before it existed
+keeps meaning exactly what it meant. It follows `RsiMeanReversion.timeframe`'s persisted-param
+convention, not `PullbackContinuation`'s non-persisted one (which `keel rules add` refuses
+rather than silently rebuilding the rule at the default on a different candle series).
The entry/exit default to **40/20** -- a longer channel than the classic Turtle System-1 20/10 --
chosen by **walk-forward out-of-sample validation** on cached 5yr BTC/ETH/PAXG (every entry
lookback longer than 20 beat 20 out-of-sample; 40 was the most robust across held-out years). The
@@ -92,8 +103,9 @@ def _completed_days(candles_by_tf: dict[Granularity, list[Candle]]) -> list[Cand
class TurtleBreakout(Rule):
"""Donchian-breakout trend-follower: ADX-gated entry, asymmetric channel exit, 2xATR stop.
- One instance trades a single `product_id` on `ONE_DAY` candles only (`self.granularity`,
- fixed -- not one of the tunable `params`).
+ One instance trades a single `product_id` on the candles of its declared `granularity`
+ (default `ONE_DAY`; a persisted param -- see the module docstring for why it exists and
+ why the default is daily).
`promotion_class = "trend_follow"` routes it to the low-win/high-R:R promotion floor
(`strategy.promotion.floor_for_class`): a breakout trend-follower wins under half its trades
@@ -110,6 +122,10 @@ class TurtleBreakout(Rule):
def __init__(
self,
product_id: str,
+ # Trading timeframe; the lookbacks below are BAR counts at THIS granularity, so the
+ # daily defaults mean what they always meant and an hourly row reinterprets them as
+ # hours -- the hourly corpus's exact configuration (docs/experiments/2026-08-11 §2).
+ granularity: Granularity = Granularity.ONE_DAY,
entry_lookback: int = 40, # Donchian-high entry (days); walk-forward OOS default (was 20)
exit_lookback: int = 20, # Donchian-low asymmetric exit (days); half the entry (was 10)
adx_period: int = 14, # 14 days -- classic ADX
@@ -137,8 +153,9 @@ def __init__(
self.name = name
self.product_id = product_id
- self.granularity = Granularity.ONE_DAY
+ self.granularity = granularity
self.params: dict = {
+ "granularity": granularity.value,
"entry_lookback": entry_lookback,
"exit_lookback": exit_lookback,
"adx_period": adx_period,
@@ -181,6 +198,24 @@ def _decline(self, gate: str, **numbers: object) -> Setup | None:
self.last_rejection = {"gate": gate, **numbers}
return None
+ def _trading_series(
+ self, candles_by_tf: dict[Granularity, list[Candle]]
+ ) -> list[Candle]:
+ """The series this rule decides on: `candles_by_tf[self.granularity]`.
+
+ At the `ONE_DAY` default that is `_completed_days`'s forming-bar-guarded daily series,
+ exactly as it always was. At any other declared granularity it is that series verbatim
+ (no forming-day guard applies): the agent's `data.market_feed` persists only CLOSED
+ candles, and the account sim presents the current bar as decided-on at its close --
+ the same contract `PullbackContinuation`/`RsiMeanReversion` already trade under. An
+ absent key yields an empty series, which `detect()` declines as insufficient history
+ rather than silently falling back to some other granularity's bars -- a rule configured
+ for hourly must never quietly decide on daily candles.
+ """
+ if self.granularity is Granularity.ONE_DAY:
+ return _completed_days(candles_by_tf)
+ return candles_by_tf.get(self.granularity, [])
+
def detect(self, candles_by_tf: dict[Granularity, list[Candle]]) -> Setup | None:
"""Confirmed close above the prior entry-lookback Donchian high, ADX>threshold, and
(optionally) a positive MACD histogram -- then a 2xATR stop and a distant nominal
@@ -188,10 +223,9 @@ def detect(self, candles_by_tf: dict[Granularity, list[Candle]]) -> Setup | None
this target; it only exists to clear the evaluation engine's rr>=1 kill-zone gate and
let winners run past a fixed 1:1/2:1 cap).
"""
- # Drop the last daily bar only when it is genuinely still forming -- see
- # `_completed_days`. The daily-only edge backtest has no ONE_HOUR key and every daily
- # bar is already closed, so it uses them all.
- daily = _completed_days(candles_by_tf)
+ # The declared granularity's series; at the ONE_DAY default this drops a genuinely
+ # still-forming last bar -- see `_completed_days`/`_trading_series`.
+ daily = self._trading_series(candles_by_tf)
entry_lookback = self.params["entry_lookback"]
exit_lookback = self.params["exit_lookback"]
@@ -306,8 +340,9 @@ def exit_signal(
nominal target are the backtester/account-sim's job to enforce separately.
"""
del held
- # Same forming-bar guard as detect() -- see `_completed_days`.
- daily = _completed_days(candles_by_tf)
+ # Same declared-granularity series (and, at the ONE_DAY default, forming-bar guard)
+ # as detect() -- see `_trading_series`/`_completed_days`.
+ daily = self._trading_series(candles_by_tf)
exit_lookback = self.params["exit_lookback"]
if len(daily) <= exit_lookback + 1:
diff --git a/paper-hourly-run.sh b/paper-hourly-run.sh
new file mode 100755
index 0000000..e757aa6
--- /dev/null
+++ b/paper-hourly-run.sh
@@ -0,0 +1,54 @@
+#!/bin/bash
+# Hourly paper runner -- ~/keel deployment (keel release install), issue #337.
+# One agent cycle per UTC HOUR in PAPER mode against the live READ-ONLY Coinbase API; it
+# places NOTHING real. Drives the HOURLY turtle rows (turtle rules stored with
+# params.granularity="ONE_HOUR") in their OWN database, keel-paperhourly.db.
+#
+# Self-contained: calls the deployment's own venv binary, so no `uv`/asdf PATH is needed.
+# Lives OUTSIDE ~/Documents for the same TCC reason as paperforward-run.sh (launchd-spawned
+# processes are not granted access to ~/Documents; home root is not TCC-protected).
+#
+# EXACTLY ONCE PER **UTC HOUR**, not per day. paperforward-run.sh's day-stamp is deliberately
+# daily-grained (the daily Turtle wants one cycle per day); this profile's evidence is the
+# hourly bar, so the stamp here is the UTC HOUR (`date -u '+%Y-%m-%dT%H'`). Every trigger at
+# or after hh:20 that finds no stamp for hour hh runs the cycle and stamps it; later triggers
+# in the same hour (a repeated load, fall-back's repeated local hour, a wake-catch-up rerun)
+# are no-ops. The NEXT hour always needs, and gets, its own cycle.
+#
+# THE STAMP IS CADENCE BOOKKEEPING HERE, NOT THE CORRECTNESS MECHANISM IT IS ON THE LIVE
+# PATH. The paper path refuses a second entry while a product is already open
+# (strategy/paper.py), so a duplicate cycle cannot double a position. It still guards the
+# EVIDENCE: two cycles in one hour would evaluate the same closed bar twice and inflate the
+# rail-veto and no-signal counts the profile exists to collect.
+#
+# The stamp is written only AFTER a successful cycle (`set -e`), so a failed run (no network
+# on wake, say) is retried by the next hourly trigger rather than being recorded as done.
+# Note what a retry can and cannot do: it evaluates the NEWEST closed bar at its own time,
+# so a bar whose hour went unstamped is covered by the next cycle's evaluation only if that
+# bar is still the newest closed bar (a signal that was there is re-seen; a bar skipped
+# while powered off is gone -- an hour of evidence, not an hour of money).
+#
+# Authored in the dev repo (tracked there since 2026-08-03). DEPLOY (copy) to ~/keel and
+# schedule via com.keel.paper-hourly.plist.
+set -euo pipefail
+
+DIR="/Users/elmehdiaitbrahim/keel"
+cd "$DIR"
+
+STAMP="$DIR/logs/.paper-hourly-last-run"
+
+THIS_HOUR="$(date -u '+%Y-%m-%dT%H')"
+STAMPED="$(cat "$STAMP" 2>/dev/null || true)"
+
+if [ "$STAMPED" = "$THIS_HOUR" ]; then
+ printf '%s [paper-hourly] cycle already ran this UTC hour (%s) -- skipping\n' \
+ "$(date '+%Y-%m-%d %H:%M')" "$THIS_HOUR"
+ exit 0
+fi
+
+# One cycle per UTC hour; the LaunchAgent supplies the cadence and the retries. Paper mode,
+# hourly rules, the profile's own database. A failure here stops short of the stamp
+# (`set -e`), so the next trigger retries.
+./.venv/bin/keel --config config.paper-hourly.yaml --db keel-paperhourly.db agent
+
+printf '%s\n' "$THIS_HOUR" > "$STAMP"
diff --git a/tests/strategy/test_turtle_breakout.py b/tests/strategy/test_turtle_breakout.py
index cd62dd4..eec9a4a 100644
--- a/tests/strategy/test_turtle_breakout.py
+++ b/tests/strategy/test_turtle_breakout.py
@@ -1,9 +1,14 @@
"""Tests for keel.strategy.rules.turtle_breakout.TurtleBreakout.
-TurtleBreakout is a DAILY rule: `detect()`/`exit_signal()` read the `ONE_DAY` key. Small
-parameter values (`entry_lookback=5, exit_lookback=3, adx_period=5, atr_period=5`) are used
-throughout so short, hand-built daily series are enough to exercise every gate: the Donchian-
-high breakout, the ADX trend-confirmation filter, and the asymmetric Donchian-low channel exit.
+TurtleBreakout is a DAILY rule BY DEFAULT: `detect()`/`exit_signal()` read the `ONE_DAY` key
+that its `granularity` param declares. That param is persisted (`RsiMeanReversion.timeframe`'s
+convention, not `PullbackContinuation`'s non-persisted one) so an hourly evidence profile
+(issue #337) can run the SAME rule on `ONE_HOUR` bars -- measured there at n≈250 per 5 years
+but NET-NEGATIVE (docs/experiments/2026-08-13, 0 of 90 / 0 of 82 cells); the param exists to
+make that measurement collectable, not profitable. Small parameter values (`entry_lookback=5,
+exit_lookback=3, adx_period=5, atr_period=5`) are used throughout so short, hand-built daily
+series are enough to exercise every gate: the Donchian-high breakout, the ADX
+trend-confirmation filter, and the asymmetric Donchian-low channel exit.
The edge backtester passes only the rule's native series (`{ONE_DAY: ...}`, no `ONE_HOUR`
key), so these edge-style fixtures use every daily bar. A separate class exercises the
@@ -162,6 +167,96 @@ def test_defaults_are_day_counts(self) -> None:
assert rule.params["atr_period"] == 20
+class TestDeclaredGranularity:
+ """The `granularity` param: how the hourly evidence profile (issue #337) runs this rule.
+
+ Everything downstream keys off the rule's DECLARED timeframe -- `engine.
+ _trading_granularity`, `agent._entry_gate_granularity`, `backtest._rule_trading_tf` -- so
+ an hourly turtle requires the declaration itself to be a param, persisted the way
+ `RsiMeanReversion.timeframe` is (its `.value` string inside `params`, coerced back by
+ `agent._GRANULARITY_PARAMS`). `PullbackContinuation` is the counter-example this must not
+ copy: it accepts `granularity` but does NOT persist it, so `rules add` refuses it outright
+ rather than silently rebuild the rule at the default on a different candle series.
+ """
+
+ def test_the_default_is_daily_and_is_persisted(self) -> None:
+ rule = TurtleBreakout(product_id="BTC-USD")
+
+ assert rule.granularity is Granularity.ONE_DAY
+ # `.value` (a plain string), so `describe()`'s params are JSON-plain and the row
+ # round-trips without a second serialization step.
+ assert rule.params["granularity"] == "ONE_DAY"
+
+ def test_an_hourly_rule_detects_on_the_one_hour_series(self) -> None:
+ rule = _rule(granularity=Granularity.ONE_HOUR)
+
+ setup = rule.detect({Granularity.ONE_HOUR: _breakout_candles()})
+
+ assert setup is not None
+ assert setup.ts == _breakout_candles()[-1].ts
+
+ def test_an_hourly_rule_does_not_read_the_one_day_series(self) -> None:
+ """A daily-keyed breakout alone must not fire an hourly rule: the declared series is
+ absent, exactly like `test_missing_granularity_key_returns_none` for the daily default.
+
+ This is the discrimination that makes the param real rather than decorative -- before
+ it existed, `detect()` read `ONE_DAY` unconditionally, so no configuration could point
+ the rule at another series (docs/experiments/2026-08-11 §7: the hourly corpus had to
+ hand hourly bars to a rule that "believed they were days").
+ """
+ rule = _rule(granularity=Granularity.ONE_HOUR)
+
+ assert rule.detect({Granularity.ONE_DAY: _on_days(_breakout_candles())}) is None
+
+ def test_an_hourly_exit_reads_the_one_hour_series(self) -> None:
+ rule = _rule(granularity=Granularity.ONE_HOUR)
+ held = rule.detect({Granularity.ONE_HOUR: _breakout_candles()})
+ assert held is not None
+
+ fires = rule.exit_signal(held, {Granularity.ONE_HOUR: _falling_candles()})
+
+ assert fires is True
+
+ def test_an_hourly_rule_round_trips_through_agent_build_rule(self) -> None:
+ """A stored hourly row must rebuild as an hourly rule -- the whole point of persisting
+ the param, since `keel-paperhourly`'s rows are read back by every agent cycle."""
+ rule = _rule(granularity=Granularity.ONE_HOUR)
+ params = dict(rule.describe()["params"])
+ # Simulate the DB round trip: Decimal params get JSON-serialized to strings, and
+ # `product_id` is re-applied by the writer (turtle's describe() does not carry it).
+ params["atr_stop_mult"] = str(params["atr_stop_mult"])
+ params["target_rr"] = str(params["target_rr"])
+ params["product_id"] = "BTC-USD"
+
+ rebuilt = agent._build_rule({"kind": "turtle_breakout", "params": params})
+
+ assert rebuilt.granularity is Granularity.ONE_HOUR
+ assert rebuilt.detect({Granularity.ONE_HOUR: _breakout_candles()}) is not None
+ assert rebuilt.detect({Granularity.ONE_DAY: _on_days(_breakout_candles())}) is None
+
+ def test_a_row_written_before_the_param_existed_defaults_to_daily(self) -> None:
+ """Default-compatibility: turtle rows already sit in `keel.db`/`keel-live.db` with no
+ `granularity` key, and they must keep meaning exactly what they meant -- daily. This is
+ the same asymmetry `_params_delta` (keel/commands/rules.py) already documents for any
+ kind that grows a param: an old row simply has no such key.
+ """
+ params = {
+ "product_id": "BTC-USD",
+ "entry_lookback": 5,
+ "exit_lookback": 3,
+ "adx_period": 5,
+ "atr_period": 5,
+ "adx_threshold": 25.0,
+ "atr_stop_mult": "2",
+ "target_rr": "6",
+ }
+
+ rebuilt = agent._build_rule({"kind": "turtle_breakout", "params": params})
+
+ assert rebuilt.granularity is Granularity.ONE_DAY
+ assert rebuilt.detect({Granularity.ONE_DAY: _on_days(_breakout_candles())}) is not None
+
+
class TestDetectFires:
def test_breakout_with_trend_confirmation_returns_long_setup(self) -> None:
rule = _rule()
diff --git a/tests/test_agent.py b/tests/test_agent.py
index 8636028..a883c52 100644
--- a/tests/test_agent.py
+++ b/tests/test_agent.py
@@ -36,6 +36,7 @@
from keel.strategy.rules.base import Action, Rule, Setup, Signal
from keel.strategy.rules.dca import Dca
from keel.strategy.rules.pullback_continuation import PullbackContinuation
+from keel.strategy.rules.turtle_breakout import TurtleBreakout
from keel.types import Candle, Granularity, Side
from tests.conftest import attest_subscription
@@ -608,6 +609,30 @@ def test_build_rule_populates_rule_id_from_the_row(repo):
assert rule.rule_id == rule_id
+def test_build_rule_reconstructs_a_turtle_rule_with_its_declared_granularity():
+ """The hourly paper profile (#337) stores turtle rows at `ONE_HOUR`; the coercion boundary
+ must turn the stored `.value` string back into the enum, exactly as it does for
+ `RsiMeanReversion.timeframe`. A row with NO `granularity` key keeps the constructor's
+ `ONE_DAY` default, which is what every pre-existing turtle row keeps meaning (pinned in
+ tests/strategy/test_turtle_breakout.py, `TestDeclaredGranularity`)."""
+ row = {
+ "kind": "turtle_breakout",
+ "params": {
+ "product_id": PRODUCT,
+ "granularity": "ONE_HOUR",
+ "entry_lookback": 40,
+ "atr_stop_mult": "2",
+ "target_rr": "6",
+ },
+ }
+
+ rule = _build_rule(row)
+
+ assert isinstance(rule, TurtleBreakout)
+ assert rule.granularity is Granularity.ONE_HOUR
+ assert rule.params["atr_stop_mult"] == Decimal("2")
+
+
def test_build_rule_leaves_rule_id_none_for_a_row_with_no_id():
"""A hand-built row (no "id" key -- e.g. a caller assembling params directly, not via
`repo.get_rules()`) must not raise; `rule_id` just stays at its default `None`."""
@@ -657,7 +682,7 @@ def test_coerced_param_keys_names_every_param_that_arrives_as_a_string():
)
assert agent.coerced_param_keys("dca") == frozenset({"budget_usd", "dip_bonus_pct"})
assert agent.coerced_param_keys("turtle_breakout") == frozenset(
- {"atr_stop_mult", "target_rr"}
+ {"atr_stop_mult", "target_rr", "granularity"}
)
diff --git a/tests/test_paper_hourly_profile.py b/tests/test_paper_hourly_profile.py
new file mode 100644
index 0000000..509f636
--- /dev/null
+++ b/tests/test_paper_hourly_profile.py
@@ -0,0 +1,324 @@
+"""The HOURLY paper profile's tracked deployment assets (issue #337).
+
+`config.paper-hourly.yaml` + `com.keel.paper-hourly.plist` + `paper-hourly-run.sh` +
+`keel-paperhourly` are the third deployment profile, tracked in-repo exactly like the
+paperforward and live ones (since 2026-08-03; see docs/RELEASING.md). Nothing about them is
+executed by the test suite's code paths, so like `tests/test_schedule.py` for the live
+schedule, this file pins them so they cannot drift silently: the config must stay the SAME
+universe at the hourly cadence, the plist must keep 24 hourly triggers and `RunAtLoad`, and
+the runner must keep its once-per-UTC-hour stamp (the paperforward day-stamp would collapse
+23 of the 24 cycles into no-ops -- the exact regression a copy-paste of that script would
+ship).
+
+The runner tests execute the REAL script verbatim through a harness that shims `date` (so
+they do not depend on, or wait on, the wall clock) and stubs the deployment's
+`.venv/bin/keel`. No sandboxing is needed here, unlike `test_schedule.py`: this script places
+nothing real, notifies nobody, and is repointed away from `~/keel` before it runs.
+"""
+
+from __future__ import annotations
+
+import os
+import plistlib
+import re
+import stat
+import subprocess
+from datetime import UTC, datetime
+from pathlib import Path
+
+from keel.config import load_config
+from keel.types import Granularity
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+CONFIG = REPO_ROOT / "config.paper-hourly.yaml"
+PAPERFORWARD_CONFIG = REPO_ROOT / "config.paperforward.yaml"
+PLIST = REPO_ROOT / "com.keel.paper-hourly.plist"
+RUN_SCRIPT = REPO_ROOT / "paper-hourly-run.sh"
+WRAPPER = REPO_ROOT / "keel-paperhourly"
+RUNBOOK = REPO_ROOT / "docs" / "operator-runbook.md"
+
+
+# -- the config: same universe as paperforward, hourly cadence --------------------------------
+
+
+def test_config_is_the_same_universe_as_paperforward():
+ """Everything that defines WHAT is traded matches the daily paper profile; only the
+ cadence differs. If this fails because paperforward moved, move with it or say why here:
+ the hourly corpus measurement (n≈268, net-negative) was taken on this universe, and a
+ silently different one would make the two profiles' evidence incomparable."""
+ hourly = load_config(str(CONFIG))
+ daily = load_config(str(PAPERFORWARD_CONFIG))
+
+ assert sorted(hourly.allowlist) == sorted(daily.allowlist)
+ assert hourly.target_weights == daily.target_weights
+ assert hourly.caps == daily.caps
+ assert hourly.quote_currency == daily.quote_currency
+ assert hourly.fees == daily.fees
+ assert hourly.paper == daily.paper
+
+
+def test_config_trades_paper_on_the_hourly_cadence():
+ """The three load-bearing differences from paperforward: paper mode, a ONE_HOUR cycle
+ (`interval_sec: 3600`), and the same three candle series (the hourly rules trade ONE_HOUR;
+ ONE_DAY stays the higher-TF bias input, FIFTEEN_MINUTE the entry-gate confirmation)."""
+ config = load_config(str(CONFIG))
+
+ assert config.auto_trade.mode == "paper"
+ assert config.auto_trade.interval_sec == 3600
+ assert config.market_data.granularities == [
+ Granularity.ONE_DAY,
+ Granularity.ONE_HOUR,
+ Granularity.FIFTEEN_MINUTE,
+ ]
+
+
+def test_config_states_the_net_negative_caveat_in_its_header():
+ """The issue's honesty requirement: anyone opening the config reads the caveat BEFORE the
+ numbers, not in a runbook they may never open. A config that stopped saying it would look
+ like a strategy worth copying."""
+ text = CONFIG.read_text()
+ assert "NET-NEGATIVE" in text
+ assert "ADMISSIBLE EVIDENCE" in text
+ assert "not profitability" in text
+ assert "keel-paperhourly.db" in text
+
+
+# -- the plist: one trigger per hour, its own stamp semantics ----------------------------------
+
+
+def _plist() -> dict:
+ return plistlib.loads(PLIST.read_bytes())
+
+
+def test_plist_is_well_formed_xml():
+ """Same requirement as com.keel.live.plist: parse with a STRICT parser. XML forbids a
+ double hyphen inside a comment, this repo's prose puts one in every other sentence, and
+ Apple's lenient parser accepts it -- so a malformed file would ship silently."""
+ _plist()
+
+
+def test_plist_fires_every_hour_at_twenty():
+ """24 triggers, one per local hour, all at :20 -- so every UTC hour gets a trigger under
+ any local offset, DST transitions included, with twenty minutes of margin for Coinbase to
+ publish and `data.market_feed` to persist the hourly candle that closed at :00 (the same
+ margin `com.keel.live.plist` uses for the same reason)."""
+ data = _plist()
+ triggers = [(entry["Hour"], entry["Minute"]) for entry in data["StartCalendarInterval"]]
+ assert sorted(triggers) == [(hour, 20) for hour in range(24)]
+
+
+def test_plist_still_runs_at_load():
+ """A boot between triggers must run the current UTC hour's cycle immediately rather than
+ wait up to an hour; the runner's hour-stamp makes a repeated load harmless."""
+ assert _plist()["RunAtLoad"] is True
+
+
+def test_plist_points_at_the_hourly_runner_in_the_deployment_dir():
+ data = _plist()
+ assert data["Label"] == "com.keel.paper-hourly"
+ assert data["ProgramArguments"] == [
+ "/bin/bash",
+ "/Users/elmehdiaitbrahim/keel/paper-hourly-run.sh",
+ ]
+ assert data["WorkingDirectory"] == "/Users/elmehdiaitbrahim/keel"
+
+
+def test_plist_documents_its_own_stamp_semantics():
+ """The paperforward day-stamp is daily-grained; this job needs an HOUR-stamp. That fact,
+ the net-negative purpose, and the :20 margin must all live in the plist's comment block
+ where the next reader of the file will meet them."""
+ text = PLIST.read_text()
+ assert "hour-stamp" in text or "hour stamp" in text
+ assert "NET-NEGATIVE" in text
+ assert ":20" in text
+
+
+# -- the runner: exactly once per UTC hour ------------------------------------------------------
+
+
+def _install_date_shim(bin_dir: Path) -> None:
+ """A `date` on PATH that reads its instant from `$KEEL_TEST_NOW` (epoch seconds) instead
+ of the wall clock. Pure Python rather than a shell wrapper around `date -r`, so the
+ harness is portable (the live schedule's shim leans on BSD `date -r`). Honours the
+ invocation forms `paper-hourly-run.sh` actually uses: `date [-u] '+FORMAT'`.
+ """
+ bin_dir.mkdir(parents=True, exist_ok=True)
+ shim = bin_dir / "date"
+ shim.write_text(
+ "#!/usr/bin/env python3\n"
+ "import os, sys, time\n"
+ "now = int(os.environ.get('KEEL_TEST_NOW', time.time()))\n"
+ "args = sys.argv[1:]\n"
+ "utc = '-u' in args\n"
+ "# the leading '+' is date(1)'s format-string prefix, not part of the format\n"
+ "fmt = next(a for a in args if a.startswith('+'))[1:]\n"
+ "import datetime\n"
+ "t = datetime.datetime.fromtimestamp(now, datetime.timezone.utc)\n"
+ "if not utc:\n"
+ " t = t.astimezone()\n"
+ "print(t.strftime(fmt))\n"
+ )
+ shim.chmod(shim.stat().st_mode | stat.S_IEXEC)
+
+
+def _sandbox(tmp_path: Path, keel_exit_code: int) -> tuple[Path, Path, Path, dict[str, str]]:
+ """Copy the REAL runner into `tmp_path`, repointed at the sandbox, with a stubbed `keel`.
+
+ Only ONE rewrite, load-bearing for safety: `DIR="..."` -> `tmp_path`, so the gate, the
+ stamp and the invocation all run VERBATIM. No notification redirection is needed (this
+ script never notifies); no sandbox-exec either (it places nothing real -- but the DIR
+ rewrite is still asserted so a test can never run the deployment's own copy).
+ """
+ source = RUN_SCRIPT.read_text()
+ patched, count = re.subn(
+ r'^DIR="[^"]*"$', f'DIR="{tmp_path}"', source, count=1, flags=re.MULTILINE
+ )
+ assert count == 1, "could not repoint DIR -- refusing to run a script aimed at the deployment"
+ assert "/Users/elmehdiaitbrahim/keel" not in patched
+
+ script = tmp_path / "paper-hourly-run.sh"
+ script.write_text(patched)
+
+ (tmp_path / "logs").mkdir(parents=True, exist_ok=True)
+ stub_dir = tmp_path / ".venv" / "bin"
+ stub_dir.mkdir(parents=True, exist_ok=True)
+ invocations = stub_dir / "keel.invocations"
+ stub = stub_dir / "keel"
+ stub.write_text(
+ "#!/bin/bash\n"
+ f'printf "%s\\n" "$*" >> "{invocations}"\n'
+ f"exit {keel_exit_code}\n"
+ )
+ stub.chmod(stub.stat().st_mode | stat.S_IEXEC)
+
+ date_bin = tmp_path / "shim-bin"
+ _install_date_shim(date_bin)
+
+ env = dict(os.environ)
+ env["PATH"] = f"{date_bin}:{env.get('PATH', '')}"
+ return script, invocations, tmp_path / "logs" / ".paper-hourly-last-run", env
+
+
+def _run(script: Path, env: dict[str, str], now_utc: datetime) -> subprocess.CompletedProcess[str]:
+ run_env = dict(env)
+ run_env["KEEL_TEST_NOW"] = str(int(now_utc.timestamp()))
+ return subprocess.run(
+ ["/bin/bash", str(script)], capture_output=True, text=True, env=run_env
+ )
+
+
+def _count_lines(path: Path) -> int:
+ if not path.exists():
+ return 0
+ return len(path.read_text().splitlines())
+
+
+def test_a_clean_cycle_stamps_the_utc_hour_and_the_same_hour_is_a_no_op(tmp_path):
+ """The dedupe, end to end, in the real shell: a successful cycle stamps THIS_HOUR
+ (`YYYY-MM-DDThh`, UTC); any later trigger in the same UTC hour (a repeated load,
+ fall-back's repeated local hour) does nothing."""
+ script, invocations, stamp, env = _sandbox(tmp_path, keel_exit_code=0)
+ at_1420 = datetime(2026, 6, 15, 14, 20, tzinfo=UTC)
+
+ first = _run(script, env, at_1420)
+ assert first.returncode == 0
+ assert _count_lines(invocations) == 1
+ assert stamp.read_text().strip() == "2026-06-15T14"
+
+ second = _run(script, env, at_1420)
+ assert second.returncode == 0
+ assert "already ran" in second.stdout
+ assert _count_lines(invocations) == 1
+
+
+def test_the_next_utc_hour_runs_its_own_cycle(tmp_path):
+ """The regression a copy of paperforward-run.sh would ship: that script stamps the DATE,
+ so 23 of this job's 24 daily triggers would be no-ops and the profile would silently
+ collect daily evidence. The stamp must be hour-grained."""
+ script, invocations, stamp, env = _sandbox(tmp_path, keel_exit_code=0)
+
+ assert _run(script, env, datetime(2026, 6, 15, 14, 20, tzinfo=UTC)).returncode == 0
+ assert _run(script, env, datetime(2026, 6, 15, 15, 20, tzinfo=UTC)).returncode == 0
+ assert _run(script, env, datetime(2026, 6, 15, 16, 50, tzinfo=UTC)).returncode == 0
+
+ assert _count_lines(invocations) == 3
+ assert stamp.read_text().strip() == "2026-06-15T16"
+
+
+def test_the_cycle_runs_the_hourly_config_against_its_own_database(tmp_path):
+ """Config and database must travel as a pair: `--db` defaults to keel.db (the DAILY paper
+ account), so a runner that dropped the flag would drive hourly rows against the wrong
+ ledger -- the exact footgun the `keel-live`/`keel-paperhourly` wrappers exist to remove."""
+ script, invocations, _, env = _sandbox(tmp_path, keel_exit_code=0)
+
+ _run(script, env, datetime(2026, 6, 15, 14, 20, tzinfo=UTC))
+
+ assert invocations.read_text().strip() == (
+ "--config config.paper-hourly.yaml --db keel-paperhourly.db agent"
+ )
+
+
+def test_a_failed_cycle_writes_no_stamp_so_the_same_hour_retries(tmp_path):
+ """Same failure direction as paperforward/live: a cycle that died (no network on wake,
+ venue late publishing the bar) must not be recorded as done. A later trigger in the SAME
+ hour retries; the stamp only appears once a cycle succeeds."""
+ script, invocations, stamp, env = _sandbox(tmp_path, keel_exit_code=4)
+
+ failed = _run(script, env, datetime(2026, 6, 15, 14, 20, tzinfo=UTC))
+ assert failed.returncode == 4, "the script must surface the cycle's exit code, not mask it"
+ assert not stamp.exists(), "a failed cycle must leave the hour unstamped so it is retried"
+
+ retried = _run(script, env, datetime(2026, 6, 15, 14, 40, tzinfo=UTC))
+ assert retried.returncode == 4
+ assert "already ran" not in retried.stdout
+ assert _count_lines(invocations) == 2
+
+
+def test_a_failed_cycle_is_retried_and_then_stamped_by_a_later_trigger(tmp_path):
+ """The two-step of the failure path, in one sandbox: fail at 14:20 (no stamp), succeed at
+ 14:40 (stamps the same UTC hour), no-op at 14:59."""
+ script, invocations, stamp, env = _sandbox(tmp_path, keel_exit_code=4)
+ at_1420 = datetime(2026, 6, 15, 14, 20, tzinfo=UTC)
+ at_1440 = datetime(2026, 6, 15, 14, 40, tzinfo=UTC)
+
+ assert _run(script, env, at_1420).returncode == 4
+ assert not stamp.exists()
+
+ # Flip the stub to success in place, then re-fire inside the same hour.
+ stub = tmp_path / ".venv" / "bin" / "keel"
+ stub.write_text(f'#!/bin/bash\nprintf "cycle\\n" >> "{invocations}"\nexit 0\n')
+ stub.chmod(stub.stat().st_mode | stat.S_IEXEC)
+
+ ok = _run(script, env, at_1440)
+ assert ok.returncode == 0
+ assert stamp.read_text().strip() == "2026-06-15T14"
+ assert _count_lines(invocations) == 2
+
+ later = _run(script, env, datetime(2026, 6, 15, 14, 59, tzinfo=UTC))
+ assert later.returncode == 0
+ assert "already ran" in later.stdout
+ assert _count_lines(invocations) == 2
+
+
+# -- the wrapper and the runbook ----------------------------------------------------------------
+
+
+def test_wrapper_pins_the_config_and_database_together():
+ """`keel-paperhourly` exists for the same reason as `keel-live`: `--db` defaults to
+ keel.db, and the hourly rows must never touch the daily paper ledger."""
+ text = WRAPPER.read_text()
+ assert "--config config.paper-hourly.yaml" in text
+ assert "--db keel-paperhourly.db" in text
+
+
+def test_runbook_documents_the_profile_its_database_and_the_caveat():
+ """The operator-facing contract: where paper-vs-live is documented, the third profile
+ must appear with its bootstrap and the net-negative caveat -- the issue's acceptance
+ demands the caveat be impossible to miss."""
+ text = RUNBOOK.read_text()
+ assert "The hourly evidence profile" in text
+ assert "config.paper-hourly.yaml" in text
+ assert "keel-paperhourly.db" in text
+ assert "NET-NEGATIVE" in text
+ assert "keel migrate --db keel-paperhourly.db" in text
+ assert '"granularity": "ONE_HOUR"' in text