Skip to content

keel v0.11.1

Choose a tag to compare

@github-actions github-actions released this 22 Aug 22:03
· 435 commits to main since this release
2560ffd

Built from 2560ffd. Version binds to this hash:
keel --version reports keel 0.11.1+2560ffdf0c6e [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.1-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.

Other changes

feat(commands): keel doctor — 'is this deployment actually working' (#443) (#488)

First slice of #443 (Refs — admissibility-at-ATR and data health follow).

What

keel doctor — read-only, --json, exit 1 only on real faults:

  • install identity — clean [release] build, no skew/DIRTY/[checkout] (reuses the keel versions check)
  • attestation freshness — rails 14 and 17 in days remaining: absent/expired fail, due-soon warns; each names its attest command
  • rail state — kill switch and streak halts report as halted: deliberate, correct, not a fault (the issue's design note, as a contract test); drawdown past the 20% rail fails
  • allowance headroom — month-to-date spend vs the attested allowance
  • recent vetoes — executor.order_vetoed events from the JSONL log aggregated by reason; one reason vetoing everything is a FAIL naming its fix — the motivating case (15 of 15 on subscription_unattested) is reproduced as a test

Verified

  • 16 tests (pure functions over plain values and log lines — no DB needed)
  • ruff + mypy clean; keel versions tests unaffected (23 passed together)
  • live smoke: doctor on this checkout immediately surfaces its own unattested subscription with the fix command

Deferred (named, not hidden)

Config-vs-reality admissibility at current ATR and per-product data staleness need the sizing/candle seams — next slice, tracked in the issue.

feat(doctor): admissibility at current ATR + per-product data health (#443 slice 2) (#489)

What

Slice 2 of keel doctor (#443): the two checks the issue names as remaining.

1. Config-vs-reality admissibility (sizing.admissible) — the fraction of allowlisted
products whose risk_pct sizing fits the profile's own caps.max_per_order_usd at
CURRENT market conditions. Pure function admissibility_findings(rows, equity, risk_pct, max_per_order_usd) over AdmissibilityRow(product_id, price, atr) dataclasses:

  • computes the risk-notional band across the two stop conventions the shipped rule
    families use (stop = price − 1.5×ATR for rsi_meanrev, 2×ATR for turtle_breakout),
    sizing each edge with execution.sizing.size/spend;
  • verdict per product: fits (band high ≤ cap) / cannot_fit (band low > cap) /
    marginal (straddles) / no_data (no ATR);
  • aggregate: OK "N/N", WARN naming flagged products with their bands, WARN "no ATR data"
    → keel fetch, FAIL when every product with data cannot fit → "lower risk_pct or raise
    caps.max_per_order_usd in the profile".

2. Per-product data health (data.missing / data.stale / data.gaps) — staleness,
cold caches and gaps per allowlisted (product, granularity) series via
SeriesHealth(product, granularity, freshness, unexplained_gaps) and
data_health_findings. Reuses fetch.assess_products over doctor's 7-day window with
fetch's own default tolerance. market_closed (FR-9) defuses staleness exactly as
keel fetch does; missing is never defused (a closed venue still serves history).
Unexplained gaps warn toward keel fetch --repair-gaps.

Conventions (stated in every finding's detail)

  • equity stand-in: caps.max_exposure_usd — the live-path convention
    (keel/execution/executor.py, the equity_override fallback);
  • ATR(14) computed on the finest configured granularity (smallest
    GRANULARITY_SECONDS step), last 200 bars, ≥30 candles required;
  • stop band 1.5–2×ATR (2× sizes smaller → band low, 1.5× → band high);
  • data health / gap window: 7 days (DOCTOR_WINDOW_SEC), same horizon as the veto sweep.

Tests

TDD: 16 new pure-function tests written first (band arithmetic pinned by hand, e.g.
equity 5000 × risk 0.01 → $50 budget; price 100 / ATR 10 → band $250.00–$333.33),
watched fail on ImportError, then implemented. tests/commands/test_doctor.py:
16 passed → 32 passed.

Closes #443

Rule significance testing: a family's edge vs the fee actually paid (#475) (#491)

What

keel.research.significance — a pure, report-only module that answers issue #475's question: is a rule family's edge distinguishable from zero at the fee actually paid? One-proportion test with the family's own payoff-implied break-even as the null (1/(1+b)), one-sided at alpha 5% (consistent with the sample-size constant (z_0.95+z_0.80)^2/4 = 1.5464), with the standard error formed on throughput.n_eff — design effect 2.57516, #427 — never raw n. Plus a provenance-tracked driver over the real cached history and its companion record.

Decimal for every rate/price/edge; float only inside p_value, as in deflate.py. The module measures; it does not score, gate, or change anything.

The honest framing (non-negotiable, and delivered)

The tool's job is to be able to say no. It says no.

Pooled per family, 30 products x 5 years of cached ONE_HOUR candles, both fee regimes — no family's edge is distinguishable from zero in the positive direction anywhere:

family outside allowance (120 bp taker) inside allowance (fee-free)
turtle_breakout edge −39.9 pts, z −42.4 → not_distinguishable edge −2.8 pts, z −3.3 → not_distinguishable
rsi_meanrev edge −56.2 pts, z −31.8 → not_distinguishable edge −8.4 pts, z −3.7 → not_distinguishable
pullback_continuation edge −81.3 pts, z −142.5 → not_distinguishable edge −6.7 pts, z −4.9 → not_distinguishable
  • Outside the allowance the taker fee is the whole story — payoff b collapses (turtle 2.62 → 0.78) and observed win rates sit tens of SEs below break-even. docs/launch.md's 0-of-90/0-of-82, reproduced under a significance framework.
  • Inside the allowance: ≈ break-even, slightly below (−2.8 to −8.4 pts) — the 5 bp/leg spread retained in that regime; the cross-verification's psi table (+0.02% at psi=0, −0.58% at 10 bp) brackets it. The shape survives: indistinguishable from break-even inside, decisively negative outside.
  • 3 of 180 per-cell flags (turtle, inside, thin books: ZEC/WLD/PAXG, p 0.030–0.045) are recorded and read as what 180 comparisons at alpha 5% expect (~9 false positives); the same regime's pooled row is −2.8 pts.

Fee-regime split & n_eff correction

Same reconstructed trades re-priced at backtest.TAKER_FEE_PCT (120 bp/leg) and at zero (rail 14's fee-free allowance — the profitability boundary). Every SE uses n_eff = n/2.57516: the turtle pool of 7,152 trades is 2,777 effective observations. Raw n is always shown beside n_eff, never alone.

Tests (TDD: written first, watched fail)

19 new tests in tests/research/test_significance.py: exact-Decimal payoff/break-even/win-rate fixtures, the n_eff correction, se/z/p hand-computed against deflate.normal_cdf, the null refused, a large synthetic edge detected, the published detection boundary, degenerate no-wins/no-losses/empty samples, render shape, determinism. One spec correction, recorded in the .md: at n=100/edge 20 pts/break-even 1/2, z lands on z_0.95+z_0.80 = 2.487 (p ≈ 0.006) — the 80%-power boundary — not 1.645/p=0.05; a 5% one-sided test crosses alpha at 13.2 points on that geometry. The published 20-point figure is the conservative case (at b=6 the null variance is lower still).

Gates

  • uv run pytest -q: 4143 passed, 3 skipped
  • uv run ruff check keel tests: clean; ruff format --check on the new files: clean (driver linted too)
  • uv run mypy: clean (321 files)

Artifact: docs/experiments/2026-08-21-rule-family-significance.jsonl (186 rows, run read-only via mode=ro). Record: docs/experiments/2026-08-21-rule-family-significance.md. A post-format re-run of the TON-USD cells reproduced its artifact rows byte-identically.

Closes #475

feat(research): optuna parameter study, research-side only (#476) (#492)

What

An Optuna parameter-study harness (`keel/research/tuning.py`, issue #476) plus its pre-registered driver and record. Candidates for the gauntlet, never auto-tuned lives.

Guardrails (the point of the issue)

  • Research-side only. The module gates exactly one thing — the proposal of a candidate — and never anything live or paper. No rule rows are written, no profile is tuned, the promotion gauntlet is untouched. The three family winners were appended to the trials ledger as `diagnostic_only` with `provenance="fitted"` (it IS fitted; that is what the ledger is for).
  • Overfitting gate before any proposal: held-out-window expectancy > 0 AND CSCV/PBO (`keel.research.cscv`, s=8, trials with <10 closed trades excluded) <= 0.5. The harness refuses to emit a `PROPOSE as candidate` line in any other state; it can only say `no candidate may be proposed: …` with the numbers.
  • Deterministic under a fixed seed (acceptance test below).
  • optuna is optional: added to the dev dependency group only (`uv lock`/`uv sync` updated); imported lazily inside `run_study`; `import keel.research.tuning` pulls no optuna (pinned by subprocess test); shipped wheels stay clean.

Determinism evidence

`tests/research/test_tuning.py` (19 tests, written first, red then green):

  • Same seed + same candles + `n_trials=15` → identical best_params, best_train_expectancy, gate, and the full 15-trial sequence, twice.
  • Different seed → different trial path (so the guarantee is not vacuous).
  • Gate pbo is exactly `cscv.pbo` on the same columns (wired, not reimplemented); thin-column skipping; refusal when CSCV has no matrix.
  • `SEARCH_SPACES` integrity incl. constructing each real rule from mid-space params (catches name drift); exact lo/mid/hi kwargs; deterministic EMA-fan clamping.

Real-run gate outcomes (docs/experiments/2026-08-22-optuna-parameter-study.{py,md,jsonl})

9 cells (3 families × BTC/ETH/SOL), 60 seeded TPE trials each, trailing 2y ONE_HOUR bars, 70/30 chronological split, 120 bp/leg taker fee + 5 bp/leg slippage, read-only db, 1,729 s wall clock.

Every cell REFUSED — no candidate may be proposed. Per family winner:

family winner train exp held-out exp PBO verdict
turtle_breakout SOL-USD −1.34 −1.82 (14 trades) 0.70 REFUSED
rsi_meanrev ETH-USD +220.98 0 over zero closed trades 0.57 REFUSED
pullback_continuation SOL-USD −4.79 −2.56 (15 trades) 0.84 REFUSED

Honest reading: turtle and pullback had no positive train configuration anywhere (120 trials across two families — the fee is the binding constraint, again, exactly as #475 found); rsi's one positive train cell fit parameters so selective the held-out window produced no closed trade at all — the overfitting signature itself. PBO 0.70–0.97 where every trial gave a usable column. That IS the expected result; the harness's job was to be able to say no, and it says no.

Gates

  • `uv run pytest -q`: 4,182 passed, 3 skipped
  • `uv run ruff check keel tests docs/experiments/2026-08-22-optuna-parameter-study.py`: clean
  • `uv run ruff format --check` on the new files: clean
  • `uv run mypy` (323 files) and direct `uv run mypy docs/experiments/2026-08-22-optuna-parameter-study.py`: clean
  • Ledger chain verified intact (90 rows) after the 3 appended diagnostics

Closes #476

feat(mcp): read-only MCP server for research assistants (#477) (#493)

Closes #477

A read-only MCP server for research assistants, over stdio, stdlib-only. The #453 capabilities check already proves what can trade and what gates it; this PR builds the assistant surface on the far side of that line and enforces it with a test that was committed before the server existed — the fence came first, the server was born inside it.

The eight tools

Tool What it returns
doctor The same findings keel doctor computes (subscription freshness, rails, allowance, vetoes, data health, sizing admissibility), each naming the CLI fix
capabilities The #453 inventory as structured rows; needs no database
profiles The deployment's config*.yaml as rows (allowlist, risk_pct, caps, DCA budget, granularities, auto-cycle interval); unloadable files listed with their error; credentials/.env never read
orders Orders audit-log rows, filterable by mode/product/status, newest last, bounded (default 50, max 200)
veto_log Parsed executor.order_vetoed JSONL events since a timestamp (default 24h), bounded
purification The KB §65.9 report: owed by asset, tainted units, entries awaiting human classification
trials Trials ledger counts + hash-chain verification errors + the most recent rows
reports List/read the research corpora (bounded, bare names only)

Why it cannot trade — enforcement, not promise

tests/mcp/test_readonly.py (commit 1, before keel/mcp/ existed) builds six walls:

  1. Write-verb vocabulary — every tool name/description checked word-boundary against arm, release, resume, spend, attest, promote, update, reset, record, withdraw, autonomy, kill, trade, execute, order_create, submit, place. Defined independently of CAPABILITIES because the registry covers only GATED mutators — keel subscription attest and keel rules promote --force have no row; the vocabulary catches them anyway — then cross-checked so every one of the 11 registry rows is caught by it (a new gated action that dodges the vocabulary fails CI).
  2. Registry mapping — no keel/mcp/*.py references any capability row's module.function, dotted or bare; every handler's source file must live under keel/mcp/.
  3. AST write-deny scan — no call on any object whose attribute starts with set_, upsert_, record_, insert_, arm, attest, execute, … with an empty allowlist, pinned empty by its own test (plus a false-capable test proving the scan bites).
  4. No gate call sites — zero calls to _require_interactive_confirmation: a server must never hold the ceremony gate.
  5. Docs pin — docs/mcp-server.md's tool table equals the exposed tool set, bidirectionally.
  6. Import surface — keel.mcp.server imports without click and without keel.cli (fresh-interpreter subprocess check); the package never imports the executor or the agent directly.

Behavioural wall: a stream of only notifications produces zero bytes of output — stdin alone is never enough.

Runtime shape matches: repo opened per call without migrate (the keel/web/server.py rule), and a missing database is a calm isError rather than letting sqlite3.connect create one. Doctor's gather is pinned read-only at the SQLite level (total_changes counted around a call).

The gather refactor

keel/commands/doctor.py now exposes gather_findings(repo, config, log_lines, now_ts) — every check the inline click body used to run, moved verbatim; doctor_cmd is a thin wrapper. The MCP doctor tool calls the same gather, so an assistant and an operator cannot be shown two different accounts of one deployment.

Transport

Newline-delimited JSON-RPC 2.0 over stdio; initialize / tools/list / tools/call; notifications unanswered; tool failures as isError results (the loop never dies); -32700/-32601/-32603 at the protocol layer. Hand-rolled over stdlib — no mcp, no fastmcp, no pydantic, zero new dependencies, same answer keel/web gives for six pages. The command prints no disclaimer: stdout is the protocol; the read-only statement travels in serverInfo and tool descriptions.

Smoke (from the worktree)

$ printf '<initialize>\n<notifications/initialized>\n<tools/list>\n<tools/call capabilities>\n' | uv run keel mcp
{"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}},
 "serverInfo": {"name": "keel-read-only", "version": "0.11.0"}}}
{"jsonrpc": "2.0", "id": 2, "result": {"tools": [{"name": "doctor", ...}, ... 8 tools ...]}}
{"jsonrpc": "2.0", "id": 3, "result": {"content": [{"type": "text", "text": "{\"rows\": [... 11 capability rows ...]}"}]}}

Three JSON lines on stdout (the notification correctly produced none), stderr empty, exit 0.

Gates

  • uv run pytest -q — 4198 passed, 3 skipped (33 new tests: 16 enforcement, 14 protocol, 3 gather, 2 CLI — the docs pin was red until the docs commit, per the issue's TDD order)
  • uv run ruff check keel tests — clean; ruff format --check on all new files — clean
  • uv run mypy — clean (329 files)

Commit order (the TDD point)

…truncated — full description in #493.

fix(execution): subtract pending purification from balance-derived sizing equity (#490) (#494)

Closes #490

Verification verdict, per trading mode

Mode Sizing equity source Rewards excluded? Evidence
Live / confirm config.caps.max_exposure_usd (config-constant stand-in; equity_override is only ever passed by the paper path) Y — immune by construction (a constant, not a balance read; reward accruals cannot reach it) keel/execution/executor.py:395-398 + module docstring lines 12-18
Paper (sizing) paper_cash_usdc seeded once from live balances when starting_equity_usd == 0, then PaperTrader.equity -> _paper_enter(equity_override=...) -> sizing.size N — GAP WAS REAL (fixed here): the seed read _mark_to_market_equity, which sums broker balances at face value, and USDC Rewards accrue inside the account (runbook §1: cannot be switched off via the Advanced Trade API). Nothing subtracted pending purification, so the tainted seed became the sizing base — riba compounding into position size (discussion #472) seed: keel/agent.py:509-531; balance read: keel/agent.py:440-444; equity: keel/strategy/paper.py:227-241; sizing handoff: keel/agent.py:602-603
Paper (funded seed) config.paper.starting_equity_usd > 0 Y — immune (operator-chosen constant, not a balance read; contains no accruals by construction) keel/agent.py:516-517
Sim account.mark_to_market(latest_price) = cash_usdc + exposure N/A — rewards cannot enter: sim cash changes only via explicit deposit() and simulated fills; there is no transaction-ledger import and no reward-accrual mechanism anywhere in keel/sim/ sizing: keel/sim/portfolio_sim.py:609-611; equity: keel/sim/account.py:635-637
Display / record (rail-11) update_drawdown fed by _mark_to_market_equity (live) or paper equity (paper) N — includes reward income, deliberately unchanged: the HWM/drawdown breaker measures what the account actually holds; purifying its input would make the breaker measure a post-obligation fiction. Out of the issue's sizing scope, documented in the runbook note producer: keel/execution/equity.py:102 (update_drawdown); feeder: keel/agent.py:1356-1368

Net verdict: the gap was real on exactly one sizing path — the paper account's balance-derived seed — and is fixed. The live config-constant stand-in was already immune and is unchanged (pinned instead). The sim has no reward mechanism. Rail-11 display equity still includes rewards by design.

The purification number

pending_purification_usd(repo) = build_report(repo.get_transactions()).total_owed_usd. Property semantics checked: the report's "accrued, must be given away" figure is total_owed_usd (sum of NON_COMPLIANT entries — the same number keel purification renders as "TOTAL OWED TO CHARITY"). There is no total_owned_usd property (the issue's name was approximate); owed_by_asset/qty_by_asset are per-asset/qty breakdowns, and needs_review (unclassified) is deliberately not counted — over-purifying would misstate a religious obligation as fact (compliance.purification.classify's own posture). Caveat stated in the docstring: the report is cumulative over the imported ledger with no discharge record, so "pending" = everything owed per the ledger; for a sizing base that is the conservative direction (equity can only come out smaller).

What changed vs what was pinned

Changed (commit 2):

  • keel/execution/equity.py: pure helpers sizing_equity(mark_to_market, pending_purification) -> max(mtm - pending, 0) and pending_purification_usd(repo), docstrings citing #490 and #472's invariant ("interest left sitting in the balance would inflate the equity the sizing formula reads from").
  • keel/agent.py: the balance-derived paper seed now goes through sizing_equity. Funded branch and live stand-in untouched.

Pinned, not changed (commit 1 — red where the gap was real, green elsewhere):

  • tests/execution/test_equity.py: the helper contracts — subtraction, zero-pending identity, zero floor; pending_purification_usd counts only NON_COMPLIANT income (CLEAN and REVIEW excluded).
  • tests/test_agent.py: test_paper_seed_excludes_accrued_but_unpurified_reward_income (was red — seeded 42000 with 2000 of reward income on the ledger; now seeds 40000); test_funded_paper_seed_is_the_configured_amount_not_purified (boundary pin).
  • tests/execution/test_executor.py: test_live_sizing_equity_is_the_config_constant_immune_to_reward_accruals — a repo full of reward income leaves the live sized qty exactly sizing.size(max_exposure_usd, ...). If sizing ever moves to a live balance read, this pin fails and the #490 exclusion must move with it.

Docs (commit 3): the invariant added next to the USDC Rewards manual step in docs/operator-runbook.md, including the rail-11 boundary.

Gates

…truncated — full description in #494.

C1a — Lookahead and recursive bias detection (#440) (#495)

Closes #440

What this adds

  • keel/research/bias.py — the pure analysis: lookahead_analysis(detect_fn, candles_by_tf, ...) (truncation diff) and recursive_analysis(candles, indicator_fn, ...) (warmup-drift), plus render_lines for both.
  • keel rules lookahead <id> — the operator diagnostic over a saved rule (exit 0 clean / 1 lookahead_detected, like doctor), with --granularity, --sample-step, --recursive.
  • Promotion enforcement — rules promote refuses a rule whose lookahead verdict is lookahead_detected.

The seam chosen, and why

detect() is documented pure, and the backtester fixes its per-bar semantics (candles_by_tf = {tf: candles[: i+1]}): the decision at bar N is detect over data ending at N, and Setup.ts names that bar. That is what a live engine sees, so the harness replays detect on truncated views — not engine.evaluate, which would drag the CTS scorer and regime gates into a bias diagnostic that must not depend on them.

One detect call only answers about its last bar, so two comparison axes run:

  • A. Full-series reference — the full run's claimed anchor (Setup.ts) is re-run as the live prefix ending at that bar and diffed on presence + entry/stop/target. Catches the classic leak (target priced off the future swing, or a signal only the future manufactures).
  • B. Higher-timeframe poison — at every sampled anchor, coarser series are handed over at full length (the future closes their bars) while the live view keeps only coarse bars closed by the anchor's close. This is the engine-veto leak point (engine._higher_tf_bias_ok reads the coarsest TF): a rule keyed on closed coarse bars sees identical data in both views (clean); a coarse[-1] reader diverges, bar named.

False-positive control: setups are compared only when both views claim the same ts — a legitimately moving argmax anchor is not lookahead. A clean last-bar rule is compared only at the final bar (where prefix == full): the report is honestly vacuous for that shape rather than manufacturing a verdict, and a never-firing rule reports clean with the bars it walked.

Engine-veto coarse-bar handling

Coarse-bar closedness is cut at the anchor bar's close instant (anchor.ts + anchor_step), matching the backtester's decision time. Every coarse bar excluded from the live view closes strictly after that instant, so the poison view's extra coarse bars are exactly the future — a rule legitimately reading a coarse bar that closes within the anchor's own span is not falsely flagged (the issue's ts + step > anchor_ts shorthand would have excluded exactly that bar). The CLI/promote paths also opportunistically add the next-coarser cached series when the deployment has one, so axis B has a coarse bar to test.

Enforcement posture

promote already runs a gauntlet that --force bypasses, so the lookahead check is wired into that gauntlet the same way (the option the issue's own instruction picks first): gated path only, fail-closed, refusal prints the divergences plus a pointer to keel rules lookahead <id>, nothing written, --force remains the documented bypass for it as for every other gate. The clean path echoes nothing new, so passing promotions' output is byte-unchanged (all pre-existing promote/parity tests green untouched).

Cost bounds

lookahead_analysis costs ~2 detect calls per sampled anchor + 1 for the full run. The CLI defaults --sample-step 1 (every bar past warmup; documented); the promote gate samples the whole history to at most ~200 anchors (sample_step = ceil(n/200)) — seconds on the series a promotion re-backtests anyway. Warmup floor is max(50, the rule's own bar-count params) read off its persisted params.

--recursive family mapping

atr_period families (turtle, rsi) run recursive_analysis on their own ATR (min_warmup = 4 x period, the same work-window multiple the rules themselves use); families without one (dca, pullback) get an honest "no recursive-suspect indicator configured — nothing to check" line. Recursive drift is printed but is not by itself an exit-1 condition (the issue calls it the lighter check; only lookahead_detected gates promotion and exit codes).

Tests (23 new, TDD: committed red before the implementation)

tests/research/test_bias.py (15): leaky target detected naming the anchor bar; clean last-bar and closed-coarse readers clean; blind coarse-tail reader detected (engine-veto leak); Decimal dust below/above tolerance; setup_present when the future manufactures the signal; sample-step bounds cost, first-5-of-N reported; short series / never-firing rule clean; converging ATR stable vs length-dependent drift flagged at worst N; both renders. tests/commands/test_rules_lookahead.py (8): CLI exit codes, --recursive mapping, unknown id, promote refusal (row unmutated) + --force bypass, and the service-seam refusal.

Gates

uv run pytest -q — 4208 passed, 3 skipped · uv run ruff check keel tests — clean · uv run ruff format --check on the new/touched files — clean · uv run mypy — clean (326 files).

C1b: Monte Carlo and candle-bootstrap resampling -- is the equity curve an outlier? (#441) (#496)

Closes #441.

The question this answers

The live reconstruction stands at a ~14.9% win rate against a ~14.9% break-even. significance.py answers "is the edge distinguishable from zero?" (so far: no). cscv.py answers "was the selection process overfit?". Neither answers the path-luck question directly: did one lucky path produce this curve? This PR answers exactly that, and nothing else. The module docstring says it in the research-package voice: this module measures; it does not score, gate, or change anything.

Two modes

  • trades -- the same trades reshuffled (reshuffle): one random.Random(seed), paths drawn sequentially, each path a permutation of the input multiset (never sampling with replacement). Honest by construction: a permutation sums to the same number, so every reshuffled path ends at the observed final equity and the percentile reads exactly 1/2 (ties count half). The report states this rather than letting a degenerate 1/2 read as "perfectly median"; the ordering luck this null exposes lives in the path between start and end, which equity_curve exposes to any caller.
  • candles -- the moving-block bootstrap (moving_block_bootstrap): consecutive blocks of real bars resampled with wrap-around to the same total length, timestamps re-anchored monotonically from candles[0].ts at the rule's own step_sec, fresh Candle instances always (inputs never aliased or mutated). This is the valuable null: it preserves the local autocorrelation a naive IID resample would destroy -- exactly the structure turtle_breakout trades. Each bootstrapped path is re-run through the real backtest at the same fee, so a percentile difference is path luck, not a pricing difference. The block-stitching seam (a price discontinuity the real series never had) is documented in the docstring and visible to the backtest, not smoothed away.

Determinism evidence

  • No global random anywhere; every entry point takes a seed and drives its own random.Random instance.
  • Pinned by tests: reshuffle(pnls, n, seed) equals itself across two calls; the bootstrap likewise; and the CLI test runs the full command twice under one seed and asserts the rendered report reproduces line-for-line (only the ledger row's timestamp/hash legitimately differ).
  • --seed is REQUIRED on the CLI -- determinism is the point.

Percentile convention

percentile_of(observed, dist) = fraction of the distribution strictly below the observed value, ties counting half, in exact Decimal: (below + ties/2) / n. Below all -> 0, above all -> 1, the all-ties trades-mode shape -> exactly 1/2. Stated in the docstring and pinned by tests.

Ledger wiring

keel trials monte-carlo --rule N --seed S [--mode trades|candles] resolves the stored rule through the exact rules backtest seam (resolve_rule_backtest; granularity default chain: explicit --granularity -> the rule's own -> ONE_HOUR), runs the observed backtest once at the resolved fee (provenance line printed beside every number), then appends one diagnostic_only row: provenance=a_priori, per_trade_pnl = the observed closed-trade P&L, summary = the report's numbers (ints and Decimals only, so read_trials round-trips). It never writes to the db and feeds no gate.

One vocabulary growth: monte_carlo added to the ledger's KINDS -- JSONL needs no migration, and filing a resample under ablation/sweep_node would corrupt the kind vocabulary the CSCV matrix's sessions lean on.

Report honesty

render_lines() always states mode, seed, n_paths, observed final, the resampled [min..max], the percentile, and the refusal line: a percentile here is path luck, not evidence of edge -- and it does not answer significance at all: whether the edge is distinguishable from zero is keel/research/significance.py's question. No n_eff caveat (resampling answers path-luck, not significance).

Tests (TDD: pinned first, watched fail on ModuleNotFoundError)

  • tests/research/test_montecarlo.py -- 15 tests: reshuffle permutation/determinism/seed-sensitivity, exact equity-curve arithmetic, bootstrap length/timestamp re-anchoring/first-block contiguity (wrap-aware)/fresh-instances/no-input-mutation/determinism/n_paths/degenerate-argument refusals, percentile below/above/tie conventions, final-equities + median parity, render refusal lines, and an end-to-end pure reshuffle case pinning the by-construction 1/2.
  • tests/research/test_trials_cli.py -- 6 new tests: --help surface, a full trades-mode invocation against a seeded tmp db asserting exit 0, output lines, exactly one diagnostic_only/monte_carlo ledger row with the chain still verifying, a candles-mode invocation, cross-run determinism of the rendered report, and the no-closed-trades refusal (no row written).

Gates

  • uv run pytest -q: 4206 passed, 3 skipped (was 4185 passed before this branch)
  • uv run ruff check keel tests: clean
  • uv run ruff format --check (new/touched files): clean
  • uv run mypy: no issues in 325 files

Not merging -- review first.

test(floor): pin mypy's python_version to the requires-python floor (#316) (#497)

Closes #316

What & why

The Python floor lives in two places that can drift apart: requires-python = ">=3.11" in every distribution and [tool.mypy] python_version = "3.11" in the root. Nothing fails a build where they disagree — mypy would happily check against 3.14 semantics while the distributions promise 3.11, and the first victim is a contributor on the floor version.

The change

Adds test_mypy_checks_against_the_floor_it_promises to tests/test_python_floor.py (in that file's established style): it parses the root pyproject.toml, derives the minor version from requires-python, and asserts [tool.mypy].python_version equals it. A drift between the two becomes a test failure with a message naming what drifted and how to fix it, instead of a silent mismatch.

Verification (test-first)

Written and watched red for the right reason: with [tool.mypy] python_version temporarily set to "3.14", the new test fails —

AssertionError: mypy checks against python_version '3.14', but the floor is '>=3.11' (minor '3.11'); ...

— then restored to "3.11" and green again.

Gates

  • uv run ruff check — All checks passed
  • uv run mypy — Success, no issues in 332 files
  • uv run pytest -q — 4203 passed, 32 skipped

ci: keep Code Quality green on push (org-level readiness gates) and fail a stale uv.lock early (#498)

Closes #402, Closes #424

Two CI failures that pointed at the wrong thing, fixed at the point where the mistake is made.

#402 — Code Quality push-runs fail on every main commit

Root cause (from the live run logs)

The issue title says "secrets/org config only in PR context"; the investigation found something more specific. This workflow has no pull_request trigger at all (deliberately, per its own header — fork PRs receive no secrets), so what passes on PRs is the SonarCloud GitHub App, a separate integration. The workflow's own failures started on 2026-08-18 — the day SONAR_TOKEN and SNYK_TOKEN were created (gh secret list confirms both, created 09:45/09:51 that day). The preflight's single configured flag flipped to true, both scan jobs ran for the first time, and both failed on org-level prerequisites that no repository secret can carry:

  • SonarQube scan → ERROR You must define the following mandatory properties for 'CodeGateSoftware_keel': sonar.organization (run 32553995252). sonar.organization is mandatory on SonarQube Cloud and is deliberately commented out in sonar-project.properties — a wrong value fails with a misleading "project not found", so it was left for the org owner to fill in. The token alone was never enough.
  • Snyk test / Snyk monitor → 422 Unprocessable Entity, SNYK-OS-PYTHON-0013 "Missing required packages". The token authenticates; the Snyk organization on the snyk.io side rejects the scan — org-level setup the repository cannot perform or even detect from YAML (the snyk monitor step has carried a --org=<keel-snyk-org-id> "SET BEFORE FIRST USE" placeholder since the workflow was written).

Net effect: a permanently red main, which is exactly the outcome this workflow's preflight was designed to prevent ("a red X nobody reads is not honest").

The fix — option (b): gate each scan on its real readiness, so it lights up the moment the org config appears

Main-branch analysis is genuinely wanted here (the push: [main] trigger was added deliberately in #272 once the repo went public), so restricting the jobs to pull_request (option a) would delete intended behaviour — and there is no PR-context behaviour in this workflow to preserve. Instead the existing preflight pattern was deepened, per scanner:

  • preflight now emits sonar_ready (token present and an active sonar.organization= line in sonar-project.properties) and snyk_ready (token present and the new SNYK_ORG repository variable, which the scan steps pass as --org — turning the old placeholder into real wiring).
  • Each scan job is gated on its own output, so one scanner's gap no longer keeps the other dark — and each lights up the moment its own missing piece is configured. No flag day.
  • The documented asymmetry is preserved and pinned by test: workflow_dispatch still fails loudly when a scan is not ready (silently ignoring a direct request is worse); push/schedule skip with a run summary naming exactly what is missing and where to set it.
  • The skipped-step notice names both the property (sonar.organization, in sonar-project.properties) and the variable (SNYK_ORG, Settings → Secrets and variables → Actions → Variables). A missing sonar-project.properties in preflight fails loudly rather than skipping the Sonar scan forever.

What an org owner still needs to do to light the scans up (the repository cannot): set sonar.organization in sonar-project.properties to the real SonarCloud key, and create/wire the Snyk organization then set SNYK_ORG. The moment either happens, its scan runs on the next push — no further workflow change.

#424 — a stale uv.lock fails the release three steps late as a dirty-tree error

Root cause

The 0.10.0 release: #422 bumped the version in all seven pyproject.toml files and missed the tracked uv.lock. uv sync silently re-locked the checkout ("Sync dependencies"), the stamp step then recorded a dirty tree, and the failure surfaced five steps later as ::error::artifact reports a dirty tree — sending the reader to keel/version.py and the stamp step instead of the lockfile, after Lint, Type-check, Test and Build had all run and passed.

The fix

New step "The lockfile must already be current" in release.yml, placed immediately after the interpreter is in place and immediately before "Sync dependencies" — the first step that can mutate the tree (and still well before "Test"). It runs uv lock --check under set -euo pipefail (flag verified against uv 0.11.31: "Check if the lockfile is up-to-date", no write), and on failure prints:

::error::uv.lock is stale for this pyproject -- run 'uv lock' locally and commit the result with the version bump. (A stale lock is otherwise discovered five steps later as 'artifact reports a dirty tree'.)

docs/RELEASING.md step 1 now says the bump is pyproject.toml and uv lock — the doc gap the issue named. The workflow still never writes to main (the issue's explicit "not proposed").

Test delta

…truncated — full description in #498.

Hygiene batch: gitignore, rail docs, env note, CLI help, kraken stub, indexes and floor pins (#499)

Nine small hygiene closures, one commit each.

  • #308 — .gitignore: added .mypy_cache/ beside the existing .ruff_cache/ / .pytest_cache/ entries.
  • #309 — docs/rails/rail-17-withdrawal-attestation.md: rail 17 (§65.4 qabd) explained for a reader who is not the author — what it permits (BUYs only under a fresh attestation), why it exists (funds enter holdings only under a deliberate, recently-renewed human act), what an expired attestation does (resolves UNKNOWN, rail fails closed, exits unaffected), the 7-day TTL, and the exact keel withdrawals attest --enabled renewal.
  • #310 — docs/rails/rail-14-subscription-allowance.md: rail 14 explained as the profitability boundary (cites docs/research/2026-08-20-quant-lab-note-cross-verification.md §5), with where the setting lives (the broker_subscriptions table via keel subscription attest, NOT config.yaml), even_daily pacing, the fail-closed fallbacks, the veto messages, and inspection via keel doctor / keel status / keel subscription show.
  • #311 — .env.example: noted that PAPER and SIMULATE also need a CDP key — read-only is enough — because candle history is fetched through the authenticated client (keel fetch without a key fails with AuthenticationError).
  • #312 — keel fetch --help and keel monitor --help no longer read as synonyms: fetch states its one-shot warm/refresh + --check verification job; monitor states its long-running keeps-the-cache-warm loop with FR-9 session recording and closed-market skip. New test pins each help's distinctive phrase and the absence of the other's.
  • #313 — packages/keel-broker-kraken: a stub adapter, port-complete (all ten Broker methods present) and honest — every data/market method raises NotImplementedError("kraken adapter is a stub — #313"), capabilities declared conservatively (no orders/preview/fees/quote-currencies; session_bound=False, crypto spot venue), no key handling (constructor takes nothing), no network calls. Wired like its siblings: workspace member, dev-group install, keel.brokers entry point, uv.lock refreshed, dependabot + security-scan export registered, strict mypy from birth with py.typed. Tests assert the port surface, the raises, the conservative capabilities and the credential-less constructor.
  • #314 — docs/experiments/README.md: index of all 31 experiment records, newest first, one honest line each (derived from each doc's title/status header), noting the 14 paired .py drivers; preamble states what the directory is (pre-registered, reproducible records; a committed script is a claim its numbers reproduce; decisions land in trials-ledger.jsonl).
  • #315 — docs/operations/purification.md: the purification report (KB §65.9) documented for the operator — what purification is (USDC Rewards accrue inside the account), the CLEAN / NON_COMPLIANT / REVIEW classification, what to do with needs_review rows, and that discharge is not recorded (total_owed_usd is lifetime-cumulative). Consistent with the runbook's #490 sizing-equity note.
  • #316 — tests/test_python_floor.py: new test pins [tool.mypy] python_version to every distribution's requires-python floor, so the floor and the checker cannot drift (packages carry no mypy config of their own; the root config checks them all).

Gates: uv run pytest -q → 4251 passed, 3 skipped (+7 cases vs base); uv run ruff check keel tests packages clean; uv run ruff format --check clean on every file created/edited here (tests/test_cli.py and tests/commands/test_brokers.py were already not format-clean at base and are left as found); uv run mypy clean (336 files).

Closes #308, Closes #309, Closes #310, Closes #311, Closes #312, Closes #313, Closes #314, Closes #315, Closes #316

feat(research): rolling-origin walk-forward validation, stability not selection (#445) (#500)

Closes #445.

What this adds

A reusable walk-forward validator that answers one narrow question: GIVEN a parameter set --
fixed before any fold is run, never chosen here -- does it hold up out-of-sample across
rolling-origin folds, and does it degrade as the data moves away from the period the set was
conceived on?

keel/research/walkforward.py (pure module)
  • Fold semantics: folds(n_bars, *, train_bars, test_bars, step_bars=None) produces
    sliding rolling-origin windows -- fold 0 trains on [0, train), tests on the next
    test_bars; each next fold advances BOTH windows by step (default step = test_bars,
    i.e. non-overlapping tests; a smaller step overlaps them). The last fold never exceeds
    n_bars; impossible geometry raises ValueError naming the window against the available
    bars.
  • Warmup/context, stated rather than implied: the TRAIN run of fold k is walked over
    candles[:train_end] -- the window plus every earlier bar, when any exist, so indicators
    enter the window with full context -- and its metrics count only trades ENTERED inside the
    train window. Fold 0 has no earlier bars, so its train run warms up inside its own window.
    The TEST run is walked over the test bars ALONE: the out-of-sample side is given no
    pre-window information and pays its own warmup cost -- the conservative direction, never
    the flattering one. Fully deterministic: no sampling, no RNG, no seed anywhere.
  • walk_forward(...) -> WalkForwardReport: per-fold FoldMetrics (bounds, train
    n_trades/expectancy for the in-sample vs out-of-sample honesty table, and the TEST-side
    n_trades / expectancy / win_rate / max_drawdown / per-trade P&L), plus aggregates: exact
    Decimal median test expectancy, n_folds_test_positive, and degradation = late-half
    median minus early-half. PINNED halves convention: with an odd fold count the MIDDLE
    fold joins neither half (first n//2 vs last n//2); fewer than two folds -> degradation is None, not an invented 0. stability_note describes ("positive in 2/3 folds;
    late-half median below the early half") and never prescribes.
  • render_lines(report): per-fold table + aggregates, and ALWAYS the statement that
    this validates ONE parameter set and does not rank. Window-size guidance from
    keel/research/deflate.py (min_trades at the observed Sharpe and trades/year,
    min_backtest_length_years) is rendered as "is this window long enough to mean anything?
    (report-only, never a gate)" -- a stopping-rule note about window SIZE, judged by the
    reader; anything not computable is stated as NOT COMPUTED rather than invented, and a
    non-positive observed Sharpe is told it has nothing to size a window against.
The refusal to rank, and how it is enforced

The Strathern rail (keel/research/cscv.py, spec §6) says a score may report and may gate
but may never be a ranking key -- a walk-forward that reported a "best window" would
reintroduce exactly that. No public function returns a fold, window or parameter set to
favour, because none is ever computed. Two tests enforce it mechanically:

  • test_refusal_to_rank_enforced_by_source_scan -- the module source may contain no
    ranking shapes: no key=lambda, no .sort(, and none of the vocabulary of selection
    ("best"/"winner"/"optimal"). Ordering for a MEDIAN (sorted(values), no key) is allowed:
    a median is an aggregate, not a winner.
  • test_report_dataclass_has_no_selection_fields -- WalkForwardReport carries no
    best_*/selection-bearing fields.
  • test_stability_note_and_render_describe_never_prescribe -- the rendered output carries
    no comparative wording ("best", "prefer", "recommend", "optimal", "winner", "superior",
    "chosen").
CLI: keel trials walk-forward

--rule (stored rule id, resolved through the exact rules backtest seam
resolve_rule_backtest, with the same explicit---granularity/rule's-own/ONE_HOUR default
chain), --train-bars, --test-bars, --step-bars, --session (default walk-forward),
--ledger. No --seed -- pinned by the help test -- because nothing samples. Prints
render_lines plus the fee-provenance line, then appends ONE ledger row PER FOLD
(kind=walk_forward, decision=diagnostic_only, provenance=a_priori, params carrying
the fold bounds + granularity + fee, per_trade_pnl = that fold's TEST P&Ls, summary with
the fold's metrics and the run's aggregates). No summary row on top -- the issue says every
fold, and a summary row would be one more place a reader might look for a scoreboard. A
fold whose test window closed zero trades is ledgered with series_missing=True (the
schema's word for "no P&L series on this row") so the fold stays visible in M instead of
being silently dropped. Refusals (no candles cached; window cannot fit even once) are
ClickExceptions that write no rows.

ledger.KINDS gains walk_forward (vocabulary addition only, same pattern #496 used for
monte_carlo).

Tests

…truncated — full description in #500.

chore(deps): bump the github-actions group with 3 updates (#501)

Bumps the github-actions group with 3 updates: astral-sh/setup-uv, actions/attest-build-provenance and actions/upload-artifact.

Updates astral-sh/setup-uv from 5 to 7

Release notes

Sourced from astral-sh/setup-uv's releases.

v7.0.0 🌈 node24 and a lot of bugfixes

Changes

This release comes with a load of bug fixes and a speed up. Because of switching from node20 to node24 it is also a breaking change. If you are running on GitHub hosted runners this will just work, if you are using self-hosted runners make sure, that your runners are up to date. If you followed the normal installation instructions your self-hosted runner will keep itself updated.

This release also removes the deprecated input server-url which was used to download uv releases from a different server. The manifest-file input supersedes that functionality by adding a flexible way to define available versions and where they should be downloaded from.

Fixes

  • The action now respects when the environment variable UV_CACHE_DIR is already set and does not overwrite it. It now also finds cache-dir settings in config files if you set them.
  • Some users encountered problems that cache pruning took forever because they had some uv processes running in the background. Starting with uv version 0.8.24 this action uses uv cache prune --ci --force to ignore the running processes
  • If you just want to install uv but not have it available in path, this action now respects UV_NO_MODIFY_PATH
  • Some other actions also set the env var UV_CACHE_DIR. This action can now deal with that but as this could lead to unwanted behavior in some edgecases a warning is now displayed.

Improvements

If you are using minimum version specifiers for the version of uv to install for example

[tool.uv]
required-version = ">=0.8.17"

This action now detects that and directly uses the latest version. Previously it would download all available releases from the uv repo to determine the highest matching candidate for the version specifier, which took much more time.

If you are using other specifiers like 0.8.x this action still needs to download all available releases because the specifier defines an upper bound (not 0.9.0 or later) and "latest" would possibly not satisfy that.

🚨 Breaking changes

🐛 Bug fixes

🚀 Enhancements

🧰 Maintenance

…truncated — full description in #501.

docs(review): the 2026-09-30 pooled review's effective power (n_eff 39, 20-point floor) (#427) (#503)

What was corrected, where

#427's finding is that the 2026-09-30 pooled review's n=100 floor (#353, scheduled in discussion #359) treats 100 pooled trades as 100 observations, when they herd (k = 8.43/episode) and correlate (ICC 0.212). This PR states the consequence everywhere the review's entry criteria or schedule are stated, citing keel/research/throughput.py (merged on main, pinned by tests/research/test_throughput.py) as the executable source:

  • docs/superpowers/specs/2026-08-21-v0.11-plus-roadmap-prd.md — the research-suite section now carries the full correction table (DEFF 2.57516; n_eff(100) = 39; detectable edge 19.9 points vs 12.4 independent; edge → n_eff → pooled: 12.4 pts → 101 → 259, 10 pts → 155 → 399, 7.5 pts → 275 → 708, 5 pts → 619 → 1,593) and the recommendation. The "answer in one paragraph" figure was sharpened from "258 pooled trades to detect the edge" to the precise statement.
  • docs/glossary.md — the promotion-gate entry now says a pooled count is not a count of independent observations (n/DEFF ≈ n/2.58; n=100 pooled is n_eff ≈ 39, a 20-point floor).
  • docs/research/2026-08-20-quant-lab-note-cross-verification.md — new §4.1 "Disposition of the correction": the executable encoding, the recommendation, and the statement that the schedule/floor decision remains the owner's.
  • Discussion #359 comment (the review's participants): https://github.com/CodeGateSoftware/keel/discussions/359#discussioncomment-18113570 — the table from the issue, what 100 pooled trades can and cannot show, and the recommendation.
  • Issue #353 comment (the review's tracking issue): #353 (comment) — the correction of record ahead of the scheduled run.

No schedule or floor was changed unilaterally: #359's body and #353's criteria stand as written; the correction lands as comments and docs.

The recommendation and its arithmetic

Reframe the 2026-09-30 review as descriptive, not a pass/fail gate (decision stays with the review's owner):

  • The cross-verification note §5 fixes the edge worth powering for at the fee actually paid: inside the fee-free allowance p_be = 14.88% vs a reconstructed 14.9% win rate — the plausible edge is within 0.6 points of zero; outside the allowance the rules are 14 points underwater and no detectable edge rescues them.
  • Powering for that small edge: required_n_eff(0.05) = 1.5464/0.05² ≈ 619 n_eff → 1,593 pooled trades (619 × DEFF 2.57516) — ~50 years at Basic hourly throughput even after the #426 fix (months_to_target), ~13 years at DCA-sized notional ($50 per buy, config.yaml's dca.budget_usd, gives 10 trades/month at the $500 allowance → 1,593/10 ≈ 159 months). A 10-point edge needs 155 n_eff → 399 pooled.
  • The minimum honest floor — 259 pooled = n_eff 101, what "n=100" was assumed to deliver — itself only detects a 12.4-point edge, which the reconstruction says does not exist.
  • So on 2026-09-30 the review should report forward outcome rates with n_eff-corrected intervals (keel/research/significance.py, PR #491), rail-veto telemetry, and accrual vs expectation, printing in the report: "at this n_eff the review can only see an edge of X points or larger."

No new code: every number cited is computed by keel/research/throughput.py and pinned by tests/research/test_throughput.py.

Gates

  • uv run pytest -q: 4347 passed, 3 skipped (docs-only change; ruff/mypy not applicable).

Partially addresses #427 — the review itself runs on its own date (2026-09-30) and its execution remains outside this PR.

fix(update): install keel_broker_alpaca on self-update (#425) (#504)

What was wrong (confirmed, not assumed)

keel update on an equities deployment (config.paper-equities.yaml, broker: name: alpaca, keel-broker-alpaca installed in the venv) fails at verification, after the wheels are in, then reverts the whole deployment — exactly the "failure AFTER a finished install" path the issue suspected, not a silent mixed install and not an overrideable warning. The trace (line numbers from main):

  1. plan_update → select_production_wheels picks exactly the four prefixes — the constant has no keel_broker_alpaca (keel/commands/update.py:87-92), the selection at :363-397, wired at :426. The plan is offered; nothing refuses.
  2. run_update backs up the DBs, downloads those four wheels (:767-779) and installs them (:799-800, installed = True). keel_core / keel_broker_api / keel_broker_coinbase / keel_trader move to the new version; keel-broker-alpaca stays at the old one — nothing selects its wheel.
  3. Migrate runs (schema-only, succeeds).
  4. _verify_versions (:632-659) runs the NEW build's keel versions, which lists every keel distribution in the venv: the alpaca row is old (:649-651 → "keel-broker-alpaca is at X, not Y"), and keel versions itself exits 1 with PARTIAL INSTALL (keel/version.py:258, exit at keel/commands/versions.py:83) → UpdateError("verify failed: ...") at :659.
  5. installed is True, so the post-install failure branch runs (:809, :839-877): the previous wheels are re-installed from Release/ best-effort (:840-845), rolled_back=True, and the error points at the manual runbook procedure. The new wheels stay in Release/; the backups stay.

So every self-update on an equities deployment fails and reverts, from v0.10.0 — the first release publishing keel_broker_alpaca beside a config that selects it.

The fix

keel_broker_alpaca joins PRODUCTION_WHEEL_PREFIXES (now five). Selection, install, the superseded set, cleanup and the verify floor all derive from the one constant, so the adapter moves with the rest and keel versions' all-must-agree invariant holds by construction. The plan/gate/manual-recovery wording that said "four" follows.

The "awkward part", addressed

The issue weighs three shapes and leans to (2) (install venue wheels already present in the venv, keeping four as the floor). This PR takes (3) (the named five-element constant), deliberately:

  • The issue's cost argument against (3) — "a Coinbase deployment must not have Alpaca's requests dependency forced on it" — is void in fact: requests already rides every deployment transitively via the Coinbase SDK (coinbase-advanced-py depends on it; uv.lock). What (3) actually puts on a Coinbase-only box is one small unused adapter module.
  • (1) (derive from the running config's broker: name:) couples the updater to config parsing — the plan could no longer state its wheel set without first reading a deployment's config.
  • (2) is self-describing but makes the wheel set a per-deployment property: the gate wording and the runbook's named-path procedure could no longer state one list, and a deployment whose adapter was somehow missing would verify "healthy" (the remaining distributions agree) while its venue import stayed a release behind — the updater would have no way to heal it. (3) heals it.
  • The deliberate exclusions stay excluded and stay named: keel_broker_fake (registers a fake venue entry point), keel_broker_robinhood (an Ed25519 stack for an adapter nothing constructs) and keel_broker_kraken (the just-merged stub, #313 — every data/market method raises; it must never ride an update). The #: docstring on the constant re-states all of it, including why the set is stated by name rather than derived; if a third real venue ever ships, (2) is worth revisiting.
  • A test pins the set as an exact tuple, so any future venue is a deliberate edit to this list, never an accident of a glob.

Also stale (the issue's last section)

docs/RELEASING.md's "Release assets" table gains a keel_broker_alpaca-* row (a deployment installs it beside the four; keel update moves all five) and keel_broker_kraken-* joins the published-but-not-deployed row. The runbook follows: the manual procedure names the fifth wheel path (with the why), the keel versions sample shows five distributions, and the self-update section says five and names the kraken exclusion.

Tests (TDD: test commit first, then the fix)

  • New: the wheel set pinned as an exact tuple — keel_broker_alpaca IN; keel_broker_fake / keel_broker_robinhood / keel_broker_kraken OUT.
  • Extended: the selection test proves the excluded venues never ride even though the fake release carries them; verify is pinned to demand keel-broker-alpaca present at the target (the pre-fix "healthy" four-distribution output now fails verify); the run-order pin derives its download count from the constant instead of a hardcoded four; the runbook self-update pin demands the section name alpaca.
  • Nothing else in code or tests assumed a count of four (["download"] * 4 and the len(...) == 4 in the zero-byte-download test were the only sites; both now derive).

Gates

…truncated — full description in #504.

Wire the ratchet-only exit policy per rule family; prove rail-9 safety; measure it and keep it off (#442) (#505)

The three hypotheses, resolved with evidence

1. Ratchet-only satisfies rail 9 -- CONFIRMED, and now proven by test. Rail 9 (keel/execution/guards.py:493-505) vetoes only a proposed stop strictly lower than the last recorded open_stop:<product_id>. _roll_stop (keel/execution/executor.py) refuses a widening proposal before guards.check ever runs, so the rail only ever sees proposed >= prior. tests/execution/test_executor.py::test_a_ratchet_only_trail_can_never_trip_rail_9 drives trail_stop_atr through an adversarial (price, ATR) walk -- ratchet step, crash, partial recovery, rally, stall -- and asserts both halves: the recorded open_stop sequence is non-decreasing across the whole walk (widening proposals refused locally, no cancel/place, no state change), and the ratchet-clamped proposal checked directly against guards.check never trips no_stop_widening.

2. Live trailing is blocked on the port, not forgotten -- CONFIRMED, and split out to #502. Live exits ride ONE native Coinbase trigger-bracket placed at entry (_bracket_order_configuration -> trigger_bracket_gtc, a raw dict against the pre-port CoinbaseClient; place_bracket records the open_stop/open_target pair). Moving a stop means cancel-and-replace of that bracket -- and the broker port has no bracket/OCO OrderSpec kind at all (packages/keel-broker-coinbase/keel_broker_coinbase/translate.py::to_order_configuration: MarketIOC/LimitGTC/StopLimitGTC, assert_never beyond). The bracket reaches the venue only because the executor bypasses the port with a raw configuration dict. There is also no per-cycle management step in agent.run_once that could call the primitives. Filed as #502 (assignee eaitbrahim, project keel) rather than solved here.

3. Turtle's real exit is the Donchian channel -- CONFIRMED by the rule's own design. turtle_breakout's target_rr PARAM_DOC reads "nominal take-profit distance in R; the real exit is the channel-low"; the exit channel is deliberately asymmetric so winners run. A trail would cut exactly the long winners a low-win-rate trend-follower exists to let run. Turtle therefore carries no exit-policy knobs, policy_for reads it as OFF (pinned by test), and the module docstring says so.

What was wired, where

The exit policy the live primitives encode -- ratchet-only ATR trailing + break-even roll -- now runs on the engines that drive exits per bar:

  • keel/strategy/exit_policy.py (new): ExitPolicy + policy_for (reads trail_atr_mult/be_roll_rr/atr_period off a rule's params; OFF for any rule without the knobs) + next_stop (ratchet-only by construction: starts at the current stop, only takes maxima) + trailing_atr (Wilder ATR over a bounded 4*period+1 tail, None before period+1 bars).
  • keel/strategy/backtest.py and keel/sim/portfolio_sim.py apply it with identical sequencing: touch checks run against the stop carried INTO the bar, management runs at bar end on the completed bar -- no lookahead, and live-parity (the bracket rests at the old level until a management cycle replaces it). setup.stop remains the ORIGINAL risk reference for R-multiples; a BE-roll exit is honestly reported as a scratch/small loss, never as +0R fiction.
  • Per-family params, not a global knob (mirroring stop_method/atr_mult style): pullback_continuation and rsi_meanrev gain trail_atr_mult/be_roll_rr (Decimal | None, default None = OFF), with PARAM_DOCS entries and agent._DECIMAL_PARAMS coercion so rows/drivers pass them quoted. Default-off is pinned by byte-identical-backtest tests on both engines.
  • scale_out stays dormant: its tripwire test (test_scale_out_has_no_production_caller) pins three prerequisites (bracket resize for a partial SELL, trade_outcomes recording for rail 16) that are exactly #502's scope.
  • Paper trader stated as NOT wired (signal-driven, holds no Rule to read params from, and on_candle gets one candle at a time so the trailing ATR has no series); with defaults OFF no paper row can diverge today, and the knobs' PARAM_DOCS say "sim/backtest engines only".

Before/after at the 120 bp fee (pre-registered: docs/experiments/2026-08-22-trailing-vs-static-exits.py + .md + .jsonl)

240 cells (2 families x 30 products x 4 arms) through the production-faithful backtester, full cached ONE_HOUR history, 120 bp/leg taker fee, 5 bp/leg slippage. Pooled:

family arm n win rate expectancy PF
pullback static 3,261 0.1435 -68.00 0.0075
pullback trail 1.5 3,697 0.0933 -61.89 0.0017
rsi static 1,201 0.2714 -108.89 0.0776
rsi trail 1.5 1,213 0.1665 -111.40 0.0197

…truncated — full description in #505.

Review follow-ups: strict workflow parses, blank sonar.organization, promote's three-check gate, eight distributions (#506)

Source

Independent whole-branch review of the 2026-08-22 delivery. Four findings, four small fixes; no issue closes (review follow-ups).

The findings and what changed

1. Strict-parse gap — tests/test_security_scans.py parsed code-quality.yml with plain yaml.safe_load

PyYAML resolves duplicate mapping keys last-key-wins, so a workflow edit leaving a stale key behind would pass every pin in the file and only fail at GitHub's STRICT parser at dispatch — the exact v0.11.0 incident class. tests/test_desktop_packaging.py already carried the _StrictLoader/_strict_load discipline for release.yml.

  • Hoisted the loader into a shared helper, tests/_workflow_yaml.py (strict_load), following the existing shared-helper precedent of tests/conftest.py imports.
  • All three code-quality.yml parses in test_security_scans.py now go through it (_load_workflow).
  • test_desktop_packaging.py imports the shared loader instead of carrying its own copy — same discipline, one definition.
  • New test proving the loader REJECTS a duplicate-key workflow text (self-contained string fixture; the same text loads once the stale key is removed).
2. Blank sonar.organization readiness hole — code-quality.yml preflight

The readiness grep accepted sonar.organization= (uncommented, EMPTY value) as ready. SonarCloud's mandatory-property refusal fires for an empty value exactly as for a missing one, so that match would flip sonar_ready to true and redden every push to main again — #402's failure, one edit later.

  • Regex tightened to ^[[:space:]]*sonar\.organization[[:space:]]*=[[:space:]]*[^[:space:]].
  • New pinning test extracts the grep pattern from the workflow itself (through the strict loader), asserts the non-blank tail is present, and behaviourally checks it via a POSIX-to-Python translation: sonar.organization= / sonar.organization=<spaces> / #sonar.organization=x are NOT ready; only a line with a value is.
3. rules promote --help understated the gauntlet — keel/commands/rules.py

The docstring said the gate is "TWO things" (G2 + G4); since #495 it is THREE — the lookahead analysis runs FIRST, fail-closed (#440), then the floors, then PBO. Help now names all three in order, notes --pbo-session supplies the third, and states --force is the one documented bypass for the whole gauntlet. Docstring only; no test pinned the old wording.

4. Dependabot header count — plus stragglers

.github/dependabot.yml said "seven distributions: the root plus six under packages/"; kraken made it eight (root + seven). Also swept: "five workspace members" in code-quality.yml (x3) and sonar-project.properties (now seven, with alpaca/kraken named), and stale totals in two test docstrings ("six distributions today", "touches seven pyproject.toml files"). security.yml was already corrected to "eight" in #499 (verified). release.yml's "seven pyproject.toml files" is #424 history — kraken landed after — and stays.

Gates

  • uv run pytest -q: 4349 passed, 3 skipped (delta: +2 tests, both in test_security_scans.py)
  • uv run ruff check tests keel: clean
  • uv run ruff format --check on touched files: clean
  • uv run mypy: clean (344 files)

Review follow-ups only — deliberately NOT merged.

feat(alerting): per-event opt-in notifications over the generic webhook (#444) (#507)

Closes #444

What this adds

An outbound-only notification layer beside the existing CRITICAL webhook: a closed five-event taxonomy, per-event opt-in (Freqtrade notification_settings shape, default OFF), the generic webhook as the one transport, and two payload formats (plain JSON + Slack-compatible {"text": ...}).

The taxonomy
Event key Category Fires when (threshold = the one keel doctor computes)
attestation.expiring attestation rail-17 withdrawal attestation has ≤2 of its 7 TTL days left, or has expired (doctor's attest.withdrawals WARN/FAIL)
rail.armed rail rail 16's streak halt arms (streak_halt_until > now) or rail 11's drawdown reaches 20% (doctor's HALTED/FAIL)
setup.unplaced execution a cycle detected an entry setup and could not place it (placed=False on any enter result)
allowance.nearing_exhaustion allowance month-to-date BUY spend ≥80% of the in-force rail-14 allowance (unlimited never fires)
feed.stale_open_position data a product in this cycle's stale set with an open position in it (without a position: no event)

All five are warn severity — each gates money or safety. Answers the issue's open question in the affirmative: an expiring attestation does warrant proactive notification, because rail 17 fails closed and silently (the incident in the issue — a real setup vetoed for weeks — is exactly this event).

The notify-only guarantee

  • No remote control surface, of any kind. No command, query or capability arrives through notifications. There is no inbound parsing, no action dispatch — the layer derives state and POSTs.
  • Zero new gate call sites. Nothing here increases any capability, so nothing here needs _require_interactive_confirmation — keel/capabilities.py and tests/test_capabilities.py are untouched and the AST scanner stays green. That is the correct outcome, not an omission: #436's TTY gates cover every capability-increasing action and this PR adds none.
  • Writes nothing back. notify_after_cycle reads the same repo keys doctor's gather_findings reads and sends; pinned by a test asserting zero state writes.

Wiring choice: post-cycle hook in run_once (not a keel notifications --check command)

run_once's tail is a clean seam — every fact the five events need is already in hand there (stale_products, the parallel signal/result lists) or one repo read away — so the hook runs after all trading work, fire-and-forget, wrapped in a swallow-everything try/except (a notification failure must NEVER cost a cycle; pinned by tests at both the transport and wiring levels). A separate --check command would have re-derived the same state from the CLI surface with no loop integration; the hook covers scheduled operation directly, which is where the silent-veto incident lived. The import is lazy (same pattern doctor's own gather_findings uses) so the loop's import graph stays lean.

Config

# default: section absent = entirely disabled
notifications:
  format: plain        # or slack
  events:
    attestation.expiring: true
    rail.armed: true
  • URL source: the existing KEEL_ALERT_WEBHOOK (env, then git-ignored .env, via resolve_webhook_url) — not a new notifications.webhook_url config key, because a webhook URL is closer to a credential than to configuration and config.yaml is committed. This also keeps ONE url driving both channels, and "no URL → no handler → zero network" is already the pinned contract of the existing module — reused verbatim.
  • The section's type is the single NotificationSettings class, defined in keel_core/notifications.py beside the taxonomy it opts into — no config-side duplicate to drift.
  • Unknown event keys in settings are ignored (they match nothing in the closed taxonomy); an unknown format is refused at load.

Deviations from the sketch, with reasons

…truncated — full description in #507.

Robinhood: pre-flight sizing at placement + a decided best_bid_ask contract (#410, #413) (#508)

Closes #410, Closes #413

Two Robinhood adapter fixes, test-first (the pin commit lands before the fix commit).

#410 — no pre-flight sizing check

Root cause. trading_pairs carries the per-pair rules (min_order_amount, asset_increment, max_order_size) and nothing read them before submission: place_order placed exactly what it was given, and a sub-minimum or off-increment order found out at the venue. #418 had added reporting of the bounds on the preview path only, and a test pinned place_order's non-consultation as the boundary.

Fix shape (all constraints from the issue honoured):

  • place_order pre-flights the size through _preflight after _reject_unsupported/to_symbol, before any body is built. A refused entry never reaches create_order.
  • Entry (BUY): refused with PlaceResult(success=False, reason=...) naming the bound, the requested value, and its denomination — e.g. sub-minimum: notional 0.00065000 USD … is below the venue's min_order_amount 0.1 …, which is a QUOTE-currency (USD) minimum, not a base-size minimum. min_order_amount is checked only where a price is already in hand (a limit order's own limit_price); it is never manufactured from an estimated_price request.
  • Exit (SELL): never refused — off-increment sizes are floored to asset_increment (exact Decimal quantize, ROUND_FLOOR; a below-one-tick size is placed as given rather than minting a "0" body), and sub-minimum/over-max exits proceed and let the venue answer. A bounds read that fails also places as given — the check degrades, it never traps.
  • Costs one request per symbol per process: _pair_rules now caches per symbol for the adapter's lifetime (the _account() shape). Only successful reads are cached, so a transient failure retries rather than silently disabling the check.
  • Venue constants come from the live-established facts already recorded in _PairRules (min is QUOTE-denominated $0.10-style, optional per pair — 63 of 89; max_order_size/asset_increment are BASE) plus tests/fixtures/rh_trading_pairs.json (the observed BTC-USD row). Nothing was invented; min_order_size is not looked for.

Test delta: the old test_place_order_does_not_consult_the_sizing_bounds (the pin of the pre-#410 boundary) is replaced by a battery covering: entry sub-minimum (exact refusal text), entry off-increment (exact text), entry over-max, pair without min_order_amount (places), exit sub-min/over-max/off-increment (round + proceed, asserted on the wire body), below-one-tick exit, once-per-symbol caching, failed-read retry/not-cached, market-BUY base-bound check, and market-BUY quote-minimum skip (no estimated_price call).

#413 — best_bid_ask is not a coherent book

Root cause. The endpoint samples its two legs independently and stamps them with one timestamp; where the true spread is under the sampling jitter (BTC, ETH, DOGE — crossings ≲1.4 bps) the legs arrive crossed (bid > ask), persistently. Nothing consumed the endpoint, so the contract was undecided — exactly the state the issue said must not be left for a first consumer to pick silently. (#414 had already corrected the fixture and documented the transport; estimated_price-based preview_order was already self-consistent and is unaffected.)

Fix shape — the contract is decided and encoded: RobinhoodAdapter.best_bid_ask(product_id) returns (bid, ask) only when the venue's own numbers order coherently (bid < ask), and None for a crossed, locked, or unreadable row — never a crossed book, never a raise. The lo = min(bid, ask) / hi = max(...) normalisation was rejected and recorded as such: it would launder two independently sampled legs into a snapshot neither vouches for, and to_price_side's BUY→ask / SELL→bid mapping would then price both directions off the optimistic leg — the exact bias that mapping exists to prevent. estimated_price remains the only pricing source. None is the expected answer on the tightest pairs; the docstring says so. Any future spread consumer inherits a non-positive-tolerant contract by construction.

Test delta: crossed BTC-USD row → None (read filtered by symbol); coherent XLM-USD row → passes through unchanged as Decimals; locked market → None; unreadable row → None; transportless adapter → the standard clear RuntimeError.

Doc retirements

transport.get_trading_pairs' "deliberately not called by the adapter yet" framing, get_best_bid_ask's "nothing calls this method today", the matching claim in test_transport.py, and the README's "proposed in #198" paragraph are all updated to the shipped state; the adapter module docstring gains the best_bid_ask decision beside the other two venue gaps.

Gates

uv run pytest -q → 4140 passed, 3 skipped. uv run ruff check keel tests packages → clean. uv run ruff format --check on all touched files → clean (pre-existing drift in untouched translate.py/test_translate.py left alone). uv run mypy → no issues in 319 files.

feat: partial fills as a distinct state, filled-quantity basis, oversized-bracket detection (#446) (#509)

Closes #446.

What partial fills look like in venue data (the field mapping)

Every adapter already normalizes to the port's OrderStatus (packages/keel-broker-api/keel_broker_api/results.py), which carries the three fields partial-fill recognition needs:

venue status spelling fill qty average price
Coinbase (cb_client.get_order) OPEN while partially working, FILLED, CANCELLED/EXPIRED filled_size (base) average_filled_price
Alpaca partially_filled -> port OPEN (translate.STATE_TO_PORT_STATUS) filled_qty filled_avg_price
Robinhood partially_filled -> port OPEN (doc-sourced spelling, see #198) filled_asset_quantity average_price
Kraken stub (get_order raises NotImplementedError) — —

Two facts fall out of that mapping and shape the implementation: average_filled_price is cumulative (already quantity-weighted across every fill so far — we observe the weighted average, we don't recompute it from increments), and the port status vocabulary deliberately folds partially_filled into OPEN, which is why recognition belongs on keel's side (0 < filled_size < ordered) rather than in the adapters.

Frequency assessment (the issue's prerequisite, recorded honestly)

Rare by construction, and we have no production number to cite — because the state was never recorded, which is the gap itself. keel places market IOC orders on liquid spot pairs (BTC/ETH/PAXG/SOL/XLM/LTC/ADA/LINK per config.live.yaml) at risk-sized notional: risk_pct: 0.01 against max_exposure_usd: 5000, and the template's own comment puts risk-sized rule orders at "$400-24k typical". A market IOC at that size sweeps the top of a deep book; a partial is the thin-book / self-trade-prevention case, not the norm. That rarity changes the priority of auto-remediation, not the validity of recognizing the state — also recorded in the reconcile.py module docstring. No production data was fabricated.

What landed

  1. Distinct non-terminal state. 0 < filled_size < ordered moves the row to partially_filled (free-text status column — the VALUE needs no migration), with the observed fill in a new filled_quantity column (schema v11: ALTER TABLE guarded by PRAGMA table_info, the v8 pattern; NULL on pre-v11 rows = "not observed", never zero). qty keeps meaning the ORDERED size. The row stays in the sweep's polling set until terminal.
  2. Average across fills. actual_fill carries the venue's cumulative average — exact Decimal, quantity-weighted by construction; a partial -> more -> full sequence is pinned step by step in tests (48900 -> 49500 -> 50070 on hand-computed fills). A missing venue price is left alone rather than recorded as 0, which would read as a free position in the basis.
  3. Rail 8 basis fixed. The averaging check now includes partially_filled BUYs at their FILLED quantity and observed average. The old status="filled"-only query skipped them (basis blind to held inventory); weighting by ordered size would bias toward a price that bought nothing. Tests discriminate both wrong turns against the true 50200 basis.
  4. Bracket policy: DECIDED and documented, safe half implemented. With no bracket/OCO kind on the port (#502), auto-resize means either amending a live native bracket or auto-cancelling a protective order on a possibly-settling snapshot — a wrong auto-action on live money. So: detect and surface. The entry path records the observed fill and warns executor.entry_partially_filled; reconcile warns reconcile.order_partially_filled; doctor gets a fill.partial WARN naming the order and both sizes with the manual remedy (cancel & re-place at the filled size). The amend-vs-cancel-and-replace decision is filed into #502 with the three concrete options and their failure modes: #502 (comment)

What went to #502 (not duplicated here)

  • The bracket resize/amend path itself (needs the port's bracket/OCO OrderSpec kind).
  • The exit-side sibling: a partially-filled market SELL still books exit_qty=position.qty in agent._close_tranches — surfaced by the same detection, but booking it correctly belongs with bracket/exit resize work. Noted in the #502 comment.

Tests (TDD: red commit first, then implementation)

17 new/changed tests: state transitions incl. partial->partial->full and partial->canceled; polling-set membership; idempotence; weighted-average exactness (Decimal); rail-8 basis under partial history (both discriminators); entry-side detection + no-warning negative control + bracket-still-ordered-size pin; doctor ok/warn/remedy/paper-boundary; migration fresh + v10-ALTER + NULL back-compat; old-row back-compat. Plus the two schema-version tripwires bumped to 11.

Gates

  • uv run pytest -q: 4395 passed, 3 skipped
  • uv run ruff check keel tests packages: clean
  • uv run mypy: clean (346 files)
  • ruff format --check on the touched files: no NEW drift (7 of them were already drifted on origin/main, verified against the baseline; the repo-wide format gate has 124 pre-existing files)

Not merged

…truncated — full description in #509.

fix(executor): quantize order size to the venue's precision (#513) (#514)

Closes #513.

The first turtle_breakout entry ever to reach Coinbase was rejected with INVALID_SIZE_PRECISION — not by a rail, by str(Decimal).

Root cause

sizing.size() returns (equity * risk_pct) / stop_distance, essentially never round, and _order_configuration serialised every digit: quote_size: "23.00803473938010547532738517".

Why it hid for weeks: DCA's budget_usd is a round constant. Orders 1 and 2 sent 26 decimal places too and filled, because the value was exactly 50. Only risk-sized orders produce arbitrary Decimals, and the live turtles had never fired until 2026-08-22.

The fix

quantize_down / quote_increment_for in sizing.py, promoted from the reasoning already in scripts/robinhood_order_probe.py.

  • Down, never nearest. guards.check already approved a specific notional against the per-order and per-day caps; rounding UP after the rails run would spend more than they authorised.
  • Unknown increment refuses the order rather than guessing a precision, consistent with every other unknown here.
  • A notional that quantizes to zero refuses rather than sending a zero-size order.

Writing the tests caught a bug in the fix

.normalize() renders Decimal("50") as Decimal("5E+1") — str() of that is "5E+1", which would have gone on the wire for a round $50 DCA buy and broken the one order shape that currently works. Flooring to a multiple handles arbitrary increments; a final quantize fixes presentation. There is a test named for it.

Deliberate scope boundary

SELL is left unquantized. base_size needs the product's base_increment, which varies per asset and which nothing on the Coinbase path fetches yet (no increment reference in packages/keel-broker-coinbase/ or cb_client.py, and no products table). Guessing one could round an exit down to dust or fail it outright, and an exit that cannot leave is strictly worse than the bug being fixed. A test documents the boundary so a future change is a decision rather than an accident. Follow-up noted on #513.

Verification

  • pytest — 4,479 passed, 3 skipped
  • ruff check + ruff format --check clean, mypy clean on both changed modules
  • Regression test built from the real rejected payload in keel-live.db

Reviewer attention

  1. Is refusing on an unknown quote currency right, or should it fall back to 0.01 for anything fiat-shaped? I chose refuse.
  2. QUOTE_INCREMENTS is keyed on currency, not product. Correct for this venue; would need revisiting for one with per-product quote increments.
  3. The SELL boundary — agree it belongs in a follow-up, or should it block this?

chore(release): 0.11.1 (#515)

Version bump so #514's size-precision fix can be released. RELEASING.md step 1: "Bump the version in a reviewed PR... CI refuses to change the version itself — that decision belongs in a PR a human reviewed."

Patch, not minor. No interface changed. The only behavioural difference is that a risk-sized BUY now serialises a value Coinbase accepts, and an order whose size cannot be expressed in the venue's units refuses instead of being sent.

What moved

All eight distributions, together — keel-trader plus the seven workspace packages — the == sibling pins inside each package's dependencies, and uv.lock.

Both halves were caught by the repo's own gates rather than by me remembering:

  • keel versions failed mid-bump with PARTIAL INSTALL: 8 keel distributions at 2 different versions (0.11.0, 0.11.1) after only keel-trader had moved.
  • tests/test_packaging.py::test_workspace_siblings_are_pinned_to_the_exact_version then failed on the stale ==0.11.0 pins, which bumping the version fields alone leaves behind.

Verification

  • pytest -q — 4,480 passed, 3 skipped
  • ruff check keel tests packages — clean
  • mypy — clean, 351 source files
  • keel versions — ok: 8 keel distributions, all at 0.11.1

After merge

Per RELEASING.md step 2: Actions → Release → Run workflow, entering 0.11.1. That is the step that tags v0.11.1, verifies the live config asset is mode: confirm, and composes the release assets.

Deployment is currently running 0.11.0+f5f471f9118b.