Procedures a human must perform, because the system cannot.
This file has a deliberately narrow scope: compliance obligations that no rail can enforce. Every
rail in keel/execution/guards.py inspects an OrderIntent — so anything that isn't an order is
invisible to all of them, by construction. Those obligations live here instead.
Things enforced in code do not belong in this file. Rail 14, for example, already refuses live BUYs
until a subscription is attested (keel subscription attest); it needs no runbook entry because it fails
closed on its own. If an item here ever becomes machine-verifiable, move it into the code and delete it
from this file.
Design home: docs/superpowers/specs/2026-07-16-keel-broker-abstraction-design.md §3.1
(CompliancePolicy account-level obligations).
Run through this before arming the agent for live trading, and re-check after any change to the Coinbase account. Item 3 is not a one-time check: it recurs weekly for as long as the agent runs.
Why. Coinbase pays USDC Rewards on idle USDC balances. This applies whenever you hold idle
USDC — which is the case if you settle in USDC, and remains the case for any USDC you keep aside.
(Since 2026-07-22 rail 13 checks the quote leg of the product being traded rather than a single
configured currency, so a -USD deployment holds USD between trades; the rewards concern still
applies to any USDC balance you do hold.) Interest accruing on that balance is riba
(KB §56.3, grounded in §28.1 / §30.1) — and it accrues with no order placed, so no rail sees it.
This is not a trading decision the system can veto; it is an account setting only you can change.
How to verify (manual — see the limitation below):
- Open the Coinbase consumer app or web account (not Advanced Trade).
- Find USDC Rewards — typically under Assets → USDC, or Settings → Rewards / Earn.
- Confirm it is off / not enrolled. Opt out if it is on.
- Check any other yield, staking, earn or lending feature on the same account is likewise off.
- Re-check after Coinbase product changes — enrolment has historically been enabled by default in some regions.
⚠️ This cannot currently be automated, and should not be faked. USDC Rewards is a consumer-account product; the Advanced Trade API does not expose enrolment status (noreward/interest/earn/yieldendpoint exists in the SDK), and the broker port surfaces only capabilities, candles, balances, preview, place and fee-summary. Ausdc_rewards_disabled: trueflag inconfig.yamlwould record what you asserted, not what is true — and a green check that verifies nothing is worse than an honest manual step, because it turns an open risk into a false assurance. If Coinbase ever exposes the state, promote this to a startup assertion and remove it from here.
The sizing-equity purification invariant (#490, discussion #472). Rewards that slipped through anyway do not silently compound: any equity base that position sizing derives from a live balance read is reduced by pending purification —
mark_to_market − build_report(transactions). total_owed_usd(keel/execution/equity.py::sizing_equity). Today that is the paper account's balance-derived seed (paper.starting_equity_usd == 0), which then sizes every paper fill; the live path sizes offcaps.max_exposure_usdand DCA offdca.budget_usd, both operator constants immune by construction. Note the boundary: the drawdown/HWM rail-11 equity is deliberately NOT purified — it measures what the account actually holds, and a breaker must trip on real value, not on a post-obligation fiction. Discharging the owed amount (keel purificationto see it) is still your act; keep the imported transaction ledger current so the subtraction sees what actually accrued. Note that no discharge is recorded —total_owed_usdis lifetime-cumulative (keel purificationrenders the same cumulative report), so an amount already given away keeps subtracting from future balance-derived seeds, which is the conservative direction; correcting or removing ledger rows is the remedy.
A zakat estimate (~2.5% of holdings' market value per lunar year) is a positive obligation, unlike item 1's prohibition, and it is informational: keel reports, you decide and discharge it. Tracked at KB §33.1. No pre-live action; noted here so the account-level obligation set is complete in one place.
Why. Rail 17 (§65.4 qabd) halts all BUY entries unless the withdrawal-capability attestation is
fresh, and "fresh" means a 7-day TTL (WITHDRAWAL_ATTESTATION_TTL_SEC,
keel/execution/executor.py). An expired attestation reads as UNKNOWN, and rail 17 fails closed on
unknown — live DCA buys are vetoed. That is not hypothetical: on 2026-08-14 it vetoed the only
live DCA signal because the attestation had lapsed, and as of 2026-08-17 every deployment's
attestation was weeks stale, so rail 17 was halting entries on the live deployment (it is a
LIVE_STATE rail, skipped in paper, where a stale attestation matters only to the status display).
Each deployment carries its own attestation (they do not share a database), so the live one must be
refreshed for the rail and the paper ones to quiet their status lines.
Rail 17 fails closed by itself — what it cannot do is refresh its own input, and that input is
deliberately human (see the warning below). This entry is the cadence obligation the rail cannot
enforce, which is why it lives here rather than in guards.py.
How to verify. keel status prints the rail-17 line with days-to-expiry (attested, expires in 3d, EXPIRED 12d ago, or never attested) — staleness is visible there before it vetoes, not
only in the veto log. keel withdrawals show reads the same state with the age to one decimal.
Cadence. Re-attest weekly — a calendar reminder is the intended mechanism. Confirm the balances really are withdrawable on demand, then, per deployment:
keel withdrawals attest --enabled
keel --config config.live-sandbox.yaml --db keel-live.db withdrawals attest --enabled
⚠️ The typed confirmation is deliberately human.--enabledRELEASES a rail-17 entry halt and demands a typedyesat a terminal — so that a scheduled job can never release a §65.4 halt, the same posture askeel autonomy on. Do not script this command and do not pipe ayesinto it: the weekly habit is the fix for staleness, and automating the release would undo the rail. If a calendar reminder ever feels like it should be a cron job, re-read this warning.
An item belongs here only if all of these hold:
- it is a compliance obligation (not an operational preference), and
- no rail can enforce it — there is no
OrderIntentto inspect, and - it is not machine-verifiable today.
If the third stops being true, implement the check and delete the entry. If the second stops being true,
it is a rail, and it belongs in guards.py.
Moved here from the README when it was rewritten for newcomers (#281): everything below is operator knowledge — deploying, upgrading, and keeping the paper and live accounts straight — not something a first-time reader needs. It is unchanged in substance.
Cutting a release is docs/RELEASING.md. Installing one into a deployment (e.g. ~/keel) is four
commands, run from the deployment directory — every path below is relative to it:
V=0.6.0
gh release download "v$V" --repo CodeGateSoftware/keel --pattern '*.whl' --dir Release/
uv pip install --python .venv --find-links Release \
Release/keel_core-$V-py3-none-any.whl \
Release/keel_broker_api-$V-py3-none-any.whl \
Release/keel_broker_coinbase-$V-py3-none-any.whl \
Release/keel_broker_alpaca-$V-py3-none-any.whl \
Release/keel_trader-$V-py3-none-any.whl
.venv/bin/keel versions
.venv/bin/keel statusSet V to the version being deployed; nothing else changes between releases. The five named
wheels are the production set (#425): the four base wheels plus keel_broker_alpaca, the
US-equities venue an equities deployment (config.paper-equities.yaml, broker: name: alpaca)
resolves through its alpaca entry point. A Coinbase-only deployment gets the adapter too — one
unused module, whose single dependency (requests) already rides every deployment transitively
via the Coinbase SDK — which is the price of a set stated by name rather than derived from each
deployment's config: an equities deployment must never be upgraded without its adapter, or
keel versions fails it with PARTIAL INSTALL.
Every wheel is named, and that is the fix for a real bug. Installing keel_trader alone
upgraded only keel_trader: its siblings were required without a version, so the keel-core
already on disk satisfied keel-core and stayed put. ~/keel ran keel-trader 0.5.7 against
keel-core 0.5.5 for two releases that way. A wheel path is a direct requirement — that exact
file is installed whatever is already there — so naming all five is what actually moves them.
The wheels now also pin their siblings exactly (Requires-Dist: keel-core==0.6.0), which forces
the upgrade even for someone who installs keel_trader alone; the named paths are the same
guarantee stated where the operator can see it.
Not Release/*.whl. The release ships every workspace wheel, three of which a deployment
must not have: keel_broker_fake, a dev-only fake venue that registers a fake entry point under
keel.brokers; keel_broker_robinhood, an optional venue that pulls an Ed25519 stack
(pynacl, cffi) in for an adapter nothing constructs; and keel_broker_kraken, a
port-complete stub (#313) whose every data method raises. The five named wheels are production's
whole set. --find-links Release still points at that directory so the pinned
siblings resolve locally rather than from PyPI, where they do not exist — which is why step 1
downloads them all. Installing by path rather than by bare name is deliberate and unchanged:
keel on PyPI is an unrelated project, so pip install keel fetches a stranger's code (see
keel/version.py).
Step 3 is the check that matters, and it is keel versions — not keel --version, which
could not fail. --version reports the keel-trader distribution's version and nothing else, so
it printed 0.6.0 while keel-core sat at 0.5.5: a verification step blind to the failure mode,
which is worse than none, because it is trusted. keel versions prints the same build identity,
then every keel distribution in that venv, and exits non-zero when they disagree:
keel 0.6.0+deb8fa7e978d [release]
keel-broker-alpaca 0.6.0
keel-broker-api 0.6.0
keel-broker-coinbase 0.6.0
keel-core 0.6.0
keel-trader 0.6.0
ok: 5 keel distributions, all at 0.6.0.
A partial upgrade fails it, with the numbers: error: PARTIAL INSTALL: 5 keel distributions at 2 different versions (0.5.5, 0.6.0) — the exact failure an equities deployment hit on every
self-update before #425 moved keel_broker_alpaca with the rest. So does finding
keel-broker-fake installed — it was, at
0.5.5, in ~/keel. Remove it: uv pip uninstall --python .venv keel-broker-fake. Nothing calls
load_broker() today so it is inert, but that is a property of this release, not of the package,
and no reason to leave a fake venue registered on the box that moves money.
A build reporting (DIRTY) or [checkout] corresponds to no commit and must not be run against
live funds. Step 4 is a read-only snapshot — no orders, no writes — confirming the new build
opens the database and reaches the venue.
If the deployment runs on a schedule (LaunchAgents, cron), a new build takes effect on the next
cycle with nothing to restart — each cycle is a fresh process. A long-running process is the
exception: a keel serve left running keeps the build it started with until you stop and restart it.
The four commands above — plus the per-database keel migrate --db step the updater also
runs (the four commands don't include it; it runs for every keel*.db with the new build,
between install and verify) — are what keel update runs for you (issue #415) — same order,
same tools, one service (keel/commands/update.py) behind two front-ends: the keel update
CLI command and the Account menu's update entry in the TUI console (see "The TUI console"
for the ceremony). keel update --check mutates nothing: it prints current vs latest and the
whole plan. All of this is for venv deployments only — a packaged (desktop) install never
self-updates; it updates by downloading the new installer
(docs/desktop-install.md, "How updates arrive"; decided in docs/decisions/0001-desktop-update-path.md),
and keel update on a bundle says so instead of offering one.
What it does, in the manual procedure's own order. It reads the latest release from the
public GitHub API (no auth, no tokens — an unauthenticated read is rate-limited to 60/hour per
IP, which a human-gated check never approaches; a rate-limit or network failure is an honest
error, not a guessed "up to date"). It downloads exactly the five production wheels —
keel_core, keel_broker_api, keel_broker_coinbase, keel_broker_alpaca, keel_trader, by
exact name, never Release/*.whl, so the fake, Robinhood and Kraken wheels can never ride
along — into Release/ in the
launch folder, verifying each file landed non-empty (and bounding the read at 200 MiB, far above
the ~1 MiB wheels — a mis-pointed URL is refused, not streamed to disk; a failed download or
install removes the partial files so a torn wheel cannot poison a later rollback).
Backups first: every keel*.db in the
launch folder is copied to <db>.bak-before-<version>-<timestamp> before anything is installed
— through SQLite's own backup API, a consistent snapshot even with a writer mid-transaction,
where a plain file copy can be torn — and the backups are never deleted — not on success,
not on failure. It installs the five
wheels by path into the RUNNING
venv with uv pip install --python <venv> --find-links Release <the five paths> — the manual
command exactly, --find-links Release and all — uv is a deployment dependency
of self-update for exactly the reason the manual procedure uses it; an absent uv is an honest
error naming this section. It runs keel migrate --db for each database with the new build,
then verifies with the new build's keel versions — every keel distribution must report the
new version, the check that can actually fail. Only a verified success removes the superseded
wheels (the old version's five) from Release/; the new five stay for the next update.
Never automatic from what keel ships — always typed. The full run demands a typed yes at
a terminal (the CLI's own confirmation gate, called inside the service before any mutation;
both shipped front-ends — the CLI and the console's update view — hand it exactly that gate),
and that gate fails closed off a TTY, so a scheduled job, which has no terminal, cannot confirm.
The service underneath is a Python API, and an operator's own code could call it with its own
gate — just as the CLI itself can be driven with scripted input on a real TTY; the guarantee is
about what keel ships, not about what is physically expressible. The wording
names the version pair, the launch folder, and that the running binary is replaced.
A failure is loud and phase-true, never papered over. pip replaces the packages at install
time, so there is no cheap rollback. When the failure IS the install (uv absent, a timeout, a
corrupt wheel), the updater says the venv was not updated — or is half-updated (keel versions shows exactly what is installed), removes the downloaded files, and attempts no
reinstall. When the failure is AFTER a finished install (migrate, verify), the updater says the
new wheels are installed, re-installs the previous wheels best-effort when they are
still in Release/ (they are — cleanup only happens on success), and names this section as the
manual recovery — pointing at the .bak-before-* backups as the data recovery (the old build
opening the migrated databases is the migrations-are-additive assumption, not a guarantee). The
backups are untouched either way.
It refuses everything that is not the deployment. The plan refuses when the running build
is not a release install, when no keel distributions are installed (an uv run keel checkout),
and when the running keel package does not resolve from the launch folder's own .venv
site-packages — a source keel/ directory under the launch folder (deploying wheels would
shadow the tree, not update it), a package resolving from outside the launch folder (a repo
run: the wheels would land in a venv that is not this deployment's), or an install whose origin
is not the wheels. From a checkout, this section's four commands by hand
remain the procedure.
Nothing relaunches itself, and there used to be one thing that did. On a verified success
keel update prints what to restart and stops there. Until #541 the TUI replaced its own process
with the new build's keel entry (os.execv, the terminal restored first), because a curses
front-end left running keeps the build it started with and there was no other way to pick up a new
one without the operator noticing. That code went with the dashboard, and its fallback had already
become wrong: with no arguments to carry it rebuilt keel tui, a command that no longer exists.
A long-running keel serve has the same property -- it keeps the build it started with -- and
needs no execv to fix it: stop it and start it again, and the browser tab reconnects to whatever is
listening.
The manual fallback is unchanged. The four commands at the top of this section still work
and remain the documented procedure when uv is absent, the API is rate-limiting, or you simply
prefer the hand run: gh release download, uv pip install --python .venv the five wheel
paths, keel versions, keel status.
A deployment such as ~/keel runs two of them side by side, and they share nothing: separate
configs, separate databases, separate allowlists, separate caps, separate schedules, separate
histories. A figure from one says nothing about the other. Checking a paper position size
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 | paper-hourly | paper-equities | |
|---|---|---|---|---|
| config | config.paperforward.yaml |
config.live-sandbox.yaml |
config.paper-hourly.yaml |
config.paper-equities.yaml |
| database | keel.db (the --db default) |
keel-live.db (must be passed) |
keel-paperhourly.db (must be passed) |
keel-equities.db (must be passed) |
auto_trade.mode |
paper |
confirm |
paper |
paper |
| allowlist | BTC, ETH, PAXG, SOL, XLM, LTC, ADA, LINK (8) | BTC, ETH, PAXG, ADA, XLM (5) | paper's 8 + 11 Tier-2 = 19 (#351) | 5 US large caps, unattested paper candidates |
caps.max_exposure_usd |
5000 | 200 | 5000 | 5000 |
| money spent | synthetic paper_cash_usdc |
the real broker balance | synthetic paper_cash_usdc |
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 | the equity account's own equity |
| rail 14 allowance | $500/month (Basic tier) | $200/month | $500/month | $500/month (simulator assumption; Alpaca has no tiers) |
equity_state_mode |
paper |
live |
paper |
paper |
| launchd job | com.keel.paperforward |
com.keel.live |
com.keel.paper-hourly |
com.keel.paper-equities |
| cadence | daily (day-stamp) | daily, UTC (UTC day-stamp) | hourly, UTC (UTC hour-stamp) | daily, in the US session (UTC day-stamp) |
| rules traded | daily turtle, paper |
daily turtle + DCA, live |
hourly turtle, paper |
daily turtle on equities, paper |
Being tracked in this repo, or even sitting in ~/keel, schedules nothing. A plist has to
be COPIED to ~/Library/LaunchAgents/ and then bootstrapped into launchd before it will ever
fire — those are two separate facts, and #640 is what it costs to conflate them.
com.keel.paper-hourly.plist has been tracked in this repo since 2026-08-03 and sat correctly
written in ~/keel, but was never installed. Verified state on 2026-08-31: the hourly book's
last cycle was 2026-08-21 00:20:05Z — ten days, and roughly 240 missed hourly cycles,
earlier — with 4 orders total (2 BUY / 2 SELL, all 2026-08-20), 19 rules in paper, and 0
rows in trade_outcomes, while ~/Library/LaunchAgents/ held only com.keel.live.plist and
com.keel.paperforward.plist. No keel surface reported it: keel status and the pre-#640
keel doctor only ever look at the one database a given invocation is pointed at, never at a
sibling profile that has gone dark. keel doctor's profile.scheduled/profile.cycled
findings close that hole -- see below.
Per profile, after copying com.keel.<name>.plist from the deployment directory to
~/Library/LaunchAgents/:
# install (or re-install after editing the plist)
cp com.keel.paper-hourly.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.keel.paper-hourly.plist
# verify it is actually loaded
launchctl list | grep com.keel
# uninstall (before deleting the plist, or before a `bootstrap` re-install)
launchctl bootout gui/$(id -u)/com.keel.paper-hourlylaunchctl list | grep com.keel is the ground truth for "is launchd actually going to run
this" -- a job absent from that list runs on no schedule at all, regardless of what the plist
file says or where it sits on disk. Do this for all four: com.keel.live,
com.keel.paperforward, com.keel.paper-hourly, com.keel.paper-equities.
keel doctor is the standing check that this never silently regresses again. Since #640 it
gathers every profile a deployment declares (plist + runner script + config + sibling
database) and asks two questions no single --config/--db invocation could ever answer on
its own, because each only ever looks at the ONE database it was pointed at:
profile.scheduled-- does launchd actually have a job for this profile right now (parseslaunchctl listitself)? FAILs when a profile is confirmed not loaded -- exactly the paper-hourly incident, reproduced as a finding instead of ten days of silence.profile.cycled-- is each profile's last cycle recent relative to ITS OWN cadence (a multiple ofinterval_sec, so an hourly profile and a daily profile are held to their own clocks, not one flat threshold)? FAILs on a profile that is loaded but has stalled.
Run keel doctor (or keel doctor --json for the machine-readable form, products array
included) after installing a new profile, and periodically thereafter -- it is what would have
caught the ten-day gap on day one instead of day ten.
The new cycle shape, and why doctor does not gate. As of #640/#642 all four wrappers run
fetch, then doctor, then the cycle (agent), then doctor again. doctor's verdict is
surfaced by a macOS notification when it FAILs, but it is a REPORT, never a second gate: the
engine (keel/agent.py's whole-cycle admission bit) already withholds every entry, on every
product, the instant any rule anywhere is blocked -- deliberately, to close a real-money
duplicate-order hazard (a blocked rule on one product must not leave a DIFFERENT product's
order placed while the day goes unstamped, which would re-enter the placed product on the next
trigger). A wrapper-level gate keyed to one product would be finer-grained than the engine
already is, would change nothing about what actually trades, and pushing the engine itself to
decide per-product would reopen the exact hazard that admission bit exists to close. So: per
product in the report, book-wide in the gate -- doctor names what is wrong; the engine
alone decides what does not trade.
This is also why fetching before every cycle matters more than it looks: on 2026-08-31 the
live book's BTC, ETH, PAXG, XLM and ADA feeds were each 18 hourly bars behind -- all five carry
live rules -- so the whole-cycle admission bit withheld EVERY entry, book-wide, every cycle,
and a single keel fetch cleared it. A product with NOTHING cached is skipped entirely and
withholds nothing (harmless, if unwatched); a product merely a few bars BEHIND is what
withholds the whole cycle. The wrappers now fetch before evaluating for exactly this reason,
and the live wrapper additionally gives exit 4 (agent.DATA_NOT_READY_EXIT, entries withheld
on data readiness) its own notification, distinct from an ordinary failure or a quiet market.
Why there are three files per database. Since keel serves a web UI, one process reads the
database while another writes it — a page refreshing while a fetch or an agent cycle runs. SQLite's
default journal cannot do that (a writer takes an exclusive lock), and it did not: a first fetch
watched from the setup page died at 45 seconds with disk I/O error. The databases are now in
WAL mode, so readers never block the writer and the writer never blocks readers.
That means keel.db-wal and keel.db-shm sit beside each keel*.db. They are part of the
database — do not delete them while keel is running, and prefer keel update's backups (which use
SQLite's own online-backup API) over copying the .db file by hand. Conversion happens on the
next connection and needs nothing from you.
Which one am I looking at. On any dashboard (keel status, keel insights, keel serve) the
equity_state_mode line names the account the equity, high-water mark and drawdown figures
describe, and paper_cash_usdc is printed in paper mode only. On the command line it is the
--config/--db pair — and --db is the one that bites, because keel.db is its default, so a
live command that omits it silently reads the paper database and answers about the wrong
account. Live commands always carry both:
keel --config config.live-sandbox.yaml --db keel-live.db statusThe same view in a browser: keel serve. This is the only interactive surface keel has; the
curses dashboard it replaced was deleted at #541, for reasons the console section below records.
keel serve renders the same reports over loopback HTTP:
keel --config config.live-sandbox.yaml --db keel-live.db serveIt prints a URL carrying a one-time token for that run and opens your browser (--no-open to
skip). The --config/--db pair still decides which account you are looking at, and still bites
the same way.
It is read-only, and structurally so: the server answers GET and HEAD and implements no
other verb, so there is no request it can answer that changes anything. Attesting, promoting a
rule, recording a flow and arming autonomy stay CLI commands behind the interactive-terminal gate.
Four things stand between that page and the rest of the machine: it binds 127.0.0.1; it checks
the Host: header, so a hostname rebound to loopback is refused even though its packets arrive
on loopback; it requires the session token, which is minted per run and never written to disk; and
it serves no write verb. --host will bind anywhere you ask, and says plainly what that costs —
on a non-loopback address your positions, equity and full trade history are readable by anyone who
can reach the port, with a cleartext token as the only obstacle.
Placing an order is gated differently. Paper places freely against synthetic cash — nothing is
asked and nothing real moves, which is the point. Live runs mode: confirm: each order is
previewed and waits for a typed y at a terminal, so a headless live cycle fails closed and
places nothing — unless autonomy is armed, which is exactly what makes an unattended live cycle
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 (the third job, com.keel.paper-hourly, is the
exception that runs once per UTC hour — see "The hourly evidence profile" below — and the
fourth, com.keel.paper-equities, runs once per day inside the US regular session; see
"The equities paper profile"). 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
while asleep but not one that passed while the machine was off, so the extra triggers are what
stop a shutdown over the scheduled hour from losing the day outright. A cycle that fails leaves
no stamp, so the next hour retries it, which also covers waking with no network. The two jobs
differ only in anchor: paper fires 09:00–20:00 local and stamps the local date; live fires hourly
at :20 and gates and stamps on the UTC date, because a daily bar is not visible until the
00:00–01:00 UTC hourly candle has closed. On live the stamp is a correctness mechanism, not tidiness
— nothing on that path dedupes an entry, so two cycles in one UTC day means two entries off one
daily bar (tests/test_schedule.py pins it).
Sizing is a different calculation on each. Paper sizes off its own synthetic equity, passed to
_build_intent as equity_override; the live path has no equity reading here and falls back to
caps.max_exposure_usd as a proxy (keel/execution/executor.py). The same rule, the same setup and
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.
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 <command>
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):
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 ZEC NEAR AVAX UNI FET ICP DOT CRV ALGO BCH DOGE; 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 <id>
# Warm the candle cache before the first cycle — fetch honors the config's
# market_data.granularities (ONE_HOUR/ONE_DAY/FIFTEEN_MINUTE x 365d here):
keel --config config.paper-hourly.yaml --db keel-paperhourly.db fetchArm it. keel migrate creates schema and never seeds, so a fresh database has no
kill_switch row and get_state("kill_switch", default=True) fails closed — the profile logs
skipped: kill_switch on every cycle until this is run:
⚠️ At a terminal, by a human, deliberately. Same gate askeel autonomy onand the rail-17 release: a scheduled job must never start a halted agent. Do not fold it into the block above, do not script it, do not pipe ayesinto it.keel resume(with this profile's--config/--db) disengages the kill switch;keel autonomy onis a SEPARATE control, deciding who gets asked rather than whether the agent runs. Neither substitutes for the other — running onlyautonomy onleaves a halted agent authorised to trade unattended, which is worse than either state alone (#693).
The 2026-08-17 expansion (#351): 8 → 19 assets. The 11 additions above — ZEC, NEAR, AVAX, UNI, FET, ICP, DOT, CRV, ALGO, BCH, DOGE — each passed a 15-minute data-health screen over 90 days (coverage ≥ 95.98%, zero zero-volume bars; results recorded in the issue), and each sits at a flat 2% target weight: the sizing half of the spread guardrail whose live-path half is #350's spread gate. The 8 incumbents keep their relative shape rescaled to 78% total (rules and params untouched, so their evidence stays comparable across the expansion). Paperforward — the daily profile — deliberately stays at 8 so its evidence remains a like-for-like 8-asset series.
Thin books cost more to trade than the cost model assumes, and the corpus's thin tail is exactly where the expansion above added exposure. The guardrail has two halves, each doing the half it can:
- Sizing (#358): every Tier-2 addition sits at a flat 2% target weight, so a thin book can only ever be a 2% position.
- Live path (#350): a routing-time maximum-spread gate refuses a live BUY when the
venue's own previewed book shows
(best_ask − best_bid) / midat or beyondexecution.max_entry_spread_pct— default 0.005 (50bp), set by #334 to equal the backtest's slippage cap (SLIPPAGE_CAP_PCT). #523 moved that cap to the corpus tail (183.8bp) and deliberately left this gate at 50bp, so the two are now independent and the gate is the stricter of the pair: if the spread ALONE costs more per leg than the model assumes for a $5M/day book, the fill economics are materially worse than anything the rule was measured on, and the entry waits for the book to tighten.
The gate is BUY-only (exits must execute — the same principle that makes rail 17 halt entries,
not exits), fails closed (a live BUY whose preview carries no readable bid/ask is refused
with a distinct book_unreadable reason, never guessed past), and lives beside the twenty
rails rather than among them: guards.check is broker-less by design, and the book exists only
in the preview the executor just fetched.
Paper accrues no evidence about it. Paper fills are synthetic and see no book, so neither
paper profile ever exercises the gate — a reason it ships before any live resumption (the gate
must already be in force when live BUYs resume) rather than being validated on paper first.
A refusal is visible in the cycle log as executor.entry_spread_refused (with the measured
spread and the threshold) or executor.entry_book_unreadable.
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.
A fourth deployment, config.paper-equities.yaml + keel-equities.db, running the same
daily turtle rules on a different asset class: US equities through Alpaca's paper API
(broker: {name: alpaca, endpoint: paper, data_feed: iex} — the config's broker: section is
the whole venue-selection surface; omitting it keeps Coinbase, byte-compatibly). One paper
cycle per day, fired inside the US regular session by com.keel.paper-equities.plist
(10:00–15:00 local/ET) and stamped on the UTC day by paper-equities-run.sh. Use
./keel-equities <command> so the config and database always travel as a pair.
Why it exists: evidence on a session-bound venue, nothing more. Every profile so far exercises the engine on one venue and one asset class. This one accrues the same admissible evidence — rail vetoes, outcomes, pending lifespans, intent divergence — where the venue has a clock: weekends and holidays are read "market closed," never "feed stale" (#370 B1), and the rails meet a second asset class for the first time. The honest caveat, which changes nothing: there is NO PROVEN EDGE on any asset class. The crypto configurations are measured net-negative on their own clocks, and these rules have never been measured on equities at all. Do not promote from this profile on a positive stretch; a new asset class is a new measurement, not a fresh start for unproven rules (Phase C's cost-fidelity work comes before any strategy evaluation is believed).
The allowlist is PAPER CANDIDATES, and asserts nothing religiously. MSFT, AAPL, GOOGL, NVDA and COST are liquid US large caps, chosen so a screen could be run on them — not because any has been screened (leverage and the other screening ratios are the operator's attestation to make, not a fact this file asserts). Trading them here is paper evidence collection, full stop; see the attestation semantics below for what live consideration would additionally demand.
Deployment to the operator's machine is out of scope here (it needs the operator's own Alpaca paper credentials); the steps, once you have them:
-
Alpaca paper account. Create a paper trading key pair in the Alpaca dashboard's paper account, and put the values in
.env(or the environment):ALPACA_API_KEY_ID=... ALPACA_API_SECRET_KEY=...
Paper keys suffice —
endpoint: paperselectspaper-api.alpaca.markets, and the adapter derives the host from that word and accepts no URL, so these cannot be pointed at the live venue by any configuration. -
Install the adapter wheel. The deployment must have
keel-broker-alpacainstalled — venue selection resolvesname: alpacathrough thekeel.brokersentry points, and the error names what is installed when it is missing. The equities deployment's wheel list is the five named production wheels — since #425 the adapter ships with the standard set, so it is not an extra on top of it. -
Migrate + seed + warm:
keel migrate --db keel-equities.db # schema only; never seeds for s in MSFT AAPL GOOGL NVDA COST; do keel --config config.paper-equities.yaml --db keel-equities.db rules add \ --kind turtle_breakout --product "${s}-USD" --params '{"granularity": "ONE_DAY"}' done keel --config config.paper-equities.yaml --db keel-equities.db rules promote --force <id> keel --config config.paper-equities.yaml --db keel-equities.db fetch
Arm it. keel migrate creates schema and never seeds, so a fresh database has no
kill_switch row and get_state("kill_switch", default=True) fails closed — the profile logs
skipped: kill_switch on every cycle until this is run:
⚠️ At a terminal, by a human, deliberately. Same gate askeel autonomy onand the rail-17 release: a scheduled job must never start a halted agent. Do not fold it into the block above, do not script it, do not pipe ayesinto it.keel resume(with this profile's--config/--db) disengages the kill switch;keel autonomy onis a SEPARATE control, deciding who gets asked rather than whether the agent runs. Neither substitutes for the other — running onlyautonomy onleaves a halted agent authorised to trade unattended, which is worse than either state alone (#693).
The rules add form (explicit per-symbol rows, granularity stated even though ONE_DAY is
the constructor default) mirrors the hourly bootstrap so the clock each row trades is
visible in the row itself. --force is the documented bypass for a rule whose backtest
cannot clear the gate; for equity turtle the gate has not been evaluated on this asset
class at all — the bypass is deliberate and the warning it prints is the caveat above
restated. fetch warms ONE_DAY × 365d for the five symbols; run it on a weekend and it is
quiet — B1's session awareness records the closed clock and --check does not alert on
closed-explained staleness.
Scheduling, in one paragraph. The plist triggers at 10:00–15:00 local (ET), on the hour —
inside the 09:30–16:00 regular session, deliberately not shortly after the close: B1's
session gate skips the whole cycle whenever the venue clock answers closed, so an
after-close trigger would log market_closed and never evaluate a bar. The daily bar that
closes at 16:00 ET is evaluated at the next session's open — the conventional
daily-system semantics (signal on close, execute next open) — and the 10:00 anchor gives the
open thirty minutes to settle. The runner stamps the UTC day (Alpaca keys a session's
ONE_DAY bar to that UTC date, and the UTC rollover at 19:00/20:00 local is always after the
window), refuses to run outside its window — 10:00 inclusive to 16:00 exclusive, local (the
15:00 trigger runs; a closed-market skip exits 0 and must never be stamped as the day's
work) — and writes the stamp only after a successful cycle (a cycle that skipped because the
venue clock could not be read exits nonzero, so a transient clock outage is retried by the
next trigger rather than recorded as the day's work).
Where that schedule is actually correct. On an ET-anchored host — or one within ±4h of ET, where the trigger hours still land inside the 09:30–16:00 ET session. The deployment host's local zone is America/New_York, so the fixed local triggers keep their Eastern meaning across both US DST transitions: what moves is the UTC instant, never the distance from the open. Anywhere else, re-anchor the trigger hours so they land 10:00–15:00 ET (on a host far enough ahead of ET, all six triggers can fire pre-open, and the runner's local-hours guard will still endorse them — it reads the host's clock, not ET — so each day would be stamped by a closed-market skip: permanently zero evidence). The guard is a backstop against off-schedule boots, not a drift absorber for a mis-anchored schedule.
Equity screening criteria (business-activity screens, leverage ratios, purification) are
operator-supplied classifications from attributed sources — the engine computes market
facts and never classifies, exactly as on crypto. Attestations are keyed per
(alpaca, SYMBOL): an equity instrument attests under its own venue namespace and is never
reused from a Coinbase row. The sources to watch are the ones the fiqh source review (#367)
already names for this territory: AAOIFI's screening standards and IFSB's
pronouncements (plus any scholar the operator trusts) — an attestation without a source is
not evidence. Two honest limits, stated rather than papered over:
keel assets attest-instrument --venue alpaca --product MSFT-USD --wrapper spotrecords the instrument half (what contract the listing is) and works today.- The asset-level screen (
keel assets screen) is Coinbase-shaped by construction — its venue constant is deliberately hardcoded tocoinbase(open item below) — so until the screen generalizes (#233 live-path work), equity classifications live in the operator's records, and this profile trades as unattested paper candidates. That is precisely why the config's allowlist carries its disclaimer and why nothing here is live.
Dividend purification is fenced to Phase B3 — planned, not forgotten. Purification appears above only as a classification input (the ratio the operator attests). The walk the fiqh source review implies — corporate actions (dividends, splits) recorded per event as they occur (FR-10's recording duty), the purification amount computed against the attested ratio under the operator's stated policy, and the disposition (how much, and where it went) recorded — is the B3 slice of this phase (corporate actions + purification recording). Until B3 lands, nothing here computes or records that walk, and a holder of dividend-paying candidates carries the purification obligation in their own records.
"Can this asset leave this venue?" maps to transfer-out capability — for a US brokerage, an ACATS transfer to another broker. It is attested like any venue:
keel --config config.paper-equities.yaml --db keel-equities.db withdrawals attest --enabledRail 17 is a live-state rail (skipped in paper), so this is recorded for the day live is ever considered, and it lapses weekly like every deployment's attestation.
US equities settle T+1: sale proceeds become spendable the next business day. Settlement is venue-side — nothing keel can enforce about when the venue settles, so this section is the documented half of a split whose enforced half already exists:
- What keel enforces/surfaces: the balances read reports the spendable figure honestly —
availableis the account's buying power clamped at cash, so unsettled T+1 proceeds show up as the gap betweenavailableandtotalrather than as spendable money (FR-6, the #562 balance work) — and rail 13 (the spend rail) vetoes any BUY whose notional exceeds the reported available quote balance, failing closed when the balance is unreadable. The engine cannot spend what it cannot see. - What the operator must respect: cadence choices that would re-spend unsettled
proceeds. On the profile's daily cadence the constraint is met by construction — the
next entry attempt is at least a day after the previous buy, by which time it has settled
(a weekend makes it longer, never shorter), and exits are never T+1-blocked: a SELL
produces cash rather than spending it. The one documented cash-crunch case is an operator
manually redeploying same-day sale proceeds outside the engine — an operator act, not an
engine one. The same
interval_secthat makes this true also scales the feed-staleness window (B1), so any future tighter equities cadence must re-answer settlement and staleness together; a sub-daily cadence is not merely a staleness question.
The paper profile's synthetic cash does not model settlement at all; that honesty is on record for any future live consideration.
The equities profile runs against a cash-equivalent posture, never borrowed funds. Margin
borrowing is a loan that charges interest — riba — so the posture is categorical, not a
preference; no-borrowing is the posture's whole claim, and it sidesteps nothing on PDT.
The PDT rule: FINRA flags a margin account as a pattern day trader when it executes four
or more day trades within five business days, and such an account then needs $25,000 of
equity to keep day trading — the rule binds margin accounts, and a true cash account would be
exempt, but Alpaca offers no true cash accounts: per Alpaca staff on the cash-account
option (forum.alpaca.markets/t/dan-wheres-the-cash-only-account-option/18353), "currently all
Alpaca accounts are margin accounts" — at max_margin_multiplier=1 "the account remains a
margin account" and "pattern day trading rules apply". What actually keeps keel clear of PDT
is the cadence: keel evaluates a session's bar once and holds overnight by construction,
so it does not day-trade in the first place; settled-cash funding is what makes entries wait
for settlement, and that interplay is the T+1 settlement section above (not repeated here).
Enforced in code since #372, not just documented. Alpaca has no account_type field;
/v2/account's multiplier is the venue's account margin classification, and it is as
cash as the venue gets: Alpaca opens every account as margin and offers no true cash
designation — multiplier 1 is the cash-equivalent posture (buying power equals cash,
shorts refused), 2 reg T margin, 4 PDT day-trading margin. At broker build — every
command that constructs one: the agent cycle, fetch, monitor, the order paths —
AlpacaAdapter.verify_cash_account reads that classification and refuses a margin
postured account (CashAccountRequired, naming riba, the honest PDT note — the posture
buys no exemption; the cadence is the PDT safety — and the fix), and refuses fail-closed
when the classification cannot be read: silence is not
consent to borrow. The engine never sees a broker on a margin account.
The operator's half — set the multiplier, and expect to. The venue's default for any
account with $2,000 or more equity is reg T margin (multiplier 2) — and a fresh Alpaca
paper account carries $100,000 of paper equity, so the default classification is MARGIN:
the first cycle against an untouched paper account refuses until the setting is changed.
Set the account's max margin multiplier to 1 (the dashboard's trading-configuration
setting; at 1 the venue refuses orders beyond available cash and blocks shorts), keep it
there, and treat any offer to "upgrade" to margin as a posture violation to decline, not a
capability to use. Confirm the classification took before moving on: re-run
keel assets holdings — a cash classification builds the broker and lists balances, a
margin one still refuses — or read GET /v2/account's multiplier directly, which must
answer 1. If the refusal persists with the setting saved, that is a question for Alpaca
support, not something to work around: the setting you changed lives on the venue's
account-configurations object (max_margin_multiplier, PATCH /v2/account/configurations)
while the classification keel reads lives on the account object itself, and the venue
implies but does not document the linkage between the two.
The posture is one system with the rest of the engine's constraints — where each piece lives:
| Constraint | Where it lives | Why there |
|---|---|---|
| No margin borrowing (riba) | Enforced in code: verify_cash_account at broker build refuses any multiplier ≠ 1, fail-closed on unreadable |
The venue reports the classification; refusing at build covers every path before one sees a broker |
| Cash-only declared | BrokerCapabilities.cash_only — every adapter declares it, keel brokers list shows "cash only" |
The port's declaration seam: vocabulary for the day an adapter announces a borrowing path |
| Long-only, no shorting | Enforced in code, pre-existing: Setup.direction is Literal["long"] (a short entry cannot be constructed), exits only close held longs, rails 18/19 refuse non-spot shapes in every mode |
Not new in #372 — cited so the posture reads as one system, not three |
| T+1 settlement churn | Documented (the section above): settlement is venue-side; keel surfaces the spendable figure and rail 13 spends only that | Nothing keel can enforce about when the venue settles |
| PDT $25k threshold | Documented (this section): keel's CADENCE — one evaluation per session bar, holds overnight by construction — is the PDT safety; the posture claims no exemption (at multiplier 1 the account remains a margin account under PDT rules) | The safety is the cadence, not the posture; there is no separate rule to enforce |
| Stock lending / cash sweep OFF | Operator-verified (the section below) | No order is placed; no rail can see account settings |
Two account settings the venue offers conflict with the posture the engine enforces, and neither is visible to any rail (no order is placed). Verify both are OFF in the Alpaca dashboard — under the account's settings, the stock-lending (fully-paid securities lending) enrollment and the cash sweep / interest program enrollment:
- Stock lending is OFF — lending out held shares conflicts with qabd (possession; the engine's own possession rail assumes held means held) and the income is interest-like.
- The high-yield cash sweep is OFF — interest on idle USD is riba.
These are operator-verified obligations in the same class as the pre-live checklist's USDC Rewards item: account settings no rail can see, re-checked after any account change.
keel/assetsscreening venue semantics stay hardcoded tocoinbase(_VENUEinkeel/cli.py) — that hardcoding is deliberate pending #233's capability-declaration work on the live path; the paper profile does not need it, and the attestation section above records the consequence.- Deployment to the operator's machine — needs the operator's Alpaca paper credentials; this section is the bootstrap, and the plist/runner/wrapper are authored for the America/New_York host like every sibling.
- Cost fidelity and the DCA benchmark — Phase C (PRD §6.3): Alpaca's real cost structure (regulatory fees on sells, spread, IEX-vs-SIP data fidelity) is measured and documented before any strategy evaluation on this asset class is believed.
- Trademark posture — unchanged and stated where it lives: the README's standing disclaimer covers Alpaca alongside every other venue, and nothing here duplicates it.
keel serve opens keel's operator console: a local web page showing Status, Setup, Activity,
Insights, Rules, Venues and Gates, over the same keel/commands/* service layer the CLI commands
call. An architectural test (tests/commands/test_console_thinness.py) pins that thinness -- the
front-end renders and dispatches, and every behaviour comes from the services.
keel --config config.live-sandbox.yaml --db keel-live.db serveIt binds loopback and prints a URL carrying a one-time token for that run. The token is never written to disk, so stopping the server invalidates it.
keel tui was the console until #541, and it is gone. It needed a terminal, and there were two
places it could not go: Windows, where CPython ships no curses, and a macOS app launched from
Finder, which has no controlling terminal at all -- both of them platforms a desktop release
targets. The menu tree it carried (Profile, Trading, Rules, Compliance, Data, Research, Account,
Help) went with it, along with roughly 24,000 lines of code and tests.
On a headless host, forward the port rather than reaching for a terminal UI:
ssh -L 8765:127.0.0.1:8765 your-hostThe browser at the reading end gets the full interface, encrypted by SSH, and http://127.0.0.1
is still a secure context there -- which is what makes the installable app work. keel status
covers the rest from a plain shell.
What the browser cannot do, and that is deliberate. Every capability-increasing action --
arming autonomy, releasing the kill-switch, clearing a consecutive-loss halt, re-seeding the
drawdown high-water mark, declaring a deposit or withdrawal, attesting withdrawal capability, and
replacing the binary -- is a CLI command behind a typed confirmation at an interactive terminal.
keel capabilities lists all seven with the gate covering each. The server implements no verb
that would reach one, so this is a property of the server rather than of what the page draws.
Four settings decide position size and how much can be spent. Three live in config.yaml; the
fourth does not, which is most of why they drift apart.
paper.starting_equity_usd— the synthetic paper account's seed. It is a ONE-TIME seed, applied on the FIRST paper run only (keel/agent.py, thepaper_trader.get_cash() is Nonebranch). Editing it afterwards does nothing at all: the seeding branch is skipped wheneverpaper_cash_usdcis already set, so an already-seeded account keeps its balance forever. Resizing a running paper account means clearing that persistedpaper_cash_usdc— a key in theagent_statetable, with no command that clears it (keel reset-hwmdoes not); a fresh database is the clean way.0(the default) means "seed from real mark-to-market equity instead"; any value above0overrides that and seeds at exactly that amount.paper.monthly_contribution_usd— a recurring top-up, applied once per UTC calendar month. It compounds, and the base is small: a contribution comparable to the seed doubles the account monthly, and every position size below grows with it.caps.max_exposure_usd— has two jobs at once. It is the ceiling on total notional held at any one moment (rail 4, and rail 6's concentration cap is a percentage of it), and it is the equity proxy that sizes orders on the live path (keel/execution/executor.py::_build_intent). So liverisk_pctis a fraction of THIS number, not of real account equity — raising the cap raises the real dollars risked per trade. Set above actual equity it stops binding before available cash does, and the refusal comes later and less legibly from the funding check (rail 13). In paper mode the proxy is bypassed: sizing uses the paper account's own equity.- rail 14's monthly allowance — the fee-free monthly BUY volume. It lives in the database,
not
config.yaml: thebroker_subscriptionsrow written bykeel subscription attest --venue coinbase --tier <tier>, or set directly withkeel subscription set --free-volume-usd N.config.yamlonly supplies the tier catalogue and the unattested fallback (subscription.unsubscribed_allowance_usd). Being in a different place from the caps is exactly why it drifts out of step with them.
The interaction is the point. Position sizing scales with equity (or, on the live path, with
the max_exposure_usd proxy); the rail-14 allowance is a fixed dollar figure that scales with
nothing. Let the two drift apart and every setup is vetoed — keel looks broken while every
component is doing exactly what it was configured to do.
The real case: at $11,000 paper equity with risk_pct: 0.01, a PAXG setup with a 3.35%-wide
stop sized to $3,284.67 — exactly 1% of equity ($110) at risk, the correct answer. Rail 14's
allowance was $500/month, so it was vetoed, as was every other setup. Not a bug in either
setting; the two were simply on different scales. Reseeding the paper account at $500 sizes the
same setup at $149.30, which fits.
Note the counter-intuitive mechanic behind those numbers: a tighter stop produces a LARGER
position, because size = risk ÷ stop-distance (keel/execution/sizing.py::size). That is how
a 1% risk becomes a 30% position — risk_pct bounds what you lose if the stop holds, not what
you spend.
Two outbound channels, one URL, zero control surface.
The CRITICAL webhook (always on when configured). keel escalates act-now conditions by
logging CRITICAL — reconcile.position_unprotected is the sharpest — and
WebhookAlertHandler (keel_core/alerting.py) POSTs each such record off the machine. The
URL lives in KEEL_ALERT_WEBHOOK (environment, or the git-ignored .env — it is closer to a
credential than to configuration, and config.yaml is committed). No URL configured means
no handler attached at all: an offline install makes zero network calls. This channel is
unchanged by the notification layer below and stays independent of it.
Opt-in event notifications (default OFF). The events an operator most needs are silent
precisely because they are not errors — rail 17's attestation nearing expiry fails CLOSED and
quietly vetoed a real setup for weeks before anyone opened the TUI. The notification layer
(#444) delivers those over the SAME generic webhook, per-event opt-in in Freqtrade's
notification_settings shape:
# config.yaml — nothing is sent until an event is opted in here AND
# KEEL_ALERT_WEBHOOK is set. Both, always.
notifications:
format: plain # plain = generic JSON (default); slack = {"text": ...} chat payload
events:
attestation.expiring: true
rail.armed: true
setup.unplaced: true
allowance.nearing_exhaustion: true
feed.stale_open_position: trueThe taxonomy (thresholds are the ones keel doctor computes — the notification layer reads
doctor's own findings, so the alert and the diagnostic can never disagree):
| Event key | Fires when | Why it is not a CRITICAL log |
|---|---|---|
attestation.expiring |
rail-17 withdrawal attestation has ≤2 of its 7 TTL days left (or has expired, or was never attested) | an expired attestation silently vetoes every entry — cycles keep running, nothing errors |
rail.armed |
rail 16's consecutive-loss halt arms, or rail 11's drawdown reaches 20% | a halt is a correct state, not a fault; the kill switch is deliberately absent (you engaged it at a terminal — you know) |
setup.unplaced |
a cycle detected an entry setup and could not place it | the veto is a WARNING; the rail-17 incident looked like a quiet week |
allowance.nearing_exhaustion |
month-to-date BUY spend reaches 80% of the in-force rail-14 allowance — or there is spend against an allowance of 0 (no subscription in force: lapsed or never attested) | rail 14 only speaks when it vetoes, which is too late to re-tier |
feed.stale_open_position |
a product's feed is stale while a position is open in it | the stale product is skipped at INFO; an open position's exits ride on that stopped data |
Payloads: plain is a flat JSON object (event, severity, category, message, plus the
numbers — pct_used, days remaining in the message); slack is Slack-compatible
{"text": ...} — accepted natively by Slack and Mattermost, and by Discord via a /slack
webhook endpoint. Delivery is one attempt per event per cycle, fire-and-forget at the end of
each agent cycle: each opted-in event is one inline POST with a 5-second timeout at the
cycle's tail, bounded by the events the cycle derived (one per taxonomy key, plus one per
armed rail and one per stale product held open) — so a dead endpoint costs a notification,
never a cycle, and a state that persists re-alerts on the next cycle anyway.
Notify-only, by design. There is no remote control surface — no command, query or
capability arrives through notifications, ever. Every capability-increasing action stays
TTY-gated (keel capabilities inventories them; #436), which is why this feature adds zero
gate call sites: outbound messages cannot release a rail, arm autonomy, or place an order.
The layer also writes nothing back — it reads the same repo keys keel doctor reads and
sends; that is the whole of it.