Skip to content

keel v0.11.0

Choose a tag to compare

@github-actions github-actions released this 21 Aug 23:09
· 121 commits to main since this release
f5f471f

Built from f5f471f. Version binds to this hash:
keel --version reports keel 0.11.0+f5f471f9118b [release].

Install

Download all wheels from this release into one directory, then install the
keel_trader wheel by path:

pip install --find-links . ./keel_trader-0.11.0-py3-none-any.whl
keel versions

keel versions — not keel --version — is the check: it reports every
keel distribution in the venv and exits non-zero if a sibling was left behind at
an older version, which --version cannot see. Upgrading an existing
deployment: see "Deploying a new version" in the README.

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

Configure

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

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

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

Desktop app (macOS / Windows)

⚠️ These builds are not code-signed, so your computer will warn you.
Apple's certificate is $99/yr and Azure Trusted Signing is ~$120/yr, and
keel cannot currently afford either. There is no cheaper tier and no free
open-source option on either platform. (Windows is worse value than it looks:
since 2024 even an EV certificate no longer grants an instant SmartScreen
pass — reputation is earned from download volume over time.)

Prefer no warning at all? Install from the wheels above instead — nothing
is downloaded as an application, so nothing objects. Needs a terminal and
Python 3.11+.

Full explanation, including how to check what you downloaded:
https://github.com/CodeGateSoftware/keel/blob/main/docs/desktop-install.md

macOS — the first open is refused. Open System Settings → Privacy &
Security
, scroll to the message about keel, and choose Open Anyway.
You will only do this once.

Windows — SmartScreen shows "Windows protected your PC". Choose More
info → Run anyway
.

Verify what you downloaded before you run it. Every artifact carries a
GitHub build attestation binding it to this repository, this workflow and this
commit — which is the question a code-signing certificate answers too:

gh attestation verify <file> --repo CodeGateSoftware/keel

SHA256SUMS.txt is attached as well. If either check fails, do not run it.

The app opens keel in your browser; there is no terminal involved. Your
config, database and credentials live in your user Application Support /
LocalAppData folder and are never touched by an update.

Other changes

docs(contributing): add the step-by-step contribution workflow (#429)

Closes #428

Adds the newcomer mechanics the file lacked — the six-step path (issue → fork → clone/upstream → branch → commit-and-gates → PR) plus the review & security guidelines — placed between the intro and the governance section.

Deliberate reconciliations so the file never contradicts itself:

  • ensure test suites pass → the four documented gates, linked, not restated
  • review timing → the recorded solo-maintainer SLA (#291), not a new promise
  • generic repo placeholders → concrete CodeGateSoftware/keel URLs (USERNAME stays a placeholder by design)
  • house style kept: no emoji headers; commit guidance folds into the existing Conventional Commits section

No code changes; CI runs the usual gates.

fix(config): seed both paper profiles from real equity, not a synthetic $10k (#430)

Refs #426, #427. Syncs the repo with a change already applied to the deployment, and fixes the same defect in paperforward because a test invariant requires the two to move together.

The defect

Both paper profiles set starting_equity_usd: 10000 — sizing against a synthetic account far larger than the real one, while inheriting the real account's subscription allowance. Rail 14 reads the attested subscription record; it knows nothing about synthetic equity.

With the venue attested at Basic, that allowance is $500/month of fee-free buy notional:

profile median stop s f_N = risk_pct/s notional/trade vs $500/month
paper-hourly 2.40% 0.42 ~$4,212 8.4x the whole month
paperforward 13.86% 0.07 ~$722 1.4x the whole month

(s measured as the median 2×ATR20 across 25 products over 5 years of cached candles.)

Every entry is refused. paper-hourly detected 15 setups in 60 cycles and recorded zero trades; paperforward's ledger is also empty. The evidence pipeline #359 describes has never recorded a single observation, and the 2026-09-30 review has nothing to review.

The fix

starting_equity_usd: 0 seeds from real broker mark-to-market equity (keel/agent.py:499). Every proposal then lands inside the fee-free allowance — which is also the only regime where these rules are not structurally losing:

regime (hourly, b=6) κ p_be vs reconstructed 14.9% win rate
inside allowance (φ=0, ψ=5bp) 0.042 14.88% +0.02%
outside allowance (φ=120bp) 1.043 29.18% −14.28%

Robust across ψ from 2–10bp. The taker fee is the entire result.

Why both configs change

test_config_universe_is_a_deliberate_superset_of_paperforward asserts hourly.paper == daily.paper — the profiles are meant to differ in cadence and nothing else. The suite caught my first attempt, which changed only the hourly profile.

Changing one and relaxing the assertion would have broken a deliberate invariant in order to hide that the same defect affects both. So both move, and the reasoning is in both files.

A drift worth knowing about

The deployment and the repo had already diverged before this PR:

repo ~/keel
config.paperforward.yaml 10000 500
config.paper-hourly.yaml 10000 10000

Someone set paperforward to 500 on the box and it never came back. That also means the deployment's two paper profiles already violated the invariant this repo asserts. After this PR the repo is internally consistent; the deployment's config.paperforward.yaml still needs the same change applied by hand.

What is deliberately not changed

The exposure cap. I briefly raised max_exposure_usd 5000 → 10000 on the deployment to admit the ~$4,212 proposals, then reverted it: when the whole month's allowance is $500, widening the per-order cap admits nothing and only hides the sizing mismatch. The config now says so explicitly, so the next person doesn't retry it.

Scope

Takes effect on a fresh paper ledger only — the seed happens on first paper run. Already-seeded ledgers (keel.db at $550, keel-paperhourly.db at $10,500) need keel equity record-flow, a typed-confirmation command. Deleting them is not a shortcut: keel-paperhourly.db holds a 1.1M-row candle cache.

Full suite: 3797 passed, 3 skipped. ruff check clean.

docs(research): publish the Quant Lab modelling note and its cross-verification (#431)

Adds a contributor's modelling note plus our review of it, so the announcement discussion has permanent links to both.

What's here

  • docs/research/Keel_Quant_Lab_note_pedagogique_260820_103629.pdfKeel Quant Lab: Couche de modélisation stochastique pour Keel, by Dr. Issam Elhattab (23 pp., French). A proposal for a sizing layer between signal detection and the guards, with a full lexicon.
  • docs/research/2026-08-20-quant-lab-note-cross-verification.md — what we checked, what held, and what applying its methods to keel's real numbers produced.

Review summary

The mathematics holds. Every formula re-derived rather than read: f_N = ρ/s, κ = 2(φ+ψ)/s, p_be = (1+κ)/(1+b), Kelly-after-costs, n ≈ 1.55/δ², the fractional-Kelly approximation, and every cell of both tables. Its claims about keel check out too.

Three results from applying it:

  1. At the real 120 bp taker fee (the note's table is illustrative at 25–60 bp and says so), break-even at a 5% stop and b=2 is 50.0%, not 36.7%. At a 2% stop, 75%.

  2. The 2026-09-30 review is underpowered. Applying the note's pooling warning: signals fire in herds of ~8 assets/day, outcomes correlate at ICC 0.212, so DEFF = 2.58 and n_eff at n=100 is ~39. It can detect only a 20-point edge. The floor needs to be ~258. (#427)

  3. Rail 14 is the profitability boundary, not a budget limit. Inside the fee-free allowance κ = 0.042 and p_be(b=6) = 14.88%, against a reconstructed 14.9% win rate. Outside it, κ = 1.043 and p_be = 29.18%. The taker fee is the entire result.

The reconstruction validates. 46.7 signals/asset-year and median n=241, against #359's published 49.4 and 268 — which is what makes everything derived from it usable.

Also a correction to us

Our first reading reported the note's 268 / 940 / 49.4 figures as unsourced. They are sourced, in #359. That was our error and the document says so.

Overlap flagged, not hidden

#368 covers the same ten articles and reaches the same headline. The review tabulates exactly where the note restates it, and the three places they diverge and need reconciling — most importantly that π_edge and #368's planned kelly_f_star now compete for the same promotion-gate slot.

Full suite: 3797 passed, 3 skipped.

docs(research): update the Quant Lab note to v1 — the Keeks implementation audit (#433)

Dr. Elhattab's revision: 22 → 27 pages. Replaces the published PDF in place so discussion #432's link keeps working. v0 stays internal — retained in git history at 0347bba, deliberately not published as a separate file.

What v1 adds

A new §3 auditing the keeks library itself, not the articles about it — pinned to wdm0006/keeks @ 1a5d04a (2026-08-18), and noting that version.py there reads 0.5.0 while PyPI still serves 0.3.0.

Five findings. Two matter to us:

K3 — the probability gate. KellyCriterion's default 0.5 floor is not the economic break-even, which is (L+c)/(B+L). For B=2 the true break-even is 1/3, so the band 1/3 ≤ p < 0.5 is economically positive and gets zeroed. FractionalKellyCriterion inherits it, delegating to an internal KellyCriterion with defaults and never exposing min_probability.

He evidences it from the project's own published artefact: in strategy_benchmark.csv the "Kelly" and "Naive" rows are identical to every decimal (median terminal capital 12,233.6, median drawdown 0.8091) — a nine-strategy benchmark effectively comparing eight.

And he states the fair reading himself: at B=L=1, c=0 the gate coincides with break-even, so the published benchmark isn't wrong; the degeneracy shows for asymmetric payoffs that benchmark doesn't cover. Worth naming that scrupulousness.

K5 — max_draw_down is not drawdown. BankRoll.max_draw_down is the maximum losable in a single settlement, raising RuinError — not peak-to-trough. keel's rail 11 (20% total / 8% weekly) is peak-to-trough, so mapping one onto the other would encode a different object. Any Keel–Keeks integration must convert explicitly.

K1 is a third independent confirmation of the same Kelly-with-costs formula: he derived it from the library, we re-derived it from g'(ρ)=0, the note derived it analytically. Three routes, one result — verification table agrees to 0 or ~1e-16.

Also in this PR

v1 inserts §3 and shifts everything after it, so every section reference in our cross-verification was stale. All updated (§3.1→§4.1, §3.3→§4.3, §5.2→§6.2, §7→§8), plus a version banner so a reader knows §1–7 were written against v0.

New §8 in the cross-verification covers the audit, the four upstream PRs he proposes, and what v1 still doesn't address — it does not yet cite #368, and the R-multiple framework still doesn't scope out dca, the only family that has ever traded and the one with no stop.

Full suite: 3797 passed, 3 skipped.

docs(specs): PRD — competitive gap analysis against Freqtrade, Hummingbot, Jesse (#448)

Milestone: Competitive parity — validation & reach (#19) · Issues #440#447

Method

Four independent capability inventories from primary sources, all answering the same 13–15 headings so the comparison is like-for-like: Freqtrade 2026.7, Hummingbot v2.16.0, Jesse 3.0.7 (repo cloned), and keel read from source — with the keel reviewer explicitly asked to separate deliberately absent from not built.

The organising finding

Every competitor's validation tooling is a diagnostic the user may ignore. keel's is a gate.

Jesse's own documentation concedes it: its train/test split, Monte Carlo and significance testing are real, "but none of it is a hard gate — a user can still deploy an over-tuned strategy directly to live." Freqtrade's lookahead-analysis is a command you may choose to run. Hummingbot has neither, and its backtester models no slippage, latency or partial fills at all.

keel's promotion ladder refuses, with pbo=None blocking rather than passing. So the strategy is not to import their features — it is to adopt their validation evidence as inputs to a gate that already exists.

Design boundaries, recorded so they are not re-proposed

Shorting, leverage, futures, market making, arbitrage, ML, and market-data-driven pair selection are charter constraints, not missing features — enforced at four independent layers with no config field that widens them.

Hyperopt gets its own argument. PBOResult structurally cannot carry which configuration was selected — enforced by a test that fails if a config-identity field is added — precisely so a PBO score can never become a sweep's ranking key, which is what an automated search requires. Adding hyperopt would import the failure mode the gate exists to catch.

Ranked gaps

C1 Lookahead detection (#440), Monte Carlo + candle bootstrap (#441) Attacks keel's actual open question; the one category where a competitor's tooling strengthens the enforcement model
C3 Wire the dormant exit primitives (#442) trail_stop_atr, roll_to_break_even, scale_out are implemented and unit-tested with zero callers in the live loop
C5 keel doctor (#443) Motivated by a real case: a profile detecting signals and recording nothing, for two causes no single command surfaced
C4 / C2 Notifications (#444), walk-forward (#445) Operator reach and validation depth
C6 / C7 Partial fills (#446), rule plugins (#447) Need decisions before implementation

Where keel already leads

Recorded so it is not traded away: enforcement over diagnostics; 18 un-overridable rails against Jesse's none and Hummingbot's single global P&L threshold; liquidity-scaled slippage (floor × sqrt(anchor/volume), 5–50bp) against Hummingbot's flat scalar; a fill model rebuilt after it was found to model optionality never available live, with the fix documented as worsening a reported edge; a hash-chained trials ledger no competitor has; and fully open live execution where Jesse's is a closed paid plugin.

Web UI and REST API are real gaps against all three but belong to the Desktop distribution milestone, where they are load-bearing rather than incidental.

docs(specs): PRD — desktop distribution, and why packaging is the last step (#449)

Milestone: Desktop distribution — keel for non-technical users (#18) · Issues #434#439

Answers "make it a one-click installer for Windows and Mac."

The short version

The packaging step is the cheap part and the last part. Four properties of keel today make a double-clickable app impossible regardless of which packaging tool is chosen. Fixing those is the work.

B1 Every path is CWD-relative (_common.py:45-46, config.py:956,974, activity.py:390-406). A Finder-launched macOS bundle runs with cwd = / — config, database and .env resolve somewhere unwritable and invisible, and a signed .app is read-only so it cannot hold them either.
B2 The TTY gate is un-overridable by design, at 11 call sites. Its docstring: "any such seam would be settable from cron and would defeat every fail-closed built on it." A GUI has no stdin TTY, so it is refused exactly like cron.
B3 curses does not exist in CPython on Windows, and windows-curses is declared nowhere — keel tui cannot run there as shipped.
B4 First run is ~10 CLI invocations plus hand-edited YAML plus two steps taken on a venue's website — disabling USDC Rewards and stock-lending interest, because that interest is riba and no rail can see it.

Packaging today would ship a signed app that launches a terminal into /, cannot write its database, cannot run its console on Windows, and refuses every safety-critical action.

What the evidence says to build instead

No established open-source trading bot ships a native windowed application. Freqtrade, Hummingbot and Jesse ship zero native binaries; all three converge on a local process serving a web UI on localhost. OctoBot — the one credible counterexample, with real per-OS executables — just starts a local web server too, and its code signing could not be verified (GPG on the git tag is source integrity, not Gatekeeper notarisation).

So: local web UI first, installer as the wrapper. It solves B2, B3 and the macOS no-terminal problem with one artifact, and reuses the thin service layer test_console_thinness already pins.

The trust argument

An unsigned binary that then asks for exchange API keys is the shape of malware distribution — the research turned up a live example while surveying the space. For a project whose proposition is auditability, that silhouette without OS-level verification would be worse than shipping no installer. Signing is a precondition, with real costs: $99/yr Apple, OV cert or Azure Trusted Signing for Windows, and no SmartScreen bypass from EV since 2024.

Open questions in the PRD

Paper-mode default on first run (recommended); self-update vs per-release installer; whether the desktop product ships all four deployment profiles or one; and who the desktop user actually is — that last answer changes scope by roughly 3×.

Also flags release.yml:93-96 claiming Requires-Python: >=3.14.4 where every pyproject.toml declares >=3.11.

feat(paths): resolve state to an app-data directory when there is no deployment folder (#450)

Closes #434. First item in the Desktop distribution milestone, and the prerequisite for D2–D5.

The problem

Config, database, credentials and logs have always resolved relative to cwd — keel.db, config.yaml, .env, logs/keel.log. That is correct for the deployment model: ~/keel is one folder holding one deployment, the four profiles are siblings, and cd selects which you are operating.

It breaks the moment keel is launched by anything but a shell. A macOS bundle opened from Finder runs with cwd = /, so those resolve to /keel.db, /config.yaml, /.env — unwritable, and nowhere near where the operator thinks their data is. A signed bundle is read-only, so it cannot hold them either.

The rule

keel_core.paths.state_root(), in order:

  1. KEEL_HOME if set — the explicit escape hatch.
  2. cwd, if it looks like a deployment — this is what keeps every existing install byte-identical.
  3. The OS app-data directory~/Library/Application Support/keel, %LOCALAPPDATA%\keel, XDG.

An existing deployment always wins. Verified against the real thing: ~/keel resolves to itself; a folder with no markers falls through to app-data.

Why detection, not migration

Migrating state on upgrade was rejected. The failure mode of a wrong guess is not an error message — it is keel opening a fresh empty database beside a populated one and reporting a healthy deployment with no positions and no history. Detection fails safe: the folder either exists (use it, unchanged) or does not (nothing to lose).

Markers are deliberately generous — config.yaml, .env, or any keel*.db. A false positive means keel uses the folder it was run in, which is the historical behaviour; a false negative is the dangerous direction.

The suite caught one of these. Dropping .env from the marker list silently stopped finding credentials that used to be found — test_load_alpaca_secrets_reads_environment_then_env_file failed, and .env is now a marker with a comment explaining why.

Two deliberate decisions

Callable Click defaults. Click evaluates a literal default= once at decoration time, which would freeze whatever directory the process started in and make both KEEL_HOME and deployment detection meaningless — including under test, where monkeypatch.chdir moves cwd after import. Pinned by test_cli_defaults_resolve_per_invocation_not_per_import.

init and init-config still write to cwd. They create a deployment folder, so "here" is what the operator means. Routing them through the resolver would make mkdir x && cd x && keel init write somewhere else, because an empty folder is not yet a deployment root. Once written, it is one. Pinned by test.

Also

logging.file's relative default resolves against the state root, so logs/keel.log keeps meaning "beside this deployment's database" rather than /logs/keel.log under a bundle launch.

Verification

Full suite 3816 passed, 3 skipped (19 new). ruff and mypy (288 files) clean. A live check confirmed ~/keel~/keel, and that a directory containing stray keel-nb.db files is detected as a deployment — correct per the documented rule, and identical to the old cwd behaviour.

feat(robinhood): replace the order fixtures with observed venue responses (#451)

Closes the order half of #412.

What was run

One real, money-moving order on Robinhood, under scripts/robinhood_order_probe.py's fences.

Step 1 — read-only auth first. scripts/robinhood_smoke.py gained --api-key-var /
--private-key-var (it had none) so the ROBINHOOD_API_KEY_CREDENTIAL + ROBINHOOD_PRIVATE_KEY
pairing could be proven before anything that can create an order. All six GET probes succeeded:
status: active, is_api_tradable: true, holdings empty, no open orders.

Step 2 — dry run, then Step 3 — --place, --max-notional 10 throughout:

symbol BTC-USD
side / type limit buy, GTC
best bid at placement 72705.56
limit price 36352.78 — exactly 50% below the bid
size 0.0001 BTC
notional $3.64
order id 6a8738ba-836d-4afd-abd8-483d7e4d01c9
order-creating requests issued 1

Step 4 — observed, cancelled, verified gone. Final state canceled, filled_asset_quantity
0, executions [], fee_charged 0.0, holdings still empty, buying power unchanged to the
cent. No money moved.

What the documentation had wrong

The four rh_order_*.json fixtures were transcribed from Robinhood's docs and had never been
corroborated — the same standing #217 proved wrong four times over on the endpoints a read-only
probe could reach. The venue's own shape report went from 8 differences to 2:

field fixtures claimed venue actually sends
filled_asset_quantity unquoted number QUOTED string, padded to 18dp
limit_order_config.asset_quantity unquoted number QUOTED string, padded to 18dp
limit_order_config.limit_price unquoted number QUOTED string, padded to 18dp
limit_order_config.time_in_force "gtc" absent — accepted on the way in, never echoed back

An invented field is the dangerous direction: the adapter reads it, gets None, and None money
reads as zero. Nothing in the adapter actually read time_in_force back, so this was a latent
trap rather than a live defect.

The two remaining differences are correct — the fixture carries the union of what the venue can
send, and the live account holds exactly one order, which is a limit order that never filled, so
average_price is null and market_order_config is absent from that one row.

fee_charged — the sharpest risk in #412, and it is fine

If this field were spelled anything else, every row would parse to None, _fees_paid would skip
every row, and get_fee_summary().fees_usd would be a confident Decimal("0") — an
always-passing fee rail, indistinguishable from a correct zero and therefore worse than no rail.

It is spelled fee_charged, is present on every order object, and arrives as an
unquoted JSON number (0.0), so parse_float=Decimal yields a Decimal and both
_decimal_or_none and Decimal(str(...)) read it exactly. No code change was needed. The
tolerance for both quoting forms still earns its keep, because the same object quotes
filled_asset_quantity as a string — #217 F6 confirmed within a single payload.

The cancel is an acknowledgement, not a confirmation

adapter.cancel_order returned False for a cancellation that had in fact landed:

t event state
…18.409035 order created open
…18.858613 first GET /orders/{id}/ open
…19.792632 GET immediately after POST …/cancel/ returned 200 open
…20.891890 order settles cancelled canceled

So the cancel 200 hands back the order as it stood when the request was accepted — exactly as
the docstring already predicted — and the single zero-delay re-poll is also too early, by
roughly one second.

…truncated — full description in #451.

feat(web): keel serve — a read-only browser view for the platforms a TUI cannot reach (#452)

Closes #435 (D2). Milestone: Desktop distribution (#18).

Why a browser

keel tui needs a terminal, and there are two places it cannot go:

  • Windows — CPython ships no curses. windows-curses exists, but its own README says it is unmaintained and seeking maintainers: a multi-year supply-chain dependency, taken on to render a layout the desktop work is trying to stop depending on.
  • macOS — an app launched from Finder has no controlling terminal at all. The packaging research rates this the single biggest technical risk in the desktop work, above code signing and above Python 3.14.

A browser needs neither, and it gives the GUI human gate (#436) somewhere to live. It is also the shape every comparable project reached independently — FreqUI, Hummingbot's Dashboard, Jesse's dashboard, and even OctoBot's per-OS executables, which start a local web server rather than opening a native window.

Read-only, structurally

KeelHandler implements do_GET and do_HEAD and nothing else, so every other verb is refused by BaseHTTPRequestHandler before any keel code — or any authentication — runs. This is not routing discipline a later change could erode: there is no code path here to extend into a write. Two tests pin it, one over the wire and one off the class MRO.

Write actions land with D3, behind its gate.

Four layers, not one

Loopback binding removes the network and says nothing about this machine. So:

layer what it refuses
bind 127.0.0.1 the network
Host: header check DNS rebinding — those packets really do arrive on loopback, and the header is the only place the lie shows
session token every other local process, and cross-site requests
GET/HEAD only the write surface

The token is minted per run, never written to disk, and exchanged for a SameSite=Strict cookie — Strict rather than Lax because Lax attaches the cookie to top-level navigations, so a link on a hostile page would arrive authenticated. The redirect drops it from the URL so it stops appearing in history, bookmarks, and anything the user pastes when asking for help. log_message is overridden to silence, because the default writes the raw request line to stderr and the raw request line carries ?token=… on the very first load.

--host binds anywhere you ask, and says plainly what that costs.

No new dependencies, and no JavaScript

Six read-only pages for one local user do not justify pulling pydantic, starlette, anyio and h11 into a wheel that D5 has to freeze into a signed bundle — where every dynamic import is a hook to write and every megabyte is download someone waits through. http.server is the smaller, more honest answer here, and it is genuinely the wrong answer the moment this serves more than one local user.

Pages render from the report dataclasses rather than wrapping terminal lines in <pre>. <pre> would have been a day instead of three, but it would freeze an 80-column layout into a medium with no columns and make this a screenshot of the TUI rather than a view of the data. Auto-refresh is a <meta http-equiv="refresh">, so the page ships no script at all — asserted by test, and by a CSP that names no script-src because nothing is allowed.

Pinned by the test that already pins the console layer

tests/commands/test_console_thinness.py now scans keel/web/*.py under the same five rules, rather than growing a second pin that would drift one allowance at a time. keel/commands/serve.py is deliberately outside the scan: binding a socket and launching a browser is service work, which is exactly what Rule 5 says belongs outside this layer.

…truncated — full description in #452.

feat(capabilities): declare every action that needs a human, and check it against the source (#453)

Refs #436 (D3). Milestone: Desktop distribution (#18). Does not close #436 — see below.

Scope, and why it stops where it does

#436 asks for two things. The gate itself, and:

Both gates are enumerable in one place, so an auditor can see every capability-increasing action and which gate covers it.
A test asserts no new code path reaches those actions without passing one of the two gates.

The second does not depend on that issue's open question, so it is what this delivers. The gate is untouched. _require_interactive_confirmation is still the only thing that stops anything, and the browser gate still waits on the decision below.

What was wrong with the status quo

The answer to "what can this program do that needs a human?" was a grep for _require_interactive_confirmation. That finds call sites. It does not say what is being released, from which front-end, whether two rows are the same action reached twice — or whether something dangerous was added without a gate.

keel/capabilities.py declares all eleven: what each grants, which surface reaches it, and which console/TUI rows mirrors a CLI action rather than being a twelfth thing.

A declaration nobody checks is worse than none

It reads like an audit and ages into fiction. tests/test_capabilities.py checks it against the source in both directions, and I mutation-verified both:

mutation result
remove a row from the inventory fails — "these call sites gate an action that the inventory does not declare"
remove the gate call from autonomy.py fails — "these inventory rows claim a gate that no longer exists in the source"

The second direction is the one that matters. An action losing its gate is invisible in a diff that only adds — and the row left behind would go on telling an auditor it was covered.

Two promises made mechanical

The gate has exactly one definition. Which is what the console modules already claim in their own docstrings when they say the two front-ends "can never drift into two ceremonies for one bypass". Now asserted rather than asserted-in-prose.

_is_interactive reads stdin and nothing else. Its docstring says it deliberately has no env-var or flag override, "because any such seam would be settable from cron and would defeat the fail-closed behaviour of every gate built on it". That seam would arrive as a convenience, in a diff that looks helpful, and would be invisible in the one place it matters — a cron-driven live cycle that quietly stops failing closed. Now it fails a test.

And the registry stays a declaration. A test pins that keel/capabilities.py may import nothing but dataclasses. If it became load-bearing it would be a second place to get the safety model wrong, and the two could disagree.

Surfaced two ways

  • keel capabilities (and --json) — answers "what can a scheduled job reach?" from the running binary rather than from a grep of the repository. No config, no database, no network.
  • /gates in the web UI — lists all eleven and says plainly that this view cannot perform a single one of them. That is true rather than aspirational: the server implements no write verb at all.

A new test also pins that the nav and the routing table agree, since a page with no nav entry is unreachable and a nav entry with no page is a 404 the user is invited to click.

Verification

3918 passed, 3 skipped (42 new). ruff check clean repo-wide, mypy clean. Smoke-tested against the real deployment — all seven routes 200, /gates renders the full inventory, no <script> anywhere.

The decision this is waiting on

#436's own open question, which now blocks the rest of the milestone:

…truncated — full description in #453.

fix(brokers): cancel_order reports what the venue said, not a boolean it cannot fit (#454)

Closes the cancel half of #412. Implements the option chosen in review: move establishing the terminal state off the exit path.

The observation this comes from

The live run on #412 placed one real order, cancelled it, and watched:

t event state
…18.409035 order created open
…19.792632 GET immediately after POST …/cancel/ returned 200 open
…20.891890 order settles canceled

Robinhood's cancel 200 is an acknowledgement, and the adapter's single zero-delay re-poll is also too early — by about a second.

Under the boolean contract that is False: the same word the port uses for "the venue REFUSED this cancel, the order may still be live." So a cancel that had in fact landed was reported as a failure. Two costs, and the second is the real one:

  1. The log told an operator a position was at risk when it was not.
  2. The exit waited a full cycle — a day, on a daily deployment — for a cancel that was already done.

Four facts, four words

A boolean has two words for four things venues actually say.

outcome meaning who says it
CONFIRMED the venue states this order is terminal-cancelled Alpaca 204, Robinhood state: canceled, Coinbase success: true
ACCEPTED the request was taken; not settled yet Robinhood's open after a 200
REFUSED the venue declined — already filled, unknown id Coinbase success: false, Alpaca 404/422
UNKNOWN 5xx, timeout, or an answer with no row for this id any transport failure

CancelOutcome.settled is the question the exit path is actually asking, in one place so no caller re-derives it. Only CONFIRMED is settled.

What this deliberately does not do

The chosen option was "cancel_order returns 'requested', and the reconciliation poll establishes the terminal state." The second half is exactly what happens. The first half cannot be taken literally at either call site, and that is worth being explicit about rather than quietly not doing.

Both _cancel_resting_brackets and roll_to_break_even place another order against the same inventory immediately after cancelling. The file already says why:

the resting native bracket already commits the whole position, so placing a replacement first would be rejected for insufficient funds

Proceeding on an unsettled cancel is the double-commit the native bracket was introduced to eliminate. So ACCEPTED still stops the caller. What changes is that it stops being called a failure, and the terminal state now comes from reconcile_open_orders at the top of the next cycle rather than from a bounded sleep on the way out of a position.

If you want the exit to proceed on ACCEPTED as well, that is a separate and much larger change — it needs inventory accounting that tolerates an in-flight cancel — and I would not fold it into this one.

Why this is safe to land on the live money path

CancelPending is a subclass of CancelUnavailable. Every existing except CancelUnavailable keeps catching it, so control flow is provably unchanged and only the reporting differs — executor.bracket_cancel_pending at INFO, bracket_cancel_failed still an exception log for a genuine refusal. There is a test asserting the subclassing, because it is the property the safety of this change rests on.

Backward compatible. coerce_cancel_outcome maps an out-of-tree adapter's TrueCONFIRMED and FalseREFUSED (failing closed identically), and anything else → UNKNOWN. There is no input that turns an unconfirmed cancel into a confirmed one, and a test says so.

Adapters report only what their venue can justify

…truncated — full description in #454.

feat(setup): the first-run checklist — what a deployment needs, and who is allowed to do it (#455)

Refs #437 (D4). Milestone: Desktop distribution (#18). First of two — this is the read half; the setup write surface follows in its own PR, per the "setup writes only" decision.

Why a state model before a wizard

First run is ~10 CLI invocations plus hand-edited YAML, described only in the go-live runbook, and a non-technical user on a machine with no terminal can perform none of it. The obvious response is a wizard that does the ten steps for you.

That response is wrong for most of them. A wizard needs a state machine before it needs a form, and the state machine is where the argument lives:

kind who may do it examples
mechanical a wizard, outright config, database, migrations, rule seeding, first candles
judgement you; a wizard may only record every Shariah attestation, every promotion
off-venue you, elsewhere; keel cannot even look USDC Rewards, stock-lending and cash-sweep interest

A wizard must never default, pre-tick or supply a plausible source for a judgement step. An attestation the operator did not actually make is worse than none, because the rails then stop asking.

Off-venue steps are never reported as done, and a test enforces it. That interest is riba and it accrues with no order placed, where no rail can see it; the venue's API does not expose enrolment status. The operator runbook is explicit that a green check verifying nothing is worse than an honest manual step, because it turns an open risk into a false assurance. It follows that ready_for(LIVE) can never return True — asserted separately, because the last word on going live belongs to the operator who checked the venue dashboard, not to a function that cannot see it.

Read-only, and it stays that way

inspect opens the database read-only, parses config if there is one, and writes nothing — safe on a deployment an agent is mid-cycle on, and safe where there is no deployment at all. Surfaced as keel setup (with --json) and as the Setup page in keel serve, from one renderer, so the CLI and the browser cannot drift into two accounts of the ceremony.

Two bugs found by checking, not by testing

Both are worth recording, because in each case the tests were green.

The first is mine. I wrote "subscriptions" and "state" as table names. The real tables are broker_subscriptions and agent_state. Nothing raised — the checklist simply reported an attested subscription and an attested withdrawal capability as missing, on a deployment where both had been done.

That is the worst way this module can fail: it sends an operator to redo finished work, and a wrongly-missing item looks exactly like a genuinely missing one. Caught by running it against the real keel-live.db rather than only against fixtures. Every table and key it reads is now a named constant pinned against a freshly migrated schema, so a rename fails a test instead of quietly turning a completed step incomplete.

The second was found by smoking an empty directory. / correctly showed the checklist, but /activity, /insights and /rules answered 500 — so a first-run user who clicked anything in the nav got an error page. Worse: sqlite3.connect creates the file it cannot find, so a read-only view left an empty keel.db behind simply by being looked at.

Fixed with a needs_database guard applied to the whole set rather than to the landing page, so a page added later gets the behaviour by construction rather than by its author remembering. The unit test that missed this walked two routes; it now walks all of them.

Smaller decisions

…truncated — full description in #455.

feat(web): a setup write surface that cannot arm, release or spend anything (#456)

Refs #437 (D4), #436 (D3). Milestone: Desktop distribution (#18). Second of two — #455 was the read half.

The guarantee that replaced "no POST at all"

D2 shipped a server implementing do_GET/do_HEAD and nothing else, so a POST died in the stdlib. Clean property. Also satisfied by a server that cannot set anything up — which is the whole problem #437 exists to solve, because a first-run user on a machine with no terminal has to be able to create a deployment somehow.

So do_POST exists, and the guarantee is narrower and more useful:

Not one of the eleven capability-increasing actions in keel/capabilities.py is reachable from keel/web/.

Asserted by an AST scan of the package, not by inspection — and mutation-verified. Adding from keel.commands.autonomy import autonomy_on_gate to server.py produces:

AssertionError: the web layer can reach a capability-increasing action:
  server.py imports autonomy_on_gate; server.py calls autonomy_on_gate

"No POST" said the server could not write. This says it cannot arm, release or spend — the property anyone actually cares about. Attesting, promoting, releasing a halt and arming autonomy stay CLI-only behind the TTY gate.

This test is only possible because #453 landed the inventory. Before that it would have been a hand-written list going stale.

The write surface is a closed set

keel.commands.setup.ACTIONS — three steps, every one declared MECHANICAL in the same module's step list, enforced against STEPS. A button for "attest this asset" cannot be added as markup: it would have to be added to the registry first, where the test is.

Every action is idempotent and never destructive — a setup flow is something a nervous user clicks twice, and a browser reload re-submits:

action second run
create config left untouched (there is no force for a web caller to pass)
create database migrations re-applied, which are no-ops
seed rule library pairs that already have a row are skipped

POST/redirect/GET stops the reload; idempotence covers the rest. Running all three twice is a test.

Nothing here increases what keel can do. Config, schema and a library of candidate rules leave an engine that still places nothing — candidates trade nothing until a human promotes them, and promotion is a judgement step.

Five refusals before anything runs

Host header → session cookie → the /setup/ prefix → CSRF token → a lookup in a closed set with no getattr and no dynamic dispatch.

Admission is shared with GET rather than reimplemented: a write path with a laxer check than the read path is exactly the shape of a bug nobody notices.

The CSRF token is derived from the session token by HMAC — no session table to keep or expire, and it dies with the session. Deliberately not the session token itself, which is HttpOnly and must never be written into the page (a test asserts the page contains the CSRF value and not the session one). SameSite=Strict already stops a cross-site POST in any current browser; this is the layer that does not depend on the browser being current.

Form bodies are capped: rfile.read(n) with an attacker-supplied n is a memory-exhaustion primitive, and there is no proxy in front of this server.

form-action in the CSP moves from 'none' to 'self''none' would have the browser silently refuse the setup form, and 'self' is still the tightest value that works.

Two things the tests caught, not review

…truncated — full description in #456.

feat(install): what an installer must decide, in one testable place (#457)

Refs #439 (D6), #438 (D5). Milestone: Desktop distribution (#18).

Implements the install-location and existing-install requirements added to #438, and the "either way" half of #439.

The decision, and its consequence

#439 recommends option A: the desktop product has no self-update. Bundle-aware self-update buys convenience and costs an update channel that must itself be secured — and for a tool that moves real money, a user deliberately downloading a signed installer is arguably the better trust posture.

That has a consequence worth taking seriously: the installer is the update path. So "what should happen when this build meets the one already on disk" is a question something has to answer correctly every single time. An Inno Setup script and a .pkg postinstall are both places where that answer cannot be tested, so it lives in neither.

The rule that is not obvious

"Versions differ, so update" is right in one direction only.

keel/data/db.py:499 migrates with if current < target and ships no down-migrations. A database already at schema N, opened by a build that expects N−2, does not fail loudly — migrate finds nothing to apply and returns, and the old code then runs against tables and columns it was never written against.

Silence is the entire hazard. So:

installed → incoming decision proceeds silently?
nothing FRESH yes
0.10.0 → 0.11.0 UPGRADE yes
0.10.0 → 0.10.0 REINSTALL no — confirm
0.10.0 → 0.9.0 DOWNGRADE no — confirm + warn
0.10.0 → nightly UNCOMPARABLE no — confirm + warn

An uncomparable pair carries the downgrade warning too, because an uncomparable pair might be a downgrade. The warning names the recovery and not just the risk: restore a database backup taken before the upgrade, rather than running the older build anyway.

"Only a fresh install or a genuine upgrade proceeds silently" is itself a test, so a fourth silent path cannot be added without deleting an assertion.

The trap this avoids

The program directory and the deployment directory are different places, and conflating them is how an upgrade destroys an operator's work — the program directory is replaced wholesale, so a config that lived there would not survive one.

default_deployment_dir delegates to keel_core.paths.app_data_dir (#434) rather than restating it. An installer that proposed a folder the runtime does not discover would produce a deployment that appears empty on first launch: config written, database written, and a dashboard reporting a healthy install with no history.

NEVER_TOUCHED names every piece of operator state and is tested against real filenames, not eyeballed — so a typo in keel*.db cannot silently protect nothing.

program deployment
macOS /Applications/keel.app, falling back to ~/Applications ~/Library/Application Support/keel
Windows %LOCALAPPDATA%\Programs\keel %LOCALAPPDATA%\keel

Per-user on both, because an elevation prompt on a first run is precisely the friction this milestone exists to remove. Windows needs no fallback — its default is already per-user, and inventing one would be a second path for no reason.

The message that reaches a desktop user

Every refusal keel update produces today is correct and useless to one: they talk about site-packages layouts and tell the reader to put uv on PATH. A packaged user has no venv and no uv, and never will.

…truncated — full description in #457.

feat(freeze): make keel work when frozen, and pin the four things that break silently (#458)

Refs #438 (D5). Milestone: Desktop distribution (#18).

Does #438's "verify first" item, and the verification turned up more than it was meant to.

The build works. That is not the finding.

PyInstaller 6.22.2 installs on Python 3.14.4 and produces a working 40 MB --onedir bundle including cryptography 50.0.0 and cffi. So the biggest scheduling risk in #438 is retired.

The finding is that a bundle can start cleanly and be wrong in three separate ways, and every one was invisible until the binary was run and asked.

1. No distribution metadata
$ ./keel versions
no keel distributions installed -- nothing to compare.

That is the deploy check whose entire purpose is to catch a partial upgrade, reporting success by having nothing to say. ~/keel once ran keel-trader 0.5.7 against keel-core 0.5.5 for two releases with --version printing the new number the whole time. This is that failure again, from a new direction.

2. No entry points, so no venues
$ ./keel brokers list
0 adapter(s) installed under keel.brokers:

load_broker raises LookupError for every venue. A packaged keel with no adapters is not a trading tool at all — and it says so only if you happen to ask it to list them.

3. Metadata alone is not enough

With --copy-metadata but no hidden imports, it stops answering zero and starts raising ModuleNotFoundError: No module named 'keel_broker_coinbase'. discover_brokers() loads adapters through the entry point, so PyInstaller's static analysis never sees the import. At least this one is loud.

4. And a bug in shipped code, not in packaging
$ ./keel serve
could not open the database at ~/Library/Application Support/keel/keel.db: unable to open database file

keel serve on a machine with no deployment called sqlite3.connect on a database whose parent directory did not exist — and refused to start, before serving the setup page that exists to fix exactly that.

It reproduces unfrozen with KEEL_HOME pointed anywhere absent, so it was never a PyInstaller problem. A first run simply never had a test that ran the whole binary. ensure_schema now migrates what is there and leaves what is not to the setup action — creating the database here would be worse, because a read-only view would bring a deployment into existence merely by being started.

Computed, never hardcoded

keel/freeze.py derives every input from the build environment. A hardcoded list is the same silent 0 adapter(s) bundle one release after a fifth adapter is added — so a test fails if any adapter name appears as a literal in that file, and another fails if the spec hardcodes one.

keel-broker-fake is excluded. It is a deterministic in-process test venue, already named dev-only, and freezing it would put a fake venue in the venue list of a signed install a real person downloaded — worse than shipping nothing, because it looks like a supported option.

Verified against a build from the spec itself

$ ./keel versions
keel-broker-alpaca 0.10.0 · keel-broker-api 0.10.0 · keel-broker-coinbase 0.10.0
keel-broker-robinhood 0.10.0 · keel-core 0.10.0 · keel-trader 0.10.0
ok: 6 keel distributions, all at 0.10.0.

$ ./keel brokers list
3 adapter(s) installed under keel.brokers      # and no fake venue

Then, on a clean machine with KEEL_HOME pointed at an empty path:

200  /        200  /setup        200  /gates        200  /venues
303  POST /setup/config    303  POST /setup/database    303  POST /setup/rules
→ state/config.yaml, state/keel.db

That is D4's acceptance demonstrated through the packaged artifact — a working paper deployment built from a browser, on a machine with nothing on it, with no command typed.

Verification

…truncated — full description in #458.

fix(version): a frozen release must not ask git whether it is dirty (#459)

Refs #438 (D5), #439 (D6). Milestone: Desktop distribution (#18).

The bug, demonstrated before it was fixed

A stamped release build reports (DIRTY) when run from inside any git repository:

$ cd keel/        ; ./keel --version    →  keel 0.10.0+aaaaaaaaaaaa (DIRTY) [release]
$ cd /tmp/other   ; ./keel --version    →  keel 0.10.0+aaaaaaaaaaaa (DIRTY) [release]

_git runs subprocess.run(["git", ...]) with no cwd, so it inherits the process working directory, reads whatever HEAD is there, finds it disagrees with the stamp, and marks the build dirty.

For a venv deployment this rarely bites — ~/keel is not a git repo. For a packaged desktop app it is the normal case, because a double-clicked binary runs wherever the user happens to be.

And the consequence is not cosmetic. A legitimate signed release prints:

warning: this build is NOT reproducible — it does not correspond to a commit. Do not run it against live funds.

A warning that fires on correct builds is a warning people learn to ignore, and this is the one that must never be ignored.

The fix, and what it deliberately does not weaken

A frozen bundle trusts its stamp and does not consult git at all — not merely "reaches the right answer", but never spawns the subprocess. On a desktop app that is also a visible pause per --version, and on a machine with no git it is an exception handler doing nothing useful.

The stale-stamp hazard the cross-check exists for cannot arise in a bundle: there is no working tree to have edited. So:

  • a test pins that a venv release is still marked dirty when git disagrees — the fix must not weaken the case the check was written for;
  • an unstamped bundle is unknown, never checkout (it is not one), which keeps plan_update's source != "release" refusal correct.

is_packaged() moves to keel/version.py and keel/install.py re-exports it. keel.version is a leaf and "how was this built, and how is it running" is its subject — two detectors would eventually disagree about the same process, which a test now forbids.

Also: what makes the installer implementable

#457 landed the decision logic, and #438 requires the installed version be read from metadata, never by executing the installed binary. A frozen bundle has no .dist-info for an installer script to parse — so the installer writes keel-install.ini into the program directory.

INI rather than JSON because Inno Setup reads INI natively and would otherwise need a JSON parser written in Pascal. A hand-rolled parser deciding whether to overwrite someone's install is not a trade worth making.

keel install-plan --target DIR is the machine interface over it, like keel versions:

exit 0 proceed without asking
exit 2 stop and confirm

So a script that reads nothing but the status still fails safe. Missing, unreadable, malformed and empty markers all read as "cannot establish what is installed" → confirmation, never a silent overwrite. An installer that crashed while deciding whether to overwrite would be worse than one that asks.

Verified against a real stamped bundle

$ keel --version          # from the keel repo AND from an unrelated git repo
keel 0.10.0+52a035aa1111 [release]

$ keel install-plan --target <empty>           → exit 0  "Install keel 0.10.0."
$ keel install-plan --target <holding 0.11.0>  → exit 2  "Replace keel 0.11.0 with the OLDER 0.10.0."
                                                         + the forward-only migrations warning

4018 passed, 3 skipped (18 new). ruff check clean repo-wide, mypy clean over keel + packages.

What is left in D5

…truncated — full description in #459.

feat(packaging): a double-clickable macOS app, and a release job that cannot ship it unsigned (#463)

Refs #438 (D5). Milestone: Desktop distribution (#18).

The macOS half — built and run, not described.

The app launches keel serve, not the CLI

packaging/macos_app.sh wraps a frozen bundle into a .app and a 20 MB compressed .dmg. The one design decision in it is that entry point.

An app launched from Finder has no controlling terminal at all — the packaging research rates this the single biggest technical risk in the milestone, above code signing. A bundle whose entry point were the CLI would open, find no tty, refuse every gated action, and exit with nothing on screen.

The console binary ships inside the same .app, so a terminal user still gets the full CLI from keel.app/Contents/Resources/keel/keel. One artifact, both audiences, no second build.

The launcher deliberately does not set a working directory. Finder launches with cwd /, which was the blocker D1 removed; keel_core.paths resolves state to the OS app-data directory now, and a cd here would override that with a guess. It only ensures the state directory exists and tees the server's output to serve.log beside it, so "why did it not start" is answerable from the folder the operator already knows.

Verified the way Finder would do it

cwd /, stdin closed, no controlling terminal:

200  /      200  /setup      200  /gates      200  /venues
303  POST /setup/config   303  POST /setup/database   303  POST /setup/rules
state created: config.yaml, keel.db, serve.log

That is the milestone's whole promise, demonstrated — a working paper deployment built from a browser, on a machine with nothing on it, with no command typed and no terminal involved.

The release job, and why it defaults to not publishing

An unsigned binary that then asks for exchange API keys is the shape of malware distribution — the desktop PRD opens on a live example found while surveying this space. For a project whose proposition is auditability, shipping that silhouette without OS-level verification would be worse than shipping no installer.

So:

  • desktop: publish fails while signing is unconfigured, rather than quietly attaching an unsigned build to a release;
  • the default is build — the safe value is the one you get by not thinking about it;
  • signing is absent, not stubbed. A step that silently does nothing when a secret is missing is precisely how an unsigned artifact comes to look signed.

The smoke step is the part that earns its keep. Three of the four ways a bundle breaks are silent — it starts cleanly and has no venues, or no version identity, or no templates (#458) — so every assertion corresponds to one of them, including that the dev-only fake venue never reaches a shipped artifact.

Windows ships a zip, for now

Inno Setup is the intended installer and carries the install-path and version-decision UX #438 specifies. It cannot be tested anywhere in this repository today, and an installer nobody has run is a worse artifact than an archive everyone understands. keel install-plan (#459) is already the interface it will call, so the logic is landed and tested ahead of the script that uses it.

What is pinned, since the job cannot run here

tests/test_desktop_packaging.py covers the claims that would be expensive to discover were false:

  • publishing unsigned is impossible by accident, and no second route to a release asset exists;
  • the job builds the tag it released, not whatever main drifted to;
  • stamping precedes freezing (the stamp is a source file the freeze embeds);
  • each of the four silent failures is still checked.

…truncated — full description in #463.

feat(secrets): the OS keychain as a place a credential can live, with .env still winning (#464)

Refs #437 (D4). Milestone: Desktop distribution (#18).

The next thing blocking a first run: after setup creates a config, a database and a rule library, keel fetch needs a CDP key — and a desktop user has no terminal, no editor open on a dotfile, and no way to create a .env.

Three sources, and the order is the whole design

source who sets it
1 the real environment whoever launched the process
2 the .env file the operator, in a folder they chose
3 the OS keychain the first-run wizard

.env sits above the keychain deliberately. Every pre-existing deployment therefore behaves byte-identically — the keychain only answers where the file was silent — and a value the operator can see beats one they cannot when the two disagree. A stale keychain entry silently overriding an edited .env is a debugging session nobody should have to have.

Nothing migrates. No credential is moved out of a file, and delete_secret never edits one: a .env is the operator's own artifact, and deleting a line out of a file someone hand-wrote, on their behalf, is not something a credential command should do.

The source is carried, and that is not decoration

"keel cannot see your key" and "keel is using a different key than the one you just typed" are the two support questions this exists to make answerable, and only the source tells them apart.

So credentials set reads the value back through the same resolver a real caller uses and says when something shadows what was just stored:

$ keel credentials set CDP_API_KEY --stdin
stored CDP_API_KEY in the OS keychain.
note: keel will still read CDP_API_KEY from your .env file, which takes precedence.
      Remove it there if you meant the keychain value to be used.

Otherwise the operator finds out at the first request that used the wrong key.

Nothing here prints a value

  • __repr__ is overridden. A dataclass that prints its own secret in a traceback has published it to every log that traceback reaches — and tracebacks travel further than anything else in a program.
  • credentials show reports set/unset and the source. Nothing else.
  • credentials set refuses to take the value as an argument. A secret on a command line is in shell history, in ps output for every other process on the machine while it runs, and in any terminal recording. It prompts with echo off, or reads stdin. Pinned off the function signature, so a --value option added later as a convenience fails a test rather than shipping.

A machine with no usable backend is detected, not discovered: keyring always imports and selects fail.Keyring, whose every operation raises. store_secret refuses up front and names the .env alternative, because a form that appears to save a credential and does not is worse than one that says no.

Verified in a frozen bundle, not assumed

keyring selects its backend dynamically — exactly the kind of thing freezing breaks. So it was built and run:

$ ./keel credentials show
keychain: available (service 'keel-trader')
precedence: environment, then .env, then the keychain.

$ echo "frozen-secret-abc" | ./keel credentials set KEEL_FROZEN_PROBE --stdin
stored KEEL_FROZEN_PROBE in the OS keychain.       # round-tripped through the real macOS Keychain
                                                    # 0 occurrences of the value in any output

Tests do not touch the real keychain

CI has no backend, and a suite that wrote to a developer's login keychain would be leaving state on their machine to make an assertion.

…truncated — full description in #464.

feat(setup): a credential form, and the line a wizard must not cross to have one (#465)

Refs #437 (D4), #436 (D3). Milestone: Desktop distribution (#18).

After setup creates a config, a database and a rule library, the next thing a first run needs is a market-data credential — keel fetch authenticates even in paper mode, where no order can be placed. A desktop user has no terminal to type one into, so the browser has to accept it.

That required moving a line I drew myself

The rule was "only MECHANICAL steps may be actions", reasoning that a JUDGEMENT step is the operator's — "a wizard may collect and record them but must never decide them".

But collecting and recording is a write. The rule as written forbade the very thing D4 asks for. It was too strong in the wrong dimension.

StepKind gains OPERATOR_INPUT, split out of JUDGEMENT, because the two are different things:

what it is may a form record it?
JUDGEMENT a decision only a human may make — a Shariah classification, a promotion no
OPERATOR_INPUT a fact only the operator possesses — an API key yes

A form that recorded a judgement would be making a compliance ruling on the operator's behalf, and no amount of "but they clicked it" makes that the same as their having decided it. A wizard can record an API key and could not possibly invent one.

Collapsing the two would have forced one of two bad outcomes: either the browser could record an attestation, or it could never accept a credential — and a desktop user could never configure keel at all.

The pin is stronger, not looser. Actions must cover only MECHANICAL or OPERATOR_INPUT steps; no JUDGEMENT or OFF_VENUE step may ever be one; and an action must declare inputs exactly when its step is OPERATOR_INPUT — so a mechanical action cannot quietly start accepting operator data, nor an operator-input one quietly stop requiring it.

What a secret is not allowed to do

  • Never rendered back into the page, not even on a re-render after failure. Pre-filling a password field puts the secret in the page source, where it survives a screenshot, a view-source and anything that saves the page. A failed submission must be retyped — that is the correct cost.
  • Never in the redirect. The Location carries the step key and nothing else. A value there is a secret in browser history, in the Referer of anything the page later loads, and in any proxy log in between — the whole reason the form is a POST.
  • autocomplete="off", so a password manager does not offer to store an exchange API key as a website login.
  • Only declared fields are forwarded. The form is attacker-shaped input the moment anyone can craft a POST, so an action never receives a key it has no name for.

Both fields are required and neither has a default: an action that could fill in a field the operator left blank is one that could record something they never supplied.

The confirmation is read back through the same resolver a real caller uses, so a .env that shadows what was just stored is reported now rather than at the first fetch that used the other key.

Verified end to end

Against a live server and the real macOS Keychain:

303  config    303  database    303  rules    303  credentials

checklist:  done · operator_input · A market-data credential · stored in the OS keychain
SECRET LEAKED INTO PAGE: False          (checked across every route)
keychain read-back:      source=keychain, matches=True

4069 passed, 3 skipped (7 new). ruff check clean repo-wide, mypy clean over keel + packages.

Next

…truncated — full description in #465.

docs(desktop): Windows is unsigned too, and the no-warning path goes first (#466)

Refs #438 (D5). Companion to CodeGateSoftware/keeltrading.com#27 (merged).

This branch was pushed earlier but the PR was never opened — my error. The site side merged; this side has been sitting unreviewed.

Windows signing is dropped as well

Azure Trusted Signing is ~$120/yr — more than Apple's $99 — and since 2024 an EV certificate no longer grants an instant SmartScreen pass: reputation accrues from download volume over time, so a new certificate on a young project leaves the warning in place for a while regardless.

Paying more to still be warned about is the worst of the three options. Every place that named only the Apple cost now names both — a page mentioning one would leave a reader assuming the other had simply been forgotten.

The no-warning path leads

Someone reading this is deciding whether to click past a security warning, and "there is a route with no warning at all" is the most useful thing to hand them at that exact moment — not four paragraphs about what Apple charges. The README's "Try it in five minutes" is now the first section, honest about the trade rather than selling it: it needs a terminal and Python 3.11+, which is precisely the friction the desktop app exists to remove.

And the instructions are actually instructions

"Click Open Anyway" is not one.

macOS runs from opening the .dmg through both confirmations, and says that on Sequoia (15) and later the right-click → Open shortcut no longer works — Apple removed it deliberately, and a page still recommending it sends people in a circle.

Windows gets the step that matters and is easiest to miss: Unblock the .zip in Properties before extracting. Skip it and Windows marks every extracted file, so the prompt returns on a later launch and the user concludes the first bypass did not take. It also says to extract somewhere the user owns and not into Program Files — keel does not need administrator rights and should not be given them.

Reflected in all three places a downloader meets: the READ ME FIRST inside the .dmg (rebuilt and mounted to confirm), docs/desktop-install.md, and the release notes.

4041 passed, 3 skipped (4 new pins: both prices named, the no-warning path first, and the per-OS steps present). ruff check clean repo-wide.

feat(setup): market data as a background job, so the last mechanical step can be a button (#467)

Refs #437 (D4). Milestone: Desktop distribution (#18).

The last step left in NOT_AUTOMATED_YET, with the reason recorded there:

a request that blocks that long is not a button, it is a background job with progress and cancellation — so it stays keel fetch until there is somewhere for such a job to live

This builds that somewhere. NOT_AUTOMATED_YET is now empty — and it stays, because the next step someone decides not to automate should have a place to say why.

Exactly one slot, deliberately

Not a queue, not a pool.

  • Two concurrent fetches write candles to one SQLite database and race each other.
  • A setup flow has no use for concurrency.
  • "Is something running?" with one answer is a question a page can render honestly.

A second start is refused and says so, rather than silently dropped or silently queued — both of which look identical to a user watching a page that is not changing.

A failed job stays visible

Not cleared on read, not cleared by time. The entire point of running something in the background is that nobody was watching when it broke, so the failure has to still be there when they look. It is replaced only when the next job starts.

The recorded error is the exception's type and message, never a traceback — a traceback in a browser page is a stack of file paths from someone else's machine.

run_fetch needed no adapting

Its echo parameter is already documented as the progress stream and emits the same lines the CLI prints; its build_client is already a lazy factory, so nothing constructs a broker until the fetch needs one. The job module knows nothing about fetching.

Blank separator lines are dropped — in a bounded tail they would push real output out of view.

Refresh only while running

A finished page that kept reloading would fight a reader; a running one that did not would be a progress display that never progresses. The zero-JS meta refresh is still the only mechanism available to a page that ships no scripts.

The step's button also disappears while its job runs: offering it again would invite a second start that the slot refuses anyway, which reads as the page ignoring the click.

Verified end to end, on the realistic first-run mistake

Starting a fetch with no credentials configured:

POST /setup/market_data  →  303, immediately

job panel:
  market_data · failed · 0s elapsed
  AuthenticationError: Unauthenticated request to private endpoint. If you wish to access
  private endpoints, you must provide your API key and secret ...
  MISSING BTC-USD ONE_DAY

meta refresh: present while running, absent once finished

That is the venue's own error, in front of the operator, with the progress that preceded it — and the checklist directly above it already names the credential step.

mypy caught a bug no test would have

The fetch closure imported keel.strategy.freshness; the module is keel.data.freshness. Every import in that closure is lazy and runs only on the background thread, so the suite was green and the job would have died with a ModuleNotFoundError the first time anyone pressed the button.

Verification

4083 passed, 3 skipped (12 new). ruff check clean repo-wide, mypy clean over keel + packages.

Where D4 stands

Every mechanical step of a first run is now reachable from the browser, with no command typed: config, database, rule library, credential, market data. What remains outstanding is by design — the judgement steps (asset attestations, promoting a rule) and the off-venue ones, which #437 says a wizard must present rather than perform.

docs(readme): link keeltrading.com in both language mirrors (#468)

Closes keeltrading.com#10 (cross-repo, closed manually on merge).

Both READMEs (EN, العربية) now carry a one-line link to the live site under the language switcher — the plain-language tour in three languages, what keel is, how to start on paper, and the honest results. Docs-only change.

feat(setup): attest and promote from the browser, on the axis that turned out to be the right one (#469)

Refs #437 (D4), #436 (D3). Stacked on #467 — retarget to main once that merges.

The rule I wrote was wrong, and this fixes the rule rather than the goal

Two JUDGEMENT steps live in the paper stage — attesting an asset, promoting a rule. So "no judgement step may ever be an action" made nothing safer. It made a terminal-free paper deployment impossible, which is the entire point of the milestone.

step stage among the eleven?
assets_attested paper no
rule_promoted paper no
subscription_attested live no
withdrawals_attested live yes

The invariant moves to where it always belonged

Not one of the eleven actions in keel/capabilities.py may be reachable from keel/web/ — already scanned and pinned independently. StepKind was a poor proxy for it, tried twice ("MECHANICAL only", then "+ OPERATOR_INPUT") and wrong both times.

The test now asserts by identity: every action's step is checked against the eleven invocations, and withdrawals attest --enabled is named as the judgement step that consequently has no form. That is the line the code already drew — taste had nothing to do with it.

The step-kind rule becomes the secondary policy it should have been: a wizard may record what the operator supplies; it may never supply it. An action over a judgement step must declare inputs, every one required, with nothing pre-filled and no select opening on a valid option.

That last clause is not pedantry

pays_yield as a checkbox would open unticked, and unticked is no — the permissive answer, since a yield-bearing asset fails the screen (KB §28.4).

A form whose default answer is the compliant one attests on the operator's behalf. So it is a select that opens on a disabled choose… and is required: it cannot be submitted without someone answering. backing is the same shape.

Exposing an attestation is safe for a reason its own CLI command states:

an attestation cannot itself place an order or raise a cap, and the screen it feeds only ever ADMITS to a list that guards.py rail 1 still enforces per-trade

Promotion has no force field, and that is load-bearing

attempt_promotion's docstring is explicit:

FORCE carries no gate HERE … the O3 contract is the front-end's to keep, never the service's to assume

The console keeps that contract with a typed terminal confirmation. A browser cannot, so this front-end simply has no force path — and force-promote is itself one of the eleven. force=False is hard-wired and pinned by test. Promotion re-runs a backtest, so it runs as a job for the same reason a fetch does.

Verified end to end

303  BTC    303  ETH    303  PAXG          ← attested from the browser
blank source → recorded NOTHING

sqlite: ('BTC','native',0,'https://example.test/ruling','operator')
        ('ETH','native',0,...)  ('PAXG','native',0,...)

checklist: unattested: ADA, LINK, LTC, SOL, XLM
           Withdrawal capability attested → still shows `keel withdrawals attest --enabled`

Exactly the supplied values, nothing defaulted, and the one gated step still terminal-only.

4087 passed, 3 skipped (5 new). ruff check clean repo-wide, mypy clean.

What is still deliberately absent

  • Withdrawal attestation — one of the eleven. That is #436's browser-gate question, and I would leave it unanswered until someone needs a terminal-free live deployment.
  • Off-venue steps — nothing to record that would be true. A form saying "I turned off USDC Rewards" records what was asserted, not what is, which the operator runbook names as worse than an honest manual step.
  • confirm_cycle — unobservable by construction.

fix(db): WAL, because watching a fetch was killing it (#470)

Refs #437, #435. Found while verifying D4's acceptance end to end — by running it, not by testing it.

The bug

A first market-data fetch, started from the browser and watched on the page that says "this page will show its progress", died at 45 seconds:

market_data · failed · OperationalError: disk I/O error
                       (31,709 candles written)

Not disk space — 362 GiB free. Not corruption — PRAGMA integrity_checkok.

The setup page auto-refreshes every 5 seconds and opens the database to render the checklist. The background job was writing to it. In SQLite's default rollback journal a writer takes an EXCLUSIVE lock and readers take SHARED ones, so the two cannot coexist.

Measured, same fetch, same machine, real venue

polling journal outcome
every 5 s (the page) rollback FAILED at 45 s, 31,709 candles
none rollback ran 150 s, 108,202 candles
every 0.2 s (25× harder) WAL ran 150 s, 108,501 candles, 694 clean reads

The middle row is what makes this worth taking seriously: the fetch was fine, and observing it was the bug. Worse, observing it is the encouraged behaviour — the action's own message invites it.

The fix

In WAL, readers never block the writer and the writer never blocks readers.

The hazard was never specific to the job runner, either: an agent writing a cycle while a dashboard refreshes is the same shape. The web UI is simply what made it reachable.

A busy timeout comes with it — SQLite's default is zero, it raises immediately, which is the wrong default for a process that now reads and writes one file at the same time.

Journal mode is a property of the file, not the connection, so an existing deployment converts on its next connection and needs nothing from an operator. :memory: is excluded: no file to journal, SQLite refuses WAL there, and a shared in-memory database is single-connection anyway.

Two things checked rather than assumed

  • keel update's backups are unaffected. It uses SQLite's own online-backup API — precisely because "a plain file copy of a database with a live rollback journal is not a snapshot" — and that API reads committed WAL content too.
  • The -wal/-shm sidecars must not be mistaken for databases. Both the backup set and is_deployment_root glob keel*.db, which does not match them. Now pinned by test, because a -wal counted as a database would be backed up as one and, worse, would make any folder holding one look like a deployment.

Also checked: a read-only URI open (file:…?mode=ro, how keel setup inspects a deployment) still works against a WAL database with no writer present.

Verified end to end after the fix

The identical scenario, polled once per second — five times harder than the page does:

market_data · running · 102s elapsed · 74,714 candles · no error

Verification

4092 passed, 3 skipped (5 new). ruff check clean repo-wide, mypy clean.

The operator runbook now explains why a deployment folder has three files per database, and says not to hand-copy the .db while keel is running.

docs(setup): a refused promotion is the gate working, and the checklist should say so (#471)

Refs #437 (D4). No behaviour change — wording plus two pins.

Found while verifying D4's acceptance end to end on a real deployment.

What happened

A freshly-seeded turtle_breakout, promoted from the browser against 74,714 real candles:

rule 25 (turtle_breakout): overfitting check = not_run
  - n_trades 12 < min_trades 100
  - win_rate 0.5 < min_win_rate 0.55
  - G4 / PBO-CSCV NOT RUN: no trial matrix was supplied, so the probability that this
    rule's parameters were selected by overfitting is UNKNOWN — which is not the same
    as low, and is not a pass.
rule 25: status -> candidate

That is exactly right. It is also the single most likely thing a first-run user will see on this step — and left unexplained it reads as a broken button on a checklist that has gone green everywhere else.

What changed

The step now says a refusal is the gate doing its job, that this item can stay outstanding for a long time, and that the deliberate bypass is keel rules promote --force — at a terminal, on the record.

ready_for(PAPER)'s docstring gains the same correction: "set up" and "has a rule worth running" are different states. It reports the second, and a fresh install does not reach it quickly.

The website already says this about keel simulate"it will very likely tell you TRAIN MORE and name the gates that fail — that is the engine working, not broken; the honesty is the feature" — and the setup checklist was the one surface still missing it.

4094 passed, 3 skipped (2 new). ruff check clean repo-wide.

The open question this surfaces

D4's acceptance reads "a user with no prior state reaches a working paper deployment without typing a command." Every mechanical and operator-input step now gets there. rule_promoted does not, and cannot, because a fresh rule legitimately fails the gate.

Two ways to close that, and I would not pick the first:

  1. Expose --force in the browser. No — it is one of the eleven, and attempt_promotion states that force carries no gate at the service level, so the front-end is the gate. A browser cannot keep that contract.
  2. Read the acceptance as "set up and ready for you to promote a rule when one earns it." That is what the product actually is, and what the honest wording above now describes.

docs(specs): PRD — the v0.11+ roadmap: deepen the moat, attack throughput and fees (#480)

The roadmap PRD from today's planning round, in the #448/#449 house format.

  • Rejects the Go rewrite on evidence (throughput and fees are the bottlenecks; the evidence base and auditability are the product; distribution is already in flight via #458/#459/#463)
  • Settles the competitor scan feature by feature — including categorical refusals (prediction markets, agentic execution, remote control) with reasons
  • Corrects the record: WAL = write-ahead logging, not an allowance ledger
  • Encodes the sequence with milestones and issue numbers, the n_eff rule, and the five guardrails that outrank any feature

Supersedes nothing on disk; complements the 2026-08-20 competitive-gap and desktop-distribution PRDs. Related: #474#479, #441, #443, #427.

feat(research): allowance-throughput planning — route within rail 14, count evidence in n_eff (#478) (#481)

First slice of #478 (Refs, not Closes — maker-vs-taker routing and runtime wiring are gated on #333 / Phase 12).

What

keel/research/throughput.py — pure, Decimal, no I/O:

  • Throughput per venue: min(expected signals, allowance / mean proposal notional). At the measured numbers ($4,212 proposal vs $500/month allowance) that is 0.1187 trades/month — the report states the fraction rather than rounding up into a breach.
  • n_eff everywhere: DEFF = 1 + (k−1)·ICC = 2.57516 from the cross-verification's measured k = 8.43, ICC = 0.212; 100 pooled trades → ~39 effective observations; (z₀.₉₅+z₀.₈₀)²/4 = 1.5464 reproduces the note's table (20-pt edge at n_eff 39; 618–619 for 5 pts). All time-to-detection claims divide by DEFF first (#427's rule).
  • Allocator: greedy per venue, most trades per allowance-dollar first; by construction spend never exceeds an allowance — products that do not fit are deferred with a reason, never squeezed through the cap. The issue's non-negotiable, enforced by construction and asserted as a property in tests.
  • render_report: the honest planner output, with the rail 14 guardrail stated outright.

Verified

  • 14 new tests reproducing the published values (including two of my own draft constants caught by re-derivation — the tests now match the math, not vice versa)
  • tests/research/ full suite: 112 passed; ruff + format clean; mypy clean

chore(release): 0.11.0 (#482)

Version bump for the v0.11 release (#474): version in pyproject.toml 0.10.0 → 0.11.0, with uv.lock refreshed in the same commit so the release job's stale-lock check (#424's three-steps-late failure mode) cannot trip.

Minor, not patch: the browser becomes a first surface (keel serve #452, the setup path #455#469), the database moves to WAL (#470), and the desktop app ships unsigned with its documented walkthrough (#458/#459/#463).

After merge: dispatch Release with version=0.11.0, desktop=publish-unsigned (the #474 acceptance — wheels + config + macOS app — with docs/desktop-install.md as the honest unsigned walkthrough).

fix(release): the desktop legs publish FILES, and each leg's checksums survive (#483)

Found by the first publish-unsigned run (v0.11.0 attempt, run 32534578927): both macOS legs failed at Checksums because out/ holds the unzipped keel.app/ and dmg-stage/ directories beside the .dmg, and shasum ./* cannot checksum a directory.

  • Checksums → files only, excluding the sums file itself (the shell redirect creates it before find runs)
  • Attest provenance → per-OS subject globs (out/*.dmg / out/*.zip): the action has no files-only filter, and a shared two-glob pattern would hand each leg a glob matching nothing
  • Attachgh release upload the files only
  • Bonus defect: three legs uploaded one shared SHA256SUMS.txt under --clobber — only the last leg's sums would survive, so a macOS user could never verify their .dmg. Now per-leg: SHA256SUMS-<os>-<arch>.txt

After merge: delete the partial v0.11.0 tag+release and re-dispatch for the complete cut.

fix(release): repair release.yml on main — glued comment made the workflow unparseable (#484)

The files-only fix in #483 merged with a defect: a newline cleanup joined the Checksums comment to - name: Checksums, so shell:/run: became duplicate keys of the Windows Package step. PyYAML (last-key-wins) passed every test; GitHub's strict parser rejected the workflow at dispatch — main currently has NO runnable release workflow.

  • Restores the newline (file parses again)
  • Adds a strict duplicate-key SafeLoader to the workflow fixture that reproduces GitHub's parser behavior — verified to reject the broken text with the exact dispatch error (duplicate key 'shell' at line 420) — so this class of edit can never merge quietly again

Hotfix for the #474 release run; re-dispatch follows immediately after merge.