Repository navigation
keel v0.11.1
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.
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 thekeel versionscheck) - 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_vetoedevents from the JSONL log aggregated by reason; one reason vetoing everything is a FAIL naming its fix — the motivating case (15 of 15 onsubscription_unattested) is reproduced as a test
Verified
- 16 tests (pure functions over plain values and log lines — no DB needed)
- ruff + mypy clean;
keel versionstests 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 forrsi_meanrev, 2×ATR forturtle_breakout),
sizing each edge withexecution.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, theequity_overridefallback); - ATR(14) computed on the finest configured granularity (smallest
GRANULARITY_SECONDSstep), 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 skippeduv run ruff check keel tests: clean;ruff format --checkon 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:
- 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 ofCAPABILITIESbecause the registry covers only GATED mutators —keel subscription attestandkeel rules promote --forcehave 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). - Registry mapping — no
keel/mcp/*.pyreferences any capability row'smodule.function, dotted or bare; every handler's source file must live underkeel/mcp/. - 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). - No gate call sites — zero calls to
_require_interactive_confirmation: a server must never hold the ceremony gate. - Docs pin —
docs/mcp-server.md's tool table equals the exposed tool set, bidirectionally. - Import surface —
keel.mcp.serverimports withoutclickand withoutkeel.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 --checkon all new files — cleanuv 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 helperssizing_equity(mark_to_market, pending_purification) -> max(mtm - pending, 0)andpending_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 throughsizing_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_usdcounts 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 exactlysizing.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) andrecursive_analysis(candles, indicator_fn, ...)(warmup-drift), plusrender_linesfor both.keel rules lookahead <id>— the operator diagnostic over a saved rule (exit 0 clean / 1lookahead_detected, likedoctor), with--granularity,--sample-step,--recursive.- Promotion enforcement —
rules promoterefuses a rule whose lookahead verdict islookahead_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_okreads the coarsest TF): a rule keyed on closed coarse bars sees identical data in both views (clean); acoarse[-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): onerandom.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, whichequity_curveexposes 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 fromcandles[0].tsat the rule's ownstep_sec, freshCandleinstances always (inputs never aliased or mutated). This is the valuable null: it preserves the local autocorrelation a naive IID resample would destroy -- exactly the structureturtle_breakouttrades. 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.Randominstance. - 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). --seedis 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:--helpsurface, a full trades-mode invocation against a seeded tmp db asserting exit 0, output lines, exactly onediagnostic_only/monte_carloledger 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: cleanuv run ruff format --check(new/touched files): cleanuv 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 passeduv run mypy— Success, no issues in 332 filesuv 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)
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.organizationis mandatory on SonarQube Cloud and is deliberately commented out insonar-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 (thesnyk monitorstep 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:
preflightnow emitssonar_ready(token present and an activesonar.organization=line insonar-project.properties) andsnyk_ready(token present and the newSNYK_ORGrepository 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_dispatchstill fails loudly when a scan is not ready (silently ignoring a direct request is worse);push/scheduleskip with a run summary naming exactly what is missing and where to set it. - The skipped-step notice names both the property (
sonar.organization, insonar-project.properties) and the variable (SNYK_ORG, Settings → Secrets and variables → Actions → Variables). A missingsonar-project.propertiesin 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 exactkeel withdrawals attest --enabledrenewal. - #310 —
docs/rails/rail-14-subscription-allowance.md: rail 14 explained as the profitability boundary (citesdocs/research/2026-08-20-quant-lab-note-cross-verification.md§5), with where the setting lives (thebroker_subscriptionstable viakeel subscription attest, NOT config.yaml),even_dailypacing, the fail-closed fallbacks, the veto messages, and inspection viakeel 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 fetchwithout a key fails with AuthenticationError). - #312 —
keel fetch --helpandkeel monitor --helpno longer read as synonyms: fetch states its one-shot warm/refresh +--checkverification 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 tenBrokermethods present) and honest — every data/market method raisesNotImplementedError("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.brokersentry point,uv.lockrefreshed, dependabot + security-scan export registered, strict mypy from birth withpy.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.pydrivers; preamble states what the directory is (pre-registered, reproducible records; a committed script is a claim its numbers reproduce; decisions land intrials-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 withneeds_reviewrows, and that discharge is not recorded (total_owed_usdis lifetime-cumulative). Consistent with the runbook's #490 sizing-equity note. - #316 —
tests/test_python_floor.py: new test pins[tool.mypy] python_versionto every distribution'srequires-pythonfloor, 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 bystep(defaultstep = test_bars,
i.e. non-overlapping tests; a smaller step overlaps them). The last fold never exceeds
n_bars; impossible geometry raisesValueErrornaming 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-foldFoldMetrics(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_notedescribes ("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_tradesat 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: nokey=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--WalkForwardReportcarries 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-urlwhich 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_DIRis 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
uvprocesses running in the background. Starting with uv version0.8.24this action usesuv cache prune --ci --forceto 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.xthis 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
- Use node24 instead of node20
@eifinger(#608)- Remove deprecated input server-url
@eifinger(#607)🐛 Bug fixes
- Respect UV_CACHE_DIR and cache-dir
@eifinger(#612)- Use --force when pruning cache
@eifinger(#611)- Respect UV_NO_MODIFY_PATH
@eifinger(#603)- Warn when
UV_CACHE_DIRhas changed@jamesbraza(#601)🚀 Enhancements
- Shortcut to latest version for minimum version specifier
@eifinger(#598)🧰 Maintenance
- Bump dependencies
@eifinger(#613)- Fix test-uv-no-modify-path
@eifinger(#604)…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 bytests/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'sdca.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.pyand pinned bytests/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 updateon an equities deployment (config.paper-equities.yaml,broker: name: alpaca,keel-broker-alpacainstalled 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 frommain):
plan_update→select_production_wheelspicks exactly the four prefixes — the constant has nokeel_broker_alpaca(keel/commands/update.py:87-92), the selection at:363-397, wired at:426. The plan is offered; nothing refuses.run_updatebacks 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_tradermove to the new version;keel-broker-alpacastays at the old one — nothing selects its wheel.- Migrate runs (schema-only, succeeds).
_verify_versions(:632-659) runs the NEW build'skeel versions, which lists every keel distribution in the venv: the alpaca row is old (:649-651→ "keel-broker-alpaca is at X, not Y"), andkeel versionsitself exits 1 withPARTIAL INSTALL(keel/version.py:258, exit atkeel/commands/versions.py:83) →UpdateError("verify failed: ...")at:659.installedis True, so the post-install failure branch runs (:809,:839-877): the previous wheels are re-installed fromRelease/best-effort (:840-845),rolled_back=True, and the error points at the manual runbook procedure. The new wheels stay inRelease/; the backups stay.So every self-update on an equities deployment fails and reverts, from
v0.10.0— the first release publishingkeel_broker_alpacabeside a config that selects it.The fix
keel_broker_alpacajoinsPRODUCTION_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 andkeel 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
requestsdependency forced on it" — is void in fact:requestsalready rides every deployment transitively via the Coinbase SDK (coinbase-advanced-pydepends 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 afakevenue entry point),keel_broker_robinhood(an Ed25519 stack for an adapter nothing constructs) andkeel_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 akeel_broker_alpaca-*row (a deployment installs it beside the four;keel updatemoves all five) andkeel_broker_kraken-*joins the published-but-not-deployed row. The runbook follows: the manual procedure names the fifth wheel path (with the why), thekeel versionssample 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_alpacaIN;keel_broker_fake/keel_broker_robinhood/keel_broker_krakenOUT.- Extended: the selection test proves the excluded venues never ride even though the fake release carries them; verify is pinned to demand
keel-broker-alpacapresent 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"] * 4and thelen(...) == 4in 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 recordedopen_stop:<product_id>._roll_stop(keel/execution/executor.py) refuses a widening proposal beforeguards.checkever runs, so the rail only ever seesproposed >= prior.tests/execution/test_executor.py::test_a_ratchet_only_trail_can_never_trip_rail_9drivestrail_stop_atrthrough an adversarial (price, ATR) walk -- ratchet step, crash, partial recovery, rally, stall -- and asserts both halves: the recordedopen_stopsequence is non-decreasing across the whole walk (widening proposals refused locally, no cancel/place, no state change), and the ratchet-clamped proposal checked directly againstguards.checknever tripsno_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-portCoinbaseClient;place_bracketrecords theopen_stop/open_targetpair). Moving a stop means cancel-and-replace of that bracket -- and the broker port has no bracket/OCOOrderSpeckind at all (packages/keel-broker-coinbase/keel_broker_coinbase/translate.py::to_order_configuration: MarketIOC/LimitGTC/StopLimitGTC,assert_neverbeyond). 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 inagent.run_oncethat 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'starget_rrPARAM_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_forreads 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(readstrail_atr_mult/be_roll_rr/atr_periodoff 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 bounded4*period+1tail,Nonebeforeperiod+1bars).keel/strategy/backtest.pyandkeel/sim/portfolio_sim.pyapply 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.stopremains 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_multstyle):pullback_continuationandrsi_meanrevgaintrail_atr_mult/be_roll_rr(Decimal | None, defaultNone= OFF), withPARAM_DOCSentries andagent._DECIMAL_PARAMScoercion so rows/drivers pass them quoted. Default-off is pinned by byte-identical-backtest tests on both engines.scale_outstays dormant: its tripwire test (test_scale_out_has_no_production_caller) pins three prerequisites (bracket resize for a partial SELL,trade_outcomesrecording for rail 16) that are exactly #502's scope.- Paper trader stated as NOT wired (signal-driven, holds no
Ruleto read params from, andon_candlegets 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_loadPyYAML 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_loaddiscipline for release.yml.
- Hoisted the loader into a shared helper,
tests/_workflow_yaml.py(strict_load), following the existing shared-helper precedent oftests/conftest.pyimports.- 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.organizationreadiness hole — code-quality.yml preflightThe 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 flipsonar_readyto 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=xare NOT ready; only a line with a value is.3.
rules promote --helpunderstated the gauntlet — keel/commands/rules.pyThe 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-sessionsupplies the third, and states--forceis the one documented bypass for the whole gauntlet. Docstring only; no test pinned the old wording.4. Dependabot header count — plus stragglers
.github/dependabot.ymlsaid "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: cleanuv run ruff format --checkon touched files: cleanuv 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_settingsshape, 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 doctorcomputes)attestation.expiringattestation rail-17 withdrawal attestation has ≤2 of its 7 TTL days left, or has expired (doctor's attest.withdrawalsWARN/FAIL)rail.armedrail rail 16's streak halt arms ( streak_halt_until > now) or rail 11's drawdown reaches 20% (doctor's HALTED/FAIL)setup.unplacedexecution a cycle detected an entry setup and could not place it ( placed=Falseon any enter result)allowance.nearing_exhaustionallowance month-to-date BUY spend ≥80% of the in-force rail-14 allowance (unlimited never fires) feed.stale_open_positiondata a product in this cycle's stale set with an open position in it (without a position: no event) All five are
warnseverity — 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.pyandtests/test_capabilities.pyare 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_cyclereads the same repo keys doctor'sgather_findingsreads and sends; pinned by a test asserting zero state writes.Wiring choice: post-cycle hook in
run_once(not akeel notifications --checkcommand)
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-everythingtry/except(a notification failure must NEVER cost a cycle; pinned by tests at both the transport and wiring levels). A separate--checkcommand 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 owngather_findingsuses) 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, viaresolve_webhook_url) — not a newnotifications.webhook_urlconfig key, because a webhook URL is closer to a credential than to configuration andconfig.yamlis 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
NotificationSettingsclass, defined inkeel_core/notifications.pybeside 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
formatis 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)
Two Robinhood adapter fixes, test-first (the pin commit lands before the fix commit).
#410 — no pre-flight sizing check
Root cause.
trading_pairscarries the per-pair rules (min_order_amount,asset_increment,max_order_size) and nothing read them before submission:place_orderplaced 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 pinnedplace_order's non-consultation as the boundary.Fix shape (all constraints from the issue honoured):
place_orderpre-flights the size through_preflightafter_reject_unsupported/to_symbol, before any body is built. A refused entry never reachescreate_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_amountis checked only where a price is already in hand (a limit order's ownlimit_price); it is never manufactured from anestimated_pricerequest.- Exit (SELL): never refused — off-increment sizes are floored to
asset_increment(exactDecimalquantize,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_rulesnow 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_incrementare BASE) plustests/fixtures/rh_trading_pairs.json(the observed BTC-USD row). Nothing was invented;min_order_sizeis 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 withoutmin_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 (noestimated_pricecall).#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-basedpreview_orderwas 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), andNonefor a crossed, locked, or unreadable row — never a crossed book, never a raise. Thelo = 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, andto_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_priceremains the only pricing source.Noneis 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 asDecimals; locked market →None; unreadable row →None; transportless adapter → the standard clearRuntimeError.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 intest_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 --checkon all touched files → clean (pre-existing drift in untouchedtranslate.py/test_translate.pyleft 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)OPENwhile partially working,FILLED,CANCELLED/EXPIREDfilled_size(base)average_filled_priceAlpaca partially_filled-> portOPEN(translate.STATE_TO_PORT_STATUS)filled_qtyfilled_avg_priceRobinhood partially_filled-> portOPEN(doc-sourced spelling, see #198)filled_asset_quantityaverage_priceKraken stub ( get_orderraisesNotImplementedError)— — Two facts fall out of that mapping and shape the implementation:
average_filled_priceis 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 foldspartially_filledintoOPEN, 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/LINKperconfig.live.yaml) at risk-sized notional:risk_pct: 0.01againstmax_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 thereconcile.pymodule docstring. No production data was fabricated.What landed
- Distinct non-terminal state.
0 < filled_size < orderedmoves the row topartially_filled(free-text status column — the VALUE needs no migration), with the observed fill in a newfilled_quantitycolumn (schema v11:ALTER TABLEguarded byPRAGMA table_info, the v8 pattern; NULL on pre-v11 rows = "not observed", never zero).qtykeeps meaning the ORDERED size. The row stays in the sweep's polling set until terminal.- Average across fills.
actual_fillcarries 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.- Rail 8 basis fixed. The averaging check now includes
partially_filledBUYs at their FILLED quantity and observed average. The oldstatus="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.- 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 warnsreconcile.order_partially_filled; doctor gets afill.partialWARN 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
OrderSpeckind).- The exit-side sibling: a partially-filled market SELL still books
exit_qty=position.qtyinagent._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 skippeduv run ruff check keel tests packages: cleanuv run mypy: clean (346 files)ruff format --checkon the touched files: no NEW drift (7 of them were already drifted onorigin/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_breakoutentry ever to reach Coinbase was rejected withINVALID_SIZE_PRECISION— not by a rail, bystr(Decimal).Root cause
sizing.size()returns(equity * risk_pct) / stop_distance, essentially never round, and_order_configurationserialised every digit:quote_size: "23.00803473938010547532738517".Why it hid for weeks: DCA's
budget_usdis 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_forinsizing.py, promoted from the reasoning already inscripts/robinhood_order_probe.py.
- Down, never nearest.
guards.checkalready 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()rendersDecimal("50")asDecimal("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 finalquantizefixes presentation. There is a test named for it.Deliberate scope boundary
SELL is left unquantized.
base_sizeneeds the product'sbase_increment, which varies per asset and which nothing on the Coinbase path fetches yet (noincrementreference inpackages/keel-broker-coinbase/orcb_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 skippedruff check+ruff format --checkclean,mypyclean on both changed modules- Regression test built from the real rejected payload in
keel-live.dbReviewer attention
- Is refusing on an unknown quote currency right, or should it fall back to 0.01 for anything fiat-shaped? I chose refuse.
QUOTE_INCREMENTSis keyed on currency, not product. Correct for this venue; would need revisiting for one with per-product quote increments.- 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.mdstep 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-traderplus the seven workspace packages — the==sibling pins inside each package'sdependencies, anduv.lock.Both halves were caught by the repo's own gates rather than by me remembering:
keel versionsfailed mid-bump withPARTIAL INSTALL: 8 keel distributions at 2 different versions (0.11.0, 0.11.1)after onlykeel-traderhad moved.tests/test_packaging.py::test_workspace_siblings_are_pinned_to_the_exact_versionthen failed on the stale==0.11.0pins, which bumping theversionfields alone leaves behind.Verification
pytest -q— 4,480 passed, 3 skippedruff check keel tests packages— cleanmypy— clean, 351 source fileskeel versions—ok: 8 keel distributions, all at 0.11.1After merge
Per
RELEASING.mdstep 2: Actions → Release → Run workflow, entering0.11.1. That is the step that tagsv0.11.1, verifies the live config asset ismode: confirm, and composes the release assets.Deployment is currently running
0.11.0+f5f471f9118b.