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