Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions com.keel.paper-hourly.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.keel.paper-hourly</string>

<!-- NOTE ON COMMENT STYLE, same as com.keel.live.plist: the rest of this repo separates
asides with a double hyphen. XML forbids a double hyphen inside a comment, and while
Apple's CoreFoundation parser is lenient enough to accept one (plutil -lint says OK),
a strict parser such as expat does not, which makes the file unreadable to ordinary
XML tooling and to the tests that assert on the schedule below. Do not "fix" it back.

HOURLY-CADENCE PAPER profile (issue #337): one paper cycle per UTC hour, against
config.paper-hourly.yaml and its OWN database keel-paperhourly.db (pinned by
paper-hourly-run.sh). It places NOTHING real. It exists because the daily-turtle
rules fire 1.19 to 3.20 times per asset-year, so the promotion gate's n=100 is 31 to
84 years away on the daily clock, while the SAME rules on ONE_HOUR bars fire ~50
times per asset-year (median n=268 over the cached 5-year window): evidence at a
rate a human can collect. MEASURED NET-NEGATIVE, 0 of 90 / 0 of 82 cells at every
reachable fee (docs/experiments/2026-08-13-restated-under-a-production-faithful-
engine.md). This job collects ADMISSIBLE EVIDENCE (rail vetoes, outcomes, pending
lifespans, intent divergence: the things a backtest cannot observe), not profit.

WHY :20 AND NOT :00. Same reason as com.keel.live.plist: twenty minutes of margin
for Coinbase to publish the hourly candle that closed at the top of the hour and
for data.market_feed to persist it. That candle is the bar this cycle exists to
evaluate; a trigger at exactly :00 would race the very bar it consumes. The entry
gate (freshness.entry_bar_ready, keel/agent.py's pre-pass) withholds entries when
the bar has not arrived, keel agent then exits nonzero, the runner leaves the hour
unstamped, and the NEXT hour's trigger runs instead: at :00 the profile would
systematically trade one hour late; at :20 it trades the bar it means to.

WHY AN HOURLY STAMP AND NOT THE DAILY ONE. com.keel.paperforward's day-stamp is
daily-grained by design: the daily Turtle wants exactly one cycle per local day.
This profile wants exactly one cycle per UTC hour, so paper-hourly-run.sh stamps
the UTC hour itself (YYYY-MM-DDThh, from date -u). The stamp here is cadence
bookkeeping, NOT the correctness mechanism it is on the live path: the paper path
already refuses a second entry while a product is open (strategy/paper.py), so a
duplicate cycle is inert. It still matters for evidence quality: two cycles in one
hour would double-log the same bar's evaluation and inflate the rail-veto counts.

THE 24 TRIGGERS ARE ONE PER LOCAL HOUR, so every UTC hour gets a trigger under any
local offset, DST transitions included. launchd re-runs a missed
StartCalendarInterval on wake from SLEEP but NOT when the trigger passed while the
machine was powered OFF (observed 2026-07-28 on the paperforward job), and no runner
can replay bars that closed while the machine was down: a powered-off hour is an
hour of evidence lost, permanently. RunAtLoad catches up the CURRENT hour immediately
on boot; hours before the outage are simply gone. Spring-forward's skipped local
hour costs one UTC hour per year the same way. Both holes are documented, not fixed:
this is an evidence collector with a known ~99.9% duty-cycle requirement, not a
money path. -->
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/elmehdiaitbrahim/keel/paper-hourly-run.sh</string>
</array>

<key>StartCalendarInterval</key>
<array>
<dict><key>Hour</key><integer>0</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>1</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>2</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>3</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>4</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>5</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>6</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>7</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>8</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>9</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>10</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>11</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>12</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>13</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>14</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>15</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>16</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>17</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>18</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>19</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>20</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>21</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>22</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>23</integer><key>Minute</key><integer>20</integer></dict>
</array>

<!-- Run on load, so a boot between triggers runs the CURRENT hour's cycle immediately
instead of waiting up to an hour. The runner's hour-stamp makes a repeated load
harmless. -->
<key>RunAtLoad</key>
<true/>

<key>WorkingDirectory</key>
<string>/Users/elmehdiaitbrahim/keel</string>

<key>StandardOutPath</key>
<string>/Users/elmehdiaitbrahim/keel/logs/paper-hourly.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/elmehdiaitbrahim/keel/logs/paper-hourly.err.log</string>
</dict>
</plist>
150 changes: 150 additions & 0 deletions config.paper-hourly.yaml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 6 additions & 4 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading